diff --git a/CHANGELOG.md b/CHANGELOG.md
index 620ca7c9..d3e165a6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
### Added
+- **Console for the browser preview** ([#466](https://github.com/raiseCatError/notMyShell/issues/466); [design](docs/design/document-workspace.md#console-runtime-diagnostics-of-the-browser-preview)). In the Chromium preview of a Markdown or HTML document, **`C`** lists what the page itself reported for the document now loaded: script exceptions, `console` errors, warnings and messages, failed loads and HTTP errors, Content Security Policy refusals and requests NMSh refused. It works like Problems — newest first, Tab filters by severity, typing searches, Shift+↑↓ marks, Del clears, Ctrl+C shows exactly what would be copied before copying — and Enter goes to the line in the file when that is certain (an HTML document unchanged since the page loaded); otherwise it says why. The Console only reads: it never turns scripts or the network on (with scripts off, only loading and policy messages appear), accepts no expressions, and ignores `/browser` pages and the Mermaid renderer. Entries stay in memory for the current load, bounded, with the preview's address, query values, tokens and likely secrets redacted before they are kept. The status line counts errors and warnings.
+- **`/doctor` reports document rendering** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). A Documents section names the Chrome or Chromium the previews would use (its version once a browser surface has checked it this session, and whether it is running), the Mermaid it found (path, version, a warning for a major version other than the tested 12), the Mermaid setting and, when diagrams would not be drawn, why. Nothing is launched and the Mermaid bundle is not read, so it says what was found, not that drawing works.
- **Mermaid diagrams in Markdown** ([#462](https://github.com/raiseCatError/notMyShell/issues/462); [design](docs/design/diagrams.md)). A ```` ```mermaid ```` fence is drawn as a real diagram — flowcharts, sequence, class, state, ER, Gantt, pie, journey, git graph, mind map, timeline, quadrant, XY, requirement, Sankey, block and C4 diagrams — in Preview (NMSh's renderer and the Chromium preview), Split's preview pane, the viewer and Hybrid's inactive blocks, with the image backend your terminal supports (Kitty graphics, iTerm2, Sixel, or character art); `i` opens it in the image viewer to zoom and pan. NMSh uses the Mermaid you installed (`npm install -g mermaid`, or `NMSH_MERMAID`) inside the real browser it already drives, and downloads nothing. The page that draws runs no document script, loads nothing from the network or disk and returns only pixels; Mermaid's security settings are locked against directives. Settings → General → **Mermaid diagrams**: *On request* (default: press `D`, or F1 › Diagrams while editing — opening a document never starts a browser), *Automatic*, or *Off*. The fence stays the source: in Hybrid the block under the caret is its exact text, edits are drawn after a pause, and anything not drawn (not asked yet, no browser or Mermaid, a syntax error, too large, a timeout) shows the fence as written with `(diagram not drawn: reason)`. Drawings are cached by content and theme, bounded in number, bytes and time, and the renderer page closes when no document shows a diagram.
- **Hybrid Markdown editing** ([#460](https://github.com/raiseCatError/notMyShell/issues/460)). **F3** in a Markdown document (or `H` from the read-only views) shows it *rendered* — headings, emphasis, links, lists, tables, quotes, code — except for the block your caret (or selection) is in, which shows its exact Markdown source and is fully editable. Move on and it renders again. It is another presentation of the same buffer: every key is the editor's (letters always type; snippets, list/table helpers, undo/redo, hunk review, recovery drafts and Save As all work as in the source editor), the caret lives in source positions, and switching views never changes the text. The smallest safe block is revealed — the whole list for a nested item, the whole table, the whole fence — and a selection shows every block it touches as source. The view is anchored to a source line, so revealing a block does not move what is above it. F3 again returns to the source editor; in the split view (F2) F3 switches the text pane between source and hybrid. Limits: Markdown files only (not HTML); images appear as `[image: alt]`; Up/Down move by source line; pointer input is not handled.
- **Expandable ``** ([#462](https://github.com/raiseCatError/notMyShell/issues/462)). The native Markdown/HTML viewer now shows `…
… ` as a disclosure: `▸ Summary` when closed (hiding its content), `▾ Summary` with the content indented when open — in the rendered view, the preview, the split's preview pane and the hybrid view. GitHub-style details, with a blank line and Markdown inside and ` ` in a later block, are understood (nested ones too); the `open` attribute sets the starting state. **`z`** toggles the summary at or below the top of the screen, **`Z`** all summaries in view; choices are kept by summary text, survive scrolling, resizing and edits above, and never change the file. Scripts, handlers and unknown tags in a details block are inert text. Not yet: pointer clicks (the viewer has no pointer support), and toggling inside the hybrid view (it shows each details as written: closed unless `open`).
diff --git a/docs/architecture/document-source-map.md b/docs/architecture/document-source-map.md
index 72738d2b..488a43d3 100644
--- a/docs/architecture/document-source-map.md
+++ b/docs/architecture/document-source-map.md
@@ -15,6 +15,7 @@ TextBuffer position (line, UTF-16 column) ⇄ Block.from / Block.to (source li
## What is built on it now
- Heading index (`headingsOf`, `DocumentIndex.headings`): level, plain text, source line, depth (nearest shallower heading) and GitHub-style slug.
+- The browser preview's Console (#466) goes to a line only for an HTML document whose text is unchanged since the page loaded (Chrome's 0-based line and UTF-16 column are the buffer's); a Markdown page is generated, so its lines are never offered as the file's.
- `/view file.md#heading`, the viewer's `#` heading prompt with Backspace to go back, and the editor's *Go to heading…* / *Back* (jumps are `SourceAnchor`s).
## Heading anchors: GitHub-style, and how Jekyll/Kramdown differs
diff --git a/docs/design/browser-engine.md b/docs/design/browser-engine.md
index d898824a..0e074ec5 100644
--- a/docs/design/browser-engine.md
+++ b/docs/design/browser-engine.md
@@ -86,6 +86,19 @@ clears the selection. The world is recreated after a navigation.
also read with `Target.getTargetInfo` after each load and every 500 ms while the page is open. Titles and addresses shown to the person
have C0, C1 and bidi controls stripped.
+### Console (#466)
+
+A preview opened with `console: {documentName}` (the document workspace's preview; never `/browser` tabs, never the diagram
+renderer) turns DevTools events the page already receives — `Runtime` and `Log` are enabled on every page for other reasons, so no
+domain is added — into `console` events: `Runtime.exceptionThrown`, `Runtime.consoleAPICalled` and `Log.entryAdded` (failed loads and
+HTTP errors, CSP refusals, interventions), plus NMSh's own refusals of a request. `Runtime` events count only from the page's default
+worlds (tracked from `executionContextCreated` with `isDefault`, cleared with the document), so NMSh's find world and any isolated
+world never report. Each event becomes a redacted `ConsoleEntry` (`src/browser/console.ts`) before it leaves the page object: the
+preview's address and token become the document's name or a path beside it, other addresses lose credentials, query values and
+fragments, likely secrets are replaced, controls are stripped and text is bounded before any pattern runs. Nothing is evaluated,
+no object is fetched (`description` only), scripts and network rules are untouched. A cross-document commit emits `document`; the
+page holds its first 200 console events until a listener attaches, because the first load starts inside `open()`.
+
### Dialogs, popups, downloads
`alert` is accepted; `confirm`, `prompt` and `beforeunload` are dismissed. Each emits `dialog` with the message. A download is denied and
diff --git a/docs/design/browser.md b/docs/design/browser.md
index 7cfd0537..0f1721a6 100644
--- a/docs/design/browser.md
+++ b/docs/design/browser.md
@@ -39,4 +39,4 @@ no Puppeteer, no Electron, no WebSocket client, no bundled browser.
## Verified by
-`tests/browserEngineChrome.test.ts` (a real Chrome 156 here: lazy launch and clean shutdown, idle stop and relaunch, URL policy, redirects, screencast, CSS animation advancing between frames, zoom, input into a form and a link click, wheel, find with scripts off, scripts toggle, popups/dialogs/downloads, remote refusal, cookie isolation, closing), `tests/browserPreviewServer.test.ts`, `tests/browserEngineProtocol.test.ts`, `tests/browserPanel.test.ts`, `tests/browserSurface.test.ts`, `tests/documentPreview.test.ts`. Chrome-dependent tests skip when no browser can start.
+`tests/browserEngineChrome.test.ts` (a real Chrome 156 here: lazy launch and clean shutdown, idle stop and relaunch, URL policy, redirects, screencast, CSS animation advancing between frames, zoom, input into a form and a link click, wheel, find with scripts off, scripts toggle, popups/dialogs/downloads, remote refusal, cookie isolation, closing), `tests/browserPreviewServer.test.ts`, `tests/browserEngineProtocol.test.ts`, `tests/browserPanel.test.ts`, `tests/browserSurface.test.ts`, `tests/documentPreview.test.ts`; the document Console: `tests/browserConsole.test.ts`, `tests/browserConsoleView.test.ts`, `tests/browserConsoleChrome.test.ts`. Chrome-dependent tests skip when no browser can start.
diff --git a/docs/design/diagrams.md b/docs/design/diagrams.md
index f9625027..b42db9e2 100644
--- a/docs/design/diagrams.md
+++ b/docs/design/diagrams.md
@@ -108,6 +108,8 @@ NMSh: bounded PNG decoder (graphics service) ──► Kitty placeholders │ iT
## Not verified, and remaining #462 work
- Physical Ghostty and Zed QA: **NOT RUN** (added to `docs/testing/v024-editor-physical-qa.md`).
-- `/doctor` does not report Mermaid's location or version yet.
+- `/doctor` reports (Documents section) the browser it would use, the Mermaid it found (path, version, a warning for a major other
+ than the tested 12), the setting and, when diagrams would not be drawn, the reason in the service's own order. It launches nothing and
+ does not read the bundle (`probeMermaid` opens and `fstat`s it), so it says what was found, never that drawing works.
- Remaining #462 items outside diagrams: following `#fragment` links inside a rendered document, outline refinements (the optional
persistent outline rail), pointer toggling of `` (and hybrid toggling).
diff --git a/docs/design/document-workspace.md b/docs/design/document-workspace.md
index cd2c0011..7f1c74e1 100644
--- a/docs/design/document-workspace.md
+++ b/docs/design/document-workspace.md
@@ -70,9 +70,22 @@ Limits: code fences are recognised by a line scan (indented code blocks and fenc
- **Currency:** checking is off until Problems is first opened. Then `ProblemsRunner` observes the buffer: each change bumps a revision and schedules a run (500 ms); a run snapshots the lines, aborts any run in flight, and its result is dropped if the revision moved. Opening Problems, Ctrl+R, a save and a Save As re-check (files the document names may have changed; no watchers). The runner is disposed with the editor, on reload and on exit, so no timer or observer outlives the document.
- **Surface:** the dialog lists findings by severity then line with counts and the number of external links not checked; Tab cycles all / problems / errors, typing filters on the message, Del dismisses (until the next report), Esc leaves. In source and preview a jump also scrolls the read-only view to the line; hybrid and the split follow the caret.
- **Independent review (opus subagent, security focus) and what changed:** (1–3) a dangling symlink, or a symlink to a folder this user cannot read, was told apart from other links outside the root (it leaked whether the outside target exists) and `readText` could be raced into following a link or blocking on a FIFO — the path is now walked component by component from the root, each link resolved (even dangling ones, via `readlink`) and confined before the walk continues, and the read opens with `O_NOFOLLOW|O_NONBLOCK`, `fstat`s a regular file and re-checks the real path; (4) heading checks in another file read it once per path and count against a bound; (5) quadratic regexes on hostile lines (80k characters took 17–38 s) — every quantifier is bounded, lines over 20,000 characters are scanned only that far and say so, and CSS is read by a linear scanner; (6) readers of reload, plain Save As-and-close now release the runner; (7) a failing checker no longer throws into the editor; (8–10) false positives and negatives: HTML comments and code spans across lines, TOML front matter, definitions on the next line or in quotes, escaped destinations, `url()` in CSS comments and strings, `url(#id)` checked against ids in HTML, ``, ids inside ``, the first of duplicate attributes, an undefined `[text][ref]` (CommonMark shows it as text) is only information; the outer link of a badge, nested brackets, inline `
` and `@import` are now checked; (11–12) the `image-alt` span is the image's own text and a value spanning lines is not an exact span; (13) the list focus stays on a row when a new report is shorter.
-- **Not provided:** a gutter marker in the text, checks of other languages, CSS property validation, link fragments inside another HTML file, following redirects or fetching anything, quick fixes, and the browser console (a separate, gated slice).
+- **Not provided:** a gutter marker in the text, checks of other languages, CSS property validation, link fragments inside another HTML file, following redirects or fetching anything, quick fixes. The runtime side is the Console below.
- **Verified by** `tests/diagnostics.test.ts` (each check, hostile paths and symlinks, the filesystem checker, no-folder documents, Unicode and stray CR, HTML and CSS, false-positive stress, bounds) and `tests/problems.test.ts` (runner staleness and disposal, the surface, filters, dismiss, refresh, widths/NO_COLOR/Safe Glyphs, and every mode: source, preview, hybrid, split, edit).
+## Console (runtime diagnostics of the browser preview)
+
+`C` in the Chromium preview opens the page's Console ([#466](https://github.com/raiseCatError/notMyShell/issues/466)): what the page itself reported for the document now loaded — script exceptions, `console` errors, warnings and messages, failed loads and HTTP errors, Content Security Policy refusals and NMSh's own refusals. `src/browser/console.ts` (entries, redaction, bounds, source mapping), `src/viewer/consoleDialog.ts` (the dialog), the engine's `console` events ([engine](browser-engine.md#console-466)) and `BrowserSession.console()`.
+
+- **Trust is unchanged by it.** The Console is a reader. Opening, filtering, clearing or copying never calls `setScripts`, never navigates or reloads and sends nothing to the page; there is no expression input and nothing is evaluated. Scripts off (the default) means only loading and policy messages appear, and the header and empty state say so; scripts on remains the explicit `T` of the preview.
+- **What is kept:** only entries of the preview opened by this document workspace (`console` is an opt-in of `preview` pages; `/browser` tabs and the Mermaid renderer have none), from the page's own worlds, for the current document load (a navigation, refresh or trust change starts again; a crash adds an entry and the next refresh opens a new page). In memory only, never written, at most 200 records and 256 KB, 2,000 characters a message, eight stack frames; identical consecutive entries are counted, not repeated; dropped ones are counted. Collection starts with the preview (so the first load's errors are there when the Console is opened) and ends when the preview closes; leaving the preview mode closes it.
+- **Redaction happens before anything is stored:** the preview's loopback address and its unguessable token become the document's name or a path beside it; other addresses lose user, password, query values and fragment (`data:` keeps only its type); the home folder becomes `~`; `Bearer`/`Basic` credentials, JWTs, common token formats (GitHub, Slack, AWS, Google, `sk-…`, Stripe) and `password=`/`token:`/`"api_key": …`-style values become `‹redacted›`; controls and bidi marks are removed. No cookie, header or body is ever asked for. Redaction is a safety net, not a guarantee: a secret in an unusual shape can survive, which is why copying shows the exact text first.
+- **Surface:** the Problems pattern — a list under the editor's text, newest first; each row has the severity glyph (E/W/i in Safe Glyphs), a source tag (JS, log, net, CSP, NMSh, page), the place (dropped below 60 columns, where the detail line carries it) and the message. ↑↓ choose, Tab cycles all / errors and warnings / errors, typing searches, Shift+↑↓ marks, select all (Alt+A, or Cmd+A where the terminal sends it) marks every shown entry, Del clears, Ctrl+C opens the copy review (Enter copies exactly what is shown, Esc goes back), Esc leaves. The header always begins *Console (page runtime, scripts on|off)* so it is not mistaken for Problems' static findings.
+- **Going to the source:** Enter moves the caret (a jump Back returns from) and shows the editor only when the place is certain: the entry names the document itself, the document is HTML (served as written, so Chrome's 0-based line and UTF-16 column are the buffer's), the line exists and the text has not changed since the page loaded. Otherwise the reason is shown (outside the document, another file beside it, generated from Markdown, the text changed — `r` refreshes, no line). The text, selection, dirty state and undo history are never touched otherwise.
+- **Verified by** `tests/browserConsole.test.ts` (events to entries, redaction, hostile and huge input, bounds, mapping, copy text), `tests/browserConsoleView.test.ts` (fake engine: opt-in, no page calls and unchanged document and undo, key ownership, filters, clear, copy review, exact and refused jumps, document resets, crash and reopen, two documents, listener cleanup, a 20,000-event flood, 30/50/80/120 columns with NO_COLOR and Safe Glyphs, no Chromium) and `tests/browserConsoleChrome.test.ts` (real Chromium: exception, console and 404 entries with the document's lines, no token in any entry, scripts off reports no script activity and nothing leaves the machine, reload, isolated worlds, web pages and the Mermaid renderer report nothing, a flood and a 3 MB message).
+- **Independent review (opus subagent, security focus) and what changed:** the trust boundary held (no script, network, navigation or evaluation path; isolation by session and default worlds held against `/browser`, the find world and the Mermaid renderer). Fixed: (1) an address in message text stopped at `()[]{}`, at 2,048 characters or after a letter glued to the scheme, leaving later query values — the whole address up to whitespace or a quote is now rewritten; (2) an address that does not parse kept credentials and query — now only scheme and host remain; (3) the preview token could appear in a location label reached as `localhost`/`[::1]` or inside another address — every loopback name of the preview is recognised and every label is scrubbed like message text; (4) a sibling `index.html` (a file on disk) was treated as the document and Enter could jump to an unrelated line — only the token's root is the document; and, smaller: a lone CR or U+2028/2029 in the text refuses the jump (the browser counts lines differently), a refresh forgets the old load at once, each console argument is cut before joining, copied continuation lines are indented so page text cannot pose as another entry, the blank page after a refused navigation no longer clears the refusal, and `/doctor` reads `package.json` without blocking and gives up on the section after 3 s.
+- **Not provided:** evaluating expressions, a network panel (requests, headers, bodies, timings), source maps for generated JavaScript, going to a line in another local file, persistence or export beyond the reviewed copy.
+
## Hybrid view
`DocumentMode` gains `'hybrid'` (#460), implemented in `src/viewer/HybridView.ts` over the shared source map (`docs/architecture/document-source-map.md`). It is a presentation of the one `TextBuffer`; there is no second editor and no second document.
diff --git a/docs/development/v024-ledger.md b/docs/development/v024-ledger.md
index bb36a6ba..a4640a85 100644
--- a/docs/development/v024-ledger.md
+++ b/docs/development/v024-ledger.md
@@ -50,6 +50,9 @@ The paused issues #460–#469 are the milestone (the maintainer superseded the e
13. **#462: Mermaid diagrams** — branch `feature/v024-mermaid`, from `dev` (after #501). Done: renderer page in the existing engine (`src/browser/engine/diagram.ts`: isolated world, page scripts off, no network, SVG only as an image, PNG out), Mermaid locator (`src/diagrams/library.ts`, the person's install, never downloaded), bounds and locked configuration (`mermaid.ts`), one bounded service (`service.ts`: lazy, settle-then-draw, drop text no longer shown, cache, timeout/crash recovery, idle close), per-document provider (`document.ts`), viewer/preview/split/hybrid and Chromium-preview integration, Settings → Mermaid diagrams (On request default), tests (`diagrams*.test.ts`, app and preview tests), [design](../design/diagrams.md). **#462 remaining:** following `#fragment` links inside a rendered document, outline refinements (optional persistent outline rail), pointer toggling of `` (and hybrid toggling), `/doctor` line for Mermaid, physical QA (NOT RUN). Milestone still open after #462: #466 optional browser console, #461/#460 interaction refinements (pointer, vertical split, Chromium split, images in hybrid), #464/#465/#467.
+14. **CI shard margin** — branch `ci/v024-test-shards`, PR #503. Diagnosis: shards ran 8–10 min of a 10-min cap before Mermaid too; weights dated 2026-10-04 left new heavy files at weight 1 and several old weights 3–4× low; each Chrome test launched its own browser. Done: weights from CI per-test durations, one browser per Chrome test file (lifecycle tests keep their own), three shards per OS, cap 12 min (about 1.5× the slowest measured job), `docs/development-verification.md`.
+15. **#462 `/doctor` Mermaid line and #466 browser Console** — branch `feature/v024-browser-console`, from `dev`. Done: `/doctor` Documents section (`src/doctor/documents.ts`, `probeMermaid`, `findBrowserExecutable`, `BrowserHost.checkedAvailability`; launches nothing, reads no bundle); the Console (`src/browser/console.ts`, `src/viewer/consoleDialog.ts`, engine `console`/`document` events, `BrowserSession.console()`, `C` in the Chromium preview), tests (`doctorDocuments`, `browserConsole`, `browserConsoleView`, `browserConsoleChrome`), design sections, physical QA items (NOT RUN). **#466 remaining:** a gutter marker for Problems, further languages; physical QA. **#462 remaining:** fragment links inside a rendered document, outline rail, pointer and hybrid `` toggling; physical QA.
+
## Environment notes
Scratch and test runs: `t7run`; focused tests via `/Volumes/T7Tmp/ai-dev/runtests.mjs` (see the testing-gotchas memory). `npm ci` was needed in a fresh worktree (the main checkout's `node_modules` is stale).
diff --git a/docs/testing/v024-editor-physical-qa.md b/docs/testing/v024-editor-physical-qa.md
index 502cb83d..04316d02 100644
--- a/docs/testing/v024-editor-physical-qa.md
+++ b/docs/testing/v024-editor-physical-qa.md
@@ -30,6 +30,12 @@ Automated tests cover the logic headlessly. Everything below is **NOT RUN** unti
- [ ] A symlink out of the project and `/etc/passwd`-style paths are reported as not checked; an external URL is never fetched (watch the network).
- [ ] 30, 50, 80 and 120 columns; NO_COLOR; Safe Glyphs (E/W/i/?).
+## Browser Console (#466)
+- [ ] NOT RUN: An HTML file with an inline script that throws, `console.error`/`console.warn`, a missing image and a remote `
`. Chromium preview (`h`), scripts off: `C` shows the missing image and the refusal only, and says scripts are off; the page's scripts stay off (nothing it would do happens). `T` turns scripts on: after the reload the exception and console lines appear; Enter on the exception opens the editor at the exact line; Back returns.
+- [ ] NOT RUN: Edit the text without refreshing: Enter says the text changed; `r` refreshes and the entries start again. A Markdown file: entries never go to a line.
+- [ ] NOT RUN: Tab filters, typing searches, Del clears, Shift+↑↓ marks, Ctrl+C shows the review, Enter copies; paste it somewhere and check no preview address token, query value or `password=` value is in it.
+- [ ] NOT RUN: Undo, dirty marker, caret and selection are as they were after opening and closing the Console; `/browser` pages in another tab never show up in it; 30, 50, 80 and 120 columns; NO_COLOR; Safe Glyphs; Ghostty and Zed.
+
## Hybrid view (#460)
- [ ] Open a Markdown file, F3 (is F3 delivered by the terminal and by Zed?): headings, bold, links, lists, tables, quotes and fences render; the block under the caret shows its source; moving with the arrows swaps blocks without the screen jumping.
- [ ] Type in every kind of block; select across blocks with Shift+Down (both show source); undo/redo; insert a snippet and Tab through it.
diff --git a/src/app/TerminalApp.ts b/src/app/TerminalApp.ts
index 657bbc36..873f13bc 100644
--- a/src/app/TerminalApp.ts
+++ b/src/app/TerminalApp.ts
@@ -282,6 +282,8 @@ import {openableUrl, projectRunAllowed, readProjectFacts} from '../ask/project.j
import {ManagedTasks} from '../tasks/ManagedTasks.js';
import {CursorPresenter} from '../cursor/CursorPresenter.js';
import {runDoctor, type DoctorCheck} from '../doctor/doctor.js';
+import {documentDoctorChecks, type DocumentRenderingFacts} from '../doctor/documents.js';
+import {findBrowserExecutable} from '../browser/engine/chrome.js';
import {parseWatch, watchSafety, WatchTasks, type WatchSafety} from '../tasks/WatchTasks.js';
import {renderWatchPanel, watchNoticeRows, watchPanelKey, type WatchPanelState} from '../tasks/WatchPanel.js';
import {finalResultRows, trackBlockRows} from '../tasks/trackBlock.js';
@@ -313,7 +315,7 @@ import {ImageError} from '../graphics/raster.js';
import {DocumentImages, documentRoot} from '../viewer/documentImages.js';
import {DiagramService} from '../diagrams/service.js';
import {DocumentDiagrams} from '../diagrams/document.js';
-import {locateMermaid} from '../diagrams/library.js';
+import {locateMermaid, probeMermaid} from '../diagrams/library.js';
import type {DiagramTheme} from '../diagrams/mermaid.js';
import {createImageView, IMAGE_EXTENSIONS, imageViewKey, renderImageView, showImage, siblingImages, type ImageViewState} from '../viewer/ImagePanel.js';
import type {TextOverlay} from '../arcade/Canvas.js';
@@ -4188,7 +4190,7 @@ export class TerminalApp {
const view = await BrowserPreviewView.open({
session: browserSession, previews, html,
geometry: () => this.panelGeometry(browserSession.viewport().scale), openLink: url => this.openLinkInBrowser(url), dirty: () => session.editor.buffer.dirty,
- }, {name: file.title, root: dirname(file.realPath), text: bufferText(session.editor), trusted: session.trusted});
+ }, {name: file.title, root: dirname(file.realPath), text: bufferText(session.editor), trusted: session.trusted, kind});
if ('error' in view) { browserSession.dispose(); session.note = `${view.error} Showing NMSh's own preview.`; return; }
if (this.docSession !== session || session.mode !== 'preview') { view.dispose(); return; }
session.preview = view;
@@ -10206,6 +10208,19 @@ export class TerminalApp {
];
}
+ /** Documents rows: the preview browser and Mermaid, found without launching the browser or reading the Mermaid bundle. */
+ private async documentDoctorChecks(): Promise {
+ const configuration = this.promptConfiguration;
+ const checked = this.browserHost?.checkedAvailability;
+ const [found, mermaid] = await Promise.all([
+ checked ? Promise.resolve(undefined) : findBrowserExecutable(undefined, process.env.NMSH_BROWSER),
+ probeMermaid({env: process.env, home: homedir(), execPath: process.execPath}),
+ ]);
+ const chromium: DocumentRenderingFacts['chromium'] = checked ? {state: 'checked', availability: checked}
+ : found?.ok ? {state: 'found', executable: found.executable} : {state: 'missing', reason: found && !found.ok ? found.reason : 'No browser was found.'};
+ return documentDoctorChecks({diagrams: configuration.diagrams, browser: configuration.browser, images: configuration.graphicsMode !== 'off', running: Boolean(this.browserHost?.running), chromium, mermaid});
+ }
+
/** /doctor: local, bounded, read-only checks gathered in parallel; a live line while they run. */
private async openDoctor(): Promise {
this.panelOrigin = undefined;
@@ -10241,7 +10256,9 @@ export class TerminalApp {
runtimeAvailable, ...(model ? {state: stateLabel(this.understanding.status, configuration.localUnderstanding)} : {})},
agents: this.agents.harnesses().map(item => ({label: item.harness.name, installed: Boolean(item.executable)})),
...(process.env.VIRTUAL_ENV ? {virtualEnv: process.env.VIRTUAL_ENV} : {})});
- if (this.doctorPanel === panel) { panel.checks = [...checks, ...this.graphicsDoctorChecks()]; panel.selected = 0; }
+ // Bounded like every doctor check: a stuck filesystem costs the section, never the panel.
+ const documents = await Promise.race([this.documentDoctorChecks(), new Promise(resolve => { setTimeout(() => resolve([]), 3000).unref(); })]).catch(() => []);
+ if (this.doctorPanel === panel) { panel.checks = [...checks, ...this.graphicsDoctorChecks(), ...documents]; panel.selected = 0; }
} finally { clock?.(); }
this.render();
}
diff --git a/src/browser/console.ts b/src/browser/console.ts
new file mode 100644
index 00000000..94d597a6
--- /dev/null
+++ b/src/browser/console.ts
@@ -0,0 +1,387 @@
+/**
+ * The runtime Console of a document's browser preview (#466): what the page itself reported while it loaded and ran — script
+ * exceptions, console errors and warnings, failed loads and HTTP errors, Content Security Policy refusals and NMSh's own refusals.
+ *
+ * It is a read-only record. Nothing here evaluates code, changes the page's script or network rules, or asks the browser for
+ * anything: the engine turns DevTools events it already receives into `ConsoleEntry` values with `consoleEntry()`, which redacts
+ * them on the way in, so a secret in a URL or message never reaches memory, the screen or the clipboard. `ConsoleLog` keeps the
+ * entries of the current document load, bounded by count, bytes and message size, in memory only.
+ */
+
+export type ConsoleSeverity = 'error' | 'warning' | 'info';
+/** Where an entry came from: a thrown exception, a console call, the network layer, a CSP refusal, NMSh's policy, the page itself. */
+export type ConsoleOrigin = 'exception' | 'console' | 'network' | 'security' | 'policy' | 'page';
+
+export interface ConsoleLocation {
+ /** `document`: the previewed document itself; `local`: a file beside it; `external`: anything else (shown redacted). */
+ target: 'document' | 'local' | 'external';
+ /** What to show: the document's name, a path relative to its folder, or a redacted address. */
+ label: string;
+ /** 0-based line and UTF-16 column, as Chrome reports them; absent when Chrome gave none. */
+ line?: number;
+ column?: number;
+}
+
+export interface ConsoleEntry {
+ severity: ConsoleSeverity;
+ origin: ConsoleOrigin;
+ /** Redacted, control characters removed, at most MAX_MESSAGE characters. */
+ message: string;
+ location?: ConsoleLocation;
+ /** Up to MAX_FRAMES call frames, each `function (location)`, redacted. */
+ stack?: string[];
+}
+
+export const MAX_MESSAGE = 2000;
+export const MAX_ENTRIES = 200;
+export const MAX_BYTES = 256 * 1024;
+const MAX_FRAMES = 8;
+/** Raw text is cut before any pattern runs, so a hostile megabyte message costs the same as a short one. */
+const MAX_RAW = 8000;
+
+// ---- redaction -------------------------------------------------------------------------------------------------------
+
+const CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f--]/gu;
+export const REDACTED = '‹redacted›';
+
+const SECRET_PATTERNS: ReadonlyArray<[RegExp, string | ((match: string, ...groups: string[]) => string)]> = [
+ // Authorization schemes followed by their credential.
+ [/\b(Bearer|Basic|Digest|Token)\s+[A-Za-z0-9._~+/=-]{6,}/giu, (_m, scheme) => `${scheme} ${REDACTED}`],
+ // JSON Web Tokens.
+ [/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{0,}/gu, REDACTED],
+ // Well-known token formats (GitHub, Slack, AWS, Google, OpenAI-style and Anthropic-style keys, Stripe).
+ [/\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|xox[abposr]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{30,}|sk-[A-Za-z0-9_-]{16,}|[rs]k_(?:live|test)_[A-Za-z0-9]{16,})\b/gu, REDACTED],
+ // key=value / key: value / "key": "value" for names that usually carry a secret.
+ [/((?:["']?)\b[A-Za-z0-9_-]{0,24}(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|access[_-]?key|private[_-]?key|auth|authorization|session(?:id)?|cookie|signature|sig|credential)s?["']?\s{0,3}[:=]\s{0,3})("[^"\n]{0,512}"|'[^'\n]{0,512}'|[^\s,;&"'}\])]{1,512})/giu,
+ (_m, key) => `${key}${REDACTED}`],
+];
+
+/** A URL anywhere in a message (absolute addresses only; bounded so a hostile line cannot make it slow). */
+// No word boundary before the scheme: `xhttps://user:pw@host` must still be found (a scheme glued to a word is still an address).
+const URL_IN_TEXT = /(?:https?|wss?|ftp|file|blob|data):[^\s"'<>`]+/giu;
+
+export interface RedactContext {
+ /** The address of the previewed document (`http://127.0.0.1://`): its token never leaves this module. */
+ documentUrl?: string;
+ /** The document's own name, shown in place of its address. */
+ documentName?: string;
+ /** The person's home folder, shown as `~`. */
+ home?: string;
+}
+
+/** The preview's base (`http://127.0.0.1://`) for a document address, or undefined when it is not one. */
+function previewBase(documentUrl: string | undefined): URL | undefined {
+ if (!documentUrl) return undefined;
+ try {
+ const url = new URL(documentUrl);
+ if (url.protocol !== 'http:' || !isLoopback(url.hostname)) return undefined;
+ const token = url.pathname.split('/')[1];
+ if (!token) return undefined;
+ return new URL(`${url.origin}/${token}/`);
+ } catch { return undefined; }
+}
+
+function isLoopback(host: string): boolean {
+ return host === 'localhost' || host === '[::1]' || /^127\.\d+\.\d+\.\d+$/u.test(host);
+}
+
+/** The same preview server reached by another loopback name (localhost, [::1]): same port, same token path. */
+function samePreview(url: URL, base: URL): boolean {
+ return url.protocol === 'http:' && isLoopback(url.hostname) && url.port === base.port;
+}
+
+export type ClassifiedUrl = {target: 'document' | 'local' | 'external'; label: string};
+
+/**
+ * What an address may be shown as. The preview's own address becomes the document's name and a file beside it a relative path,
+ * so the preview's unguessable token is never shown or copied; any other address keeps its scheme, host and path, with
+ * credentials removed, query values and the fragment replaced, and `data:` reduced to its type.
+ */
+export function classifyUrl(raw: string, context: RedactContext = {}): ClassifiedUrl {
+ const classified = classifyRaw(raw, context);
+ // Every label is scrubbed like message text (the token, the home folder, likely secrets), whatever path produced it.
+ return {target: classified.target, label: scrub(classified.label, context).slice(0, 300)};
+}
+
+/** An address that does not parse: no credentials and nothing after `?` or `#`. */
+function unparsedAddress(text: string): string {
+ return text.replace(/^([A-Za-z][A-Za-z0-9+.-]*:\/*)[^/?#\s]*@/u, '$1').replace(/[?#][\s\S]*$/u, '?…');
+}
+
+function classifyRaw(raw: string, context: RedactContext): ClassifiedUrl {
+ const text = raw.slice(0, MAX_RAW).replace(CONTROLS, '');
+ const base = previewBase(context.documentUrl);
+ let url: URL;
+ try { url = new URL(text); } catch { return {target: 'external', label: unparsedAddress(text)}; }
+ if (base && samePreview(url, base) && url.pathname.startsWith(base.pathname)) {
+ const relative = url.pathname.slice(base.pathname.length);
+ // Only the token's root is the buffer; `index.html` beside the document is a file on disk, not this text.
+ if (relative === '') return {target: 'document', label: context.documentName ?? 'this document'};
+ let decoded = relative;
+ try { decoded = decodeURIComponent(relative); } catch { /* keep it encoded */ }
+ return {target: 'local', label: decoded.replace(CONTROLS, '').slice(0, 300)};
+ }
+ if (base && samePreview(url, base)) return {target: 'external', label: `${url.origin}/${REDACTED}`};
+ if (url.protocol === 'data:') return {target: 'external', label: `data:${/^[\w.+-]+\/[\w.+-]+/u.exec(url.pathname)?.[0] ?? ''},…`};
+ if (url.protocol === 'blob:') return {target: 'external', label: 'blob:…'};
+ if (url.protocol === 'file:') {
+ const path = decodeSafe(url.pathname);
+ return {target: 'external', label: `file://${context.home && path.startsWith(`${context.home}/`) ? `~${path.slice(context.home.length)}` : path}`.slice(0, 300)};
+ }
+ url.username = '';
+ url.password = '';
+ const keys = [...new Set([...url.searchParams.keys()])].slice(0, 12);
+ const query = keys.length ? `?${keys.map(key => `${encodeURIComponent(key)}=…`).join('&')}` : '';
+ return {target: 'external', label: `${url.protocol}//${url.host}${url.pathname}${query}${url.hash ? '#…' : ''}`};
+}
+
+function decodeSafe(text: string): string {
+ try { return decodeURIComponent(text); } catch { return text; }
+}
+
+/** Remove controls, rewrite every address (see `classifyUrl`) and replace likely secrets. */
+export function redactText(raw: string, context: RedactContext = {}, urls = true): string {
+ let text = raw.slice(0, MAX_RAW).replace(CONTROLS, '').replace(/\r\n?/gu, '\n');
+ if (urls) text = text.replace(URL_IN_TEXT, match => classifyUrl(match, context).label);
+ return scrub(text, context);
+}
+
+/** Likely secrets, the home folder and the preview token, wherever they appear. */
+function scrub(input: string, context: RedactContext): string {
+ let text = input;
+ for (const [pattern, replacement] of SECRET_PATTERNS) text = text.replace(pattern, replacement as string);
+ if (context.home && context.home.length > 1) text = text.split(context.home).join('~');
+ // Whatever token the preview address carried is gone even if it appeared outside a URL.
+ const token = previewBase(context.documentUrl)?.pathname.split('/')[1];
+ if (token && token.length >= 8) text = text.split(token).join(REDACTED);
+ return text;
+}
+
+// ---- turning DevTools events into entries ----------------------------------------------------------------------------
+
+/** The DevTools `Runtime.RemoteObject` fields a console message is made from (never fetched further: nothing is evaluated). */
+interface RemoteObject {type?: string; subtype?: string; value?: unknown; unserializableValue?: string; description?: string; className?: string}
+interface CallFrame {functionName?: string; url?: string; lineNumber?: number; columnNumber?: number}
+
+function remoteText(arg: RemoteObject): string {
+ return remoteTextRaw(arg).slice(0, MAX_RAW);
+}
+
+function remoteTextRaw(arg: RemoteObject): string {
+ if (arg.type === 'string') return String(arg.value ?? '');
+ if (arg.unserializableValue !== undefined) return String(arg.unserializableValue);
+ if (arg.type === 'undefined') return 'undefined';
+ if (arg.subtype === 'null') return 'null';
+ if (arg.value !== undefined && (arg.type === 'number' || arg.type === 'boolean')) return String(arg.value);
+ return String(arg.description ?? arg.className ?? arg.type ?? '');
+}
+
+/** console.log's own formatting: %s %d %i %f %o %O consume an argument, %c (a style) consumes one and prints nothing. */
+function formatArgs(args: readonly RemoteObject[]): string {
+ const list = args.slice(0, 32);
+ if (list.length === 0) return '';
+ let rest = list.slice(1);
+ let head = remoteText(list[0]!);
+ if (list[0]!.type === 'string' && /%[sdifoOc%]/u.test(head)) {
+ head = head.slice(0, MAX_RAW).replace(/%([sdifoOc%])/gu, (_match, code: string) => {
+ if (code === '%') return '%';
+ const next = rest[0];
+ if (!next) return `%${code}`;
+ rest = rest.slice(1);
+ if (code === 'c') return '';
+ const value = remoteText(next);
+ if (code === 'd' || code === 'i') return String(Number.parseInt(value, 10));
+ if (code === 'f') return String(Number.parseFloat(value));
+ return value;
+ });
+ }
+ return [head.slice(0, MAX_RAW), ...rest.map(remoteText)].join(' ').slice(0, MAX_RAW);
+}
+
+function frameText(frame: CallFrame, context: RedactContext): string {
+ const where = frame.url ? classifyUrl(frame.url, context).label : '(anonymous)';
+ const position = frame.lineNumber !== undefined ? `:${frame.lineNumber + 1}:${(frame.columnNumber ?? 0) + 1}` : '';
+ return `${(frame.functionName || '(anonymous)').replace(CONTROLS, '').slice(0, 80)} (${where}${position})`;
+}
+
+function location(url: string | undefined, line: number | undefined, column: number | undefined, context: RedactContext): ConsoleLocation | undefined {
+ if (!url) return undefined;
+ const target = classifyUrl(url, context);
+ const valid = (value: number | undefined) => typeof value === 'number' && Number.isInteger(value) && value >= 0 && value < 10_000_000;
+ return {...target, ...(valid(line) ? {line} : {}), ...(valid(line) && valid(column) ? {column} : {})};
+}
+
+function finish(entry: ConsoleEntry): ConsoleEntry {
+ const message = entry.message.length > MAX_MESSAGE ? `${entry.message.slice(0, MAX_MESSAGE - 1)}…` : entry.message;
+ return {...entry, message: message || '(empty message)'};
+}
+
+const SEVERITY_OF_CONSOLE: Readonly> = {error: 'error', assert: 'error', warning: 'warning'};
+
+/** A DevTools event the Console keeps, as an entry; undefined for events it does not show. Never throws on malformed input. */
+export function consoleEntry(method: string, params: any, context: RedactContext = {}): ConsoleEntry | undefined {
+ try {
+ if (method === 'Runtime.exceptionThrown') {
+ const details = params?.exceptionDetails ?? {};
+ const description = typeof details.exception?.description === 'string' ? details.exception.description : '';
+ const head = description.split('\n')[0] || String(details.exception?.value ?? '');
+ const message = [String(details.text ?? 'Uncaught'), head].filter(Boolean).join(' ').replace(/^Uncaught Uncaught/u, 'Uncaught');
+ const frames: CallFrame[] = Array.isArray(details.stackTrace?.callFrames) ? details.stackTrace.callFrames : [];
+ const top = frames[0];
+ return finish({
+ severity: 'error', origin: 'exception', message: redactText(message, context),
+ ...(location(details.url ?? top?.url, details.lineNumber ?? top?.lineNumber, details.columnNumber ?? top?.columnNumber, context) ? {location: location(details.url ?? top?.url, details.lineNumber ?? top?.lineNumber, details.columnNumber ?? top?.columnNumber, context)!} : {}),
+ ...(frames.length ? {stack: frames.slice(0, MAX_FRAMES).map(frame => redactText(frameText(frame, context), context))} : {}),
+ });
+ }
+ if (method === 'Runtime.consoleAPICalled') {
+ const type = String(params?.type ?? 'log');
+ if (type === 'clear' || type === 'startGroup' || type === 'startGroupCollapsed' || type === 'endGroup' || type === 'profile' || type === 'profileEnd') return undefined;
+ const frames: CallFrame[] = Array.isArray(params?.stackTrace?.callFrames) ? params.stackTrace.callFrames : [];
+ const top = frames[0];
+ const where = top ? location(top.url, top.lineNumber, top.columnNumber, context) : undefined;
+ return finish({
+ severity: SEVERITY_OF_CONSOLE[type] ?? 'info', origin: 'console',
+ message: redactText(`${type === 'assert' ? 'Assertion failed: ' : ''}${formatArgs(Array.isArray(params?.args) ? params.args : [])}`, context),
+ ...(where ? {location: where} : {}),
+ ...(type === 'error' || type === 'assert' || type === 'trace' ? (frames.length > 1 ? {stack: frames.slice(0, MAX_FRAMES).map(frame => redactText(frameText(frame, context), context))} : {}) : {}),
+ });
+ }
+ if (method === 'Log.entryAdded') {
+ const entry = params?.entry ?? {};
+ const level = String(entry.level ?? 'info');
+ if (level === 'verbose' || entry.source === 'violation') return undefined;
+ const text = String(entry.text ?? '');
+ // NMSh's own refusal of a request is recorded as a policy entry, which says why; Chrome's echo of it would repeat it.
+ if (entry.source === 'network' && /ERR_BLOCKED_BY_CLIENT/u.test(text)) return undefined;
+ const origin: ConsoleOrigin = entry.source === 'network' ? 'network' : entry.source === 'security' ? 'security' : entry.source === 'javascript' ? 'exception' : 'page';
+ const where = location(entry.url, entry.lineNumber, undefined, context);
+ return finish({severity: level === 'error' ? 'error' : level === 'warning' ? 'warning' : 'info', origin, message: redactText(text, context), ...(where ? {location: where} : {})});
+ }
+ } catch { /* a malformed event is not shown */ }
+ return undefined;
+}
+
+/** NMSh's own refusal of a request or navigation (a preview loads nothing from outside its folder). */
+export function policyEntry(url: string, reason: string, context: RedactContext = {}): ConsoleEntry {
+ const where = classifyUrl(url, context);
+ return finish({severity: 'warning', origin: 'policy', message: redactText(`Refused by NMSh: ${reason}`, context), location: where});
+}
+
+// ---- the record ------------------------------------------------------------------------------------------------------
+
+export interface ConsoleRecord extends ConsoleEntry {
+ /** Increases with every entry kept: the order things happened in. */
+ id: number;
+ /** Consecutive identical entries are one record with a count. */
+ count: number;
+}
+
+function sizeOf(entry: ConsoleEntry): number {
+ return (entry.message.length + (entry.location?.label.length ?? 0) + (entry.stack?.reduce((sum, frame) => sum + frame.length, 0) ?? 0)) * 2 + 64;
+}
+
+function same(a: ConsoleEntry, b: ConsoleEntry): boolean {
+ return a.severity === b.severity && a.origin === b.origin && a.message === b.message && a.location?.label === b.location?.label
+ && a.location?.line === b.location?.line && a.location?.column === b.location?.column;
+}
+
+/**
+ * The entries of the current document load, newest last. Bounded: at most MAX_ENTRIES records and MAX_BYTES of text; the oldest
+ * go first and are counted in `dropped`. `reset()` starts a new load (the page navigated, reloaded or was opened again).
+ */
+export class ConsoleLog {
+ private records: ConsoleRecord[] = [];
+ private bytes = 0;
+ private next = 1;
+ dropped = 0;
+ /** Bumped by every change, so a view knows when to re-read. */
+ version = 0;
+ private listeners = new Set<() => void>();
+
+ entries(): readonly ConsoleRecord[] { return this.records; }
+ get size(): number { return this.records.length; }
+
+ add(entry: ConsoleEntry): void {
+ const last = this.records.at(-1);
+ if (last && same(last, entry)) { last.count = Math.min(last.count + 1, 999_999); this.changed(); return; }
+ const record: ConsoleRecord = {...entry, id: this.next++, count: 1};
+ this.records.push(record);
+ this.bytes += sizeOf(record);
+ while (this.records.length > MAX_ENTRIES || (this.bytes > MAX_BYTES && this.records.length > 1)) {
+ const old = this.records.shift()!;
+ this.bytes -= sizeOf(old);
+ this.dropped += old.count;
+ }
+ this.changed();
+ }
+
+ /** The person cleared the Console: the entries go, and so does the count of dropped ones. */
+ clear(): void {
+ this.records = [];
+ this.bytes = 0;
+ this.dropped = 0;
+ this.changed();
+ }
+
+ /** A new document load: what the previous one reported no longer applies. */
+ reset(): void { this.clear(); }
+
+ onChange(listener: () => void): () => void {
+ this.listeners.add(listener);
+ return () => { this.listeners.delete(listener); };
+ }
+
+ dispose(): void {
+ this.records = [];
+ this.bytes = 0;
+ this.listeners.clear();
+ }
+
+ private changed(): void {
+ this.version += 1;
+ for (const listener of [...this.listeners]) { try { listener(); } catch { /* a listener's failure is its own */ } }
+ }
+}
+
+// ---- going to the source -------------------------------------------------------------------------------------------
+
+export interface SourceContext {
+ /** How the page was made: an HTML document is served exactly as written; Markdown is converted, so its lines are not the file's. */
+ kind: 'html' | 'markdown';
+ /** The text the loaded page was built from, and the buffer's text now. */
+ servedText: string | undefined;
+ currentText: string;
+ lines: readonly string[];
+}
+
+/**
+ * Where Enter on an entry goes: a position in the document's own text, only when that is certain (an HTML document, unchanged
+ * since the page loaded, a line Chrome named that exists); otherwise the reason it is not offered.
+ */
+export function sourcePosition(entry: ConsoleEntry, context: SourceContext): {line: number; column: number} | {reason: string} {
+ const where = entry.location;
+ if (!where) return {reason: 'no source location was reported'};
+ if (where.target === 'external') return {reason: 'it comes from outside the document'};
+ if (where.target === 'local') return {reason: `it is in ${where.label}, not this document`};
+ if (context.kind !== 'html') return {reason: 'the page is generated from Markdown, so its lines are not the file\'s'};
+ if (where.line === undefined) return {reason: 'no line was reported'};
+ if (context.servedText === undefined || context.servedText !== context.currentText) return {reason: 'the text changed since the page loaded (r in the preview refreshes it)'};
+ // The browser counts a lone CR (and, in scripts, U+2028/U+2029) as a line break; the buffer does not, so lines would drift.
+ if (/[\r\u2028\u2029]/u.test(context.servedText)) return {reason: 'the document has line breaks the browser counts differently (a lone CR, U+2028 or U+2029)'};
+ if (where.line >= context.lines.length) return {reason: 'the line is past the end of the document'};
+ const length = context.lines[where.line]!.length;
+ return {line: where.line, column: Math.min(where.column ?? 0, length)};
+}
+
+/** Plain text for the clipboard: one entry per line block, already redacted; no colours, no terminal controls. */
+export function consoleCopyText(records: readonly ConsoleRecord[], documentName: string): string {
+ const out = [`Console of ${documentName.replace(CONTROLS, '')} (NMSh browser preview, redacted)`];
+ for (const record of records) {
+ const where = record.location ? ` ${record.location.label}${record.location.line !== undefined ? `:${record.location.line + 1}${record.location.column !== undefined ? `:${record.location.column + 1}` : ''}` : ''}` : '';
+ // Continuation lines are indented, so page text cannot pose as another entry in the copy.
+ out.push(`[${record.severity}] ${record.origin}${where}${record.count > 1 ? ` (×${record.count})` : ''}: ${record.message.split('\n').join('\n | ')}`);
+ for (const frame of record.stack ?? []) out.push(` at ${frame}`);
+ }
+ return out.join('\n');
+}
diff --git a/src/browser/documentPreview.ts b/src/browser/documentPreview.ts
index 76907d9c..18c5d0fe 100644
--- a/src/browser/documentPreview.ts
+++ b/src/browser/documentPreview.ts
@@ -6,6 +6,7 @@ import {framePanel} from '../ui/PanelShell.js';
import {foreground, UI_COLORS} from '../ui/palette.js';
import {displayWidth, truncateAnsi, truncateText} from '../util/text.js';
import type {PreviewView} from '../viewer/DocumentWorkspace.js';
+import type {ConsoleFeed} from '../viewer/consoleDialog.js';
import {cellToViewport} from './keymap.js';
import type {BrowserSession} from './session.js';
import type {PreviewHandle, PreviewServer} from './types.js';
@@ -28,6 +29,8 @@ export interface PreviewDocument {
root: string;
text: string;
trusted: boolean;
+ /** How the page is made from the file (HTML is served as written; Markdown is converted). */
+ kind?: 'html' | 'markdown';
}
export interface DocumentPreviewDeps {
@@ -63,7 +66,7 @@ export class BrowserPreviewView implements PreviewView {
const view = new BrowserPreviewView(deps, document);
try {
view.handle = await deps.previews.serve({html: deps.html(document.text), root: document.root, trusted: document.trusted, name: document.name});
- const opened = await deps.session.open(view.handle.url, {kind: 'preview', scripts: document.trusted});
+ const opened = await deps.session.open(view.handle.url, {kind: 'preview', scripts: document.trusted, console: {documentName: document.name}});
if (!opened.ok) { view.dispose(); return {error: opened.reason}; }
} catch (error) {
view.dispose();
@@ -85,6 +88,8 @@ export class BrowserPreviewView implements PreviewView {
refresh(text: string): Promise {
return this.enqueue(async () => {
if (this.disposed || !this.handle) return;
+ // What the old load reported must not be mapped against the new text.
+ this.deps.session.console()?.reset();
this.document = {...this.document, text};
this.handle.update(this.deps.html(text));
await this.deps.session.reload();
@@ -97,6 +102,7 @@ export class BrowserPreviewView implements PreviewView {
if (this.disposed) return;
// Scripts go off before anything else changes, so a failure part-way never leaves them on.
if (!trusted) await this.deps.session.setScripts(false);
+ this.deps.session.console()?.reset();
this.document = {...this.document, trusted, text};
this.handle?.dispose();
this.handle = await this.deps.previews.serve({html: this.deps.html(text), root: this.document.root, trusted, name: this.document.name});
@@ -106,6 +112,13 @@ export class BrowserPreviewView implements PreviewView {
}
problems(): ReadonlyArray<{path: string; reason: string}> { return this.handle?.problems() ?? []; }
+
+ /** The page's runtime Console: read-only, filled by the page itself; opening it never changes scripts or the network. */
+ console(): ConsoleFeed | undefined {
+ const log = this.disposed ? undefined : this.deps.session.console();
+ if (!log) return undefined;
+ return {log, documentName: this.document.name, scripts: this.document.trusted, kind: this.document.kind ?? 'markdown', servedText: () => this.document.text};
+ }
capturing(): boolean { return Boolean(this.finding); }
status(): string | undefined {
@@ -116,6 +129,9 @@ export class BrowserPreviewView implements PreviewView {
if (view?.find) parts.push(view.find.matches ? `${view.find.current}/${view.find.matches} for “${view.find.query}”` : `no match for “${view.find.query}”`);
const notice = this.deps.session.notice();
if (notice) parts.push(notice);
+ const reported = this.deps.session.console()?.entries() ?? [];
+ const errors = reported.filter(record => record.severity === 'error').length, warnings = reported.filter(record => record.severity === 'warning').length;
+ if (errors || warnings) parts.push(`Console: ${[errors ? `${errors} error${errors === 1 ? '' : 's'}` : '', warnings ? `${warnings} warning${warnings === 1 ? '' : 's'}` : ''].filter(Boolean).join(', ')} (C)`);
const missing = this.problems();
if (missing.length) parts.push(`${missing.length} resource${missing.length === 1 ? '' : 's'} missing or refused (${missing.at(-1)!.path})`);
return parts.join(' · ') || undefined;
@@ -128,7 +144,7 @@ export class BrowserPreviewView implements PreviewView {
const width = Math.max(10, columns - 4);
const controls = renderControlRows(this.finding
? [['Enter', 'find'], ['Esc', 'cancel']]
- : [['↑↓', 'scroll'], ['/', 'find'], ['+/-', 'zoom'], ['h', 'source'], ['e', 'edit'], ['r', 'refresh'], ['T', this.document.trusted ? 'scripts off' : 'scripts on'], ...(this.deps.session.lastBlocked ? [['o', 'open link'] as const] : []), ['Esc', 'source']], width);
+ : [['↑↓', 'scroll'], ['/', 'find'], ['+/-', 'zoom'], ['h', 'source'], ['e', 'edit'], ['r', 'refresh'], ['T', this.document.trusted ? 'scripts off' : 'scripts on'], ['C', 'console'], ...(this.deps.session.lastBlocked ? [['o', 'open link'] as const] : []), ['Esc', 'source']], width);
const title = truncateText(this.document.name, Math.max(8, Math.floor(width * 0.5)));
const detail = `rendered by Chromium${this.document.trusted ? ' · scripts on' : ''}`;
const header = ` ${primary}${title}${RESET}${this.deps.dirty() ? `${warning} ● edited${RESET}` : ''} ${subtle}${truncateText(detail, Math.max(1, width - displayWidth(title) - (this.deps.dirty() ? 10 : 0) - 2))}${RESET}`;
diff --git a/src/browser/engine/chrome.ts b/src/browser/engine/chrome.ts
index 9490a112..1bfed277 100644
--- a/src/browser/engine/chrome.ts
+++ b/src/browser/engine/chrome.ts
@@ -79,6 +79,20 @@ async function check(host: LocatorHost, executable: string): Promise = realLocatorHost, override?: string): Promise<{ok: true; executable: string} | {ok: false; reason: string}> {
+ const configured = override ?? host.env.NMSH_BROWSER;
+ if (configured) {
+ if (!path.isAbsolute(configured)) return {ok: false, reason: 'NMSH_BROWSER must be the absolute path of a browser executable.'};
+ return (await host.isExecutable(configured)) ? {ok: true, executable: configured} : {ok: false, reason: `NMSH_BROWSER points to ${configured}, which is not an executable file.`};
+ }
+ for (const candidate of await candidatePaths(host)) if (await host.isExecutable(candidate)) return {ok: true, executable: candidate};
+ return {ok: false, reason: `No Chrome, Chromium or Edge was found. ${INSTALL_HINT}`};
+}
+
export async function locateBrowser(host: LocatorHost = realLocatorHost, override?: string): Promise {
const configured = override ?? host.env.NMSH_BROWSER;
if (configured) {
diff --git a/src/browser/engine/page.ts b/src/browser/engine/page.ts
index 7fbbaa4b..1c64ce3c 100644
--- a/src/browser/engine/page.ts
+++ b/src/browser/engine/page.ts
@@ -1,4 +1,6 @@
import {randomBytes} from 'node:crypto';
+import {homedir} from 'node:os';
+import {consoleEntry, policyEntry, type RedactContext} from '../console.js';
import type {BrowserPage, Frame, KeyEventInput, MouseEventInput, OpenOptions, PageEvent, PageKind, Viewport} from '../types.js';
import type {CdpClient, CdpEvent} from './cdp.js';
import {cleanForDisplay} from './preview.js';
@@ -162,6 +164,15 @@ export class ChromePage implements BrowserPage {
private readonly unsubscribe: () => void;
private titleTimer: NodeJS.Timeout | undefined;
private titlePolling = false;
+ /** Console records are made only for a preview opened with `console`; the page's own (default) worlds only, never NMSh's isolated ones. */
+ private readonly consoleName: string | undefined;
+ private readonly pageWorlds = new Set();
+ /**
+ * The document starts loading inside `open()`, before anyone can listen: a Console page keeps what it reports until the first
+ * listener arrives (bounded), so errors of the first load are not lost.
+ */
+ private early: PageEvent[] | undefined;
+ private refusedNavigation = false;
private constructor(private readonly host: PageHost, id: string, options: OpenOptions) {
this.id = id;
@@ -170,6 +181,8 @@ export class ChromePage implements BrowserPage {
this.scripts = options.scripts ?? options.kind === 'web';
this.colorScheme = options.colorScheme;
this.reducedMotion = options.reducedMotion;
+ this.consoleName = options.kind === 'preview' ? options.console?.documentName : undefined;
+ if (this.consoleName !== undefined) this.early = [];
this.unsubscribe = host.cdp.onEvent(event => this.onCdpEvent(event));
// `Target.targetInfoChanged` is not sent for every title change (a script setting document.title, a background window), so the
// title is also read directly; one small call twice a second.
@@ -269,6 +282,10 @@ export class ChromePage implements BrowserPage {
// ---- events ------------------------------------------------------------------------------------------------------------
private emit(event: PageEvent): void {
+ if (this.early && (event.type === 'console' || event.type === 'document')) {
+ if (this.eventListeners.size === 0) { if (this.early.length < 200) this.early.push(event); return; }
+ this.early = undefined;
+ }
for (const listener of [...this.eventListeners]) {
try { listener(event); } catch { /* a listener's bug must not break the page */ }
}
@@ -276,6 +293,9 @@ export class ChromePage implements BrowserPage {
onEvent(listener: (event: PageEvent) => void): () => void {
this.eventListeners.add(listener);
+ const early = this.early;
+ this.early = undefined;
+ for (const event of early ?? []) { try { listener(event); } catch { /* a listener's bug must not break the page */ } }
return () => { this.eventListeners.delete(listener); };
}
@@ -296,8 +316,26 @@ export class ChromePage implements BrowserPage {
this.emit({type: 'navigated', url: this._url});
}
- private block(url: string, reason: string): void {
+ /** `record`: also a Console entry (false when Chrome's own log entry for the same refusal is recorded instead). */
+ private block(url: string, reason: string, record = true): void {
this.emit({type: 'blocked', url: cleanForDisplay(url, MAX_URL_DISPLAY), reason: cleanForDisplay(reason, 120)});
+ if (record && this.consoleName !== undefined) this.emit({type: 'console', entry: policyEntry(url, reason, this.redaction())});
+ }
+
+ /** What the Console's redaction needs: the document's address (its token is never shown), its name, the home folder. */
+ private redaction(): RedactContext {
+ return {documentUrl: this._url, documentName: this.consoleName ?? 'this document', home: homedir()};
+ }
+
+ /** A DevTools event for the Console: Runtime events count only from the page's own worlds of its current document. */
+ private onConsoleEvent(method: string, params: any): void {
+ if (this.consoleName === undefined) return;
+ if (method !== 'Log.entryAdded') {
+ const context = method === 'Runtime.exceptionThrown' ? params?.exceptionDetails?.executionContextId : params?.executionContextId;
+ if (!this.pageWorlds.has(context)) return;
+ }
+ const entry = consoleEntry(method, params, this.redaction());
+ if (entry) this.emit({type: 'console', entry});
}
private onCdpEvent(event: CdpEvent): void {
@@ -350,13 +388,25 @@ export class ChromePage implements BrowserPage {
break;
case 'Runtime.executionContextsCleared':
this.findWorld = undefined;
+ this.pageWorlds.clear();
this.setEditable(false);
break;
+ case 'Runtime.executionContextCreated':
+ if (params.context?.auxData?.isDefault === true && typeof params.context.id === 'number') this.pageWorlds.add(params.context.id);
+ break;
+ case 'Runtime.executionContextDestroyed':
+ this.pageWorlds.delete(params.executionContextId);
+ break;
+ case 'Runtime.exceptionThrown':
+ case 'Runtime.consoleAPICalled':
+ this.onConsoleEvent(method, params);
+ break;
case 'Fetch.requestPaused':
void this.onRequestPaused(params);
break;
case 'Log.entryAdded':
this.onLogEntry(params.entry);
+ this.onConsoleEvent(method, params);
break;
case 'Inspector.targetCrashed':
void this.gone(true);
@@ -387,6 +437,10 @@ export class ChromePage implements BrowserPage {
private onMainFrameNavigated(frame: {id: string; url: string; unreachableUrl?: string}): void {
this.mainFrameId = frame.id;
+ // A new document: what the previous one reported belongs to it (Page.frameNavigated is cross-document only). The blank page
+ // NMSh shows after refusing a navigation is not a new document of the person's: the refusal it explains stays.
+ if (this.refusedNavigation && frame.url === 'about:blank') this.refusedNavigation = false;
+ else this.emit({type: 'document'});
if (frame.url.startsWith('chrome-error:')) {
// Chrome's own page for a failed load: show the address the person asked for.
this.setUrl(frame.unreachableUrl ?? this._url);
@@ -394,6 +448,7 @@ export class ChromePage implements BrowserPage {
const verdict = this.kind === 'preview' ? this.checkPreviewUrl(frame.url) : checkWebUrl(frame.url);
if (!verdict.ok) {
this.block(frame.url, verdict.reason);
+ this.refusedNavigation = true;
void this.send('Page.navigate', {url: 'about:blank'}).catch(() => undefined);
return;
}
@@ -452,7 +507,7 @@ export class ChromePage implements BrowserPage {
const text = String(entry.text ?? '');
if (!/Content Security Policy|violates the following/iu.test(text)) return;
const quoted = /'((?:https?|ftp|ws|wss|data|blob):[^']*)'/iu.exec(text);
- this.block(quoted?.[1] ?? String(entry.url ?? ''), 'a preview loads nothing from outside its own folder');
+ this.block(quoted?.[1] ?? String(entry.url ?? ''), 'a preview loads nothing from outside its own folder', false);
}
// ---- screencast --------------------------------------------------------------------------------------------------------
diff --git a/src/browser/host.ts b/src/browser/host.ts
index f90b6eb2..1b2a2322 100644
--- a/src/browser/host.ts
+++ b/src/browser/host.ts
@@ -26,6 +26,7 @@ export class BrowserHost {
private browser: BrowserEngine | undefined;
private server: PreviewServer | undefined;
private available: Promise | undefined;
+ private checked: BrowserAvailability | undefined;
constructor(private readonly options: BrowserHostOptions, private readonly load: () => Promise = () => import('./engine/index.js')) {}
@@ -36,10 +37,13 @@ export class BrowserHost {
/** Is a usable Chrome or Chromium installed? Remembered for the life of the host. */
async availability(): Promise {
- this.available ??= this.engineModule().then(module => module.locateBrowser(undefined, this.options.env?.NMSH_BROWSER));
+ this.available ??= this.engineModule().then(module => module.locateBrowser(undefined, this.options.env?.NMSH_BROWSER)).then(result => { this.checked = result; return result; });
return this.available;
}
+ /** The answer of `availability()` if a surface already asked this session; never starts a check (for /doctor). */
+ get checkedAvailability(): BrowserAvailability | undefined { return this.checked; }
+
async engine(): Promise {
const module = await this.engineModule();
this.browser ??= module.createBrowserEngine({profileDir: this.options.profileDir, idleMs: this.options.idleMs, ...(this.options.env ? {env: this.options.env} : {})});
diff --git a/src/browser/session.ts b/src/browser/session.ts
index 41c58e20..d77d2faf 100644
--- a/src/browser/session.ts
+++ b/src/browser/session.ts
@@ -1,3 +1,4 @@
+import {ConsoleLog} from './console.js';
import type {FrameStream} from '../graphics/stream.js';
import {plainText, popupAddress} from './address.js';
import type {BrowserEngine, BrowserPage, Frame, KeyEventInput, MouseEventInput, PageEvent, PageKind, Viewport} from './types.js';
@@ -84,6 +85,9 @@ interface Tab {
unsubscribe: Array<() => void>;
retry?: unknown;
closed: boolean;
+ /** A document preview's runtime Console (#466): what its page reported for the current document, redacted and bounded. */
+ console?: ConsoleLog;
+ consoleName?: string;
}
export class BrowserSession {
@@ -115,6 +119,9 @@ export class BrowserSession {
view(): TabView[] { return this.tabs_.map(viewOf); }
+ /** The active tab's Console, when it is a document preview opened with one. */
+ console(): ConsoleLog | undefined { return this.tabs_[this.active_]?.console; }
+
activeView(): TabView | undefined {
const tab = this.tabs_[this.active_];
return tab ? viewOf(tab) : undefined;
@@ -171,7 +178,7 @@ export class BrowserSession {
// ---- tabs ------------------------------------------------------------------------------------------------------
- async open(url = 'about:blank', options: {background?: boolean; kind?: PageKind; scripts?: boolean} = {}): Promise {
+ async open(url = 'about:blank', options: {background?: boolean; kind?: PageKind; scripts?: boolean; console?: {documentName: string}} = {}): Promise {
if (this.disposed) return {ok: false, reason: 'The browser is closed.'};
if (this.tabs_.length >= MAX_TABS) {
const reason = `${MAX_TABS} tabs are open. Close one first.`;
@@ -181,6 +188,7 @@ export class BrowserSession {
const tab = this.makeTab(url);
if (options.kind) tab.kind = options.kind;
if (options.scripts !== undefined) tab.scripts = options.scripts;
+ if (options.console && options.kind === 'preview') { tab.console = new ConsoleLog(); tab.consoleName = options.console.documentName; }
this.tabs_.push(tab);
if (!options.background) this.activateTab(tab);
this.touch();
@@ -199,6 +207,7 @@ export class BrowserSession {
page = await this.options.engine.open({
kind: tab.kind ?? 'web', url, viewport: this.viewport(),
...(tab.scripts !== undefined ? {scripts: tab.scripts} : {}),
+ ...(tab.consoleName !== undefined ? {console: {documentName: tab.consoleName}} : {}),
...(this.options.colorScheme ? {colorScheme: this.options.colorScheme} : {}),
...(this.options.reducedMotion !== undefined ? {reducedMotion: this.options.reducedMotion} : {}),
});
@@ -265,6 +274,7 @@ export class BrowserSession {
for (const off of tab.unsubscribe.splice(0)) { try { off(); } catch { /* already gone */ } }
if (tab.retry !== undefined) { this.clock.clearTimeout(tab.retry); tab.retry = undefined; }
tab.stream.dispose();
+ tab.console?.dispose();
const page = tab.page;
tab.page = undefined;
if (page) void page.close().catch(() => undefined);
@@ -333,7 +343,12 @@ export class BrowserSession {
else void this.open(url);
break;
}
- case 'crashed': this.fail(tab, 'The page crashed.'); break;
+ case 'console': tab.console?.add(event.entry); break;
+ case 'document': tab.console?.reset(); break;
+ case 'crashed':
+ tab.console?.add({severity: 'error', origin: 'page', message: 'The page crashed. Refreshing the preview opens it again.'});
+ this.fail(tab, 'The page crashed.');
+ break;
case 'closed': this.fail(tab, 'The page was closed.'); break;
}
this.touch();
diff --git a/src/browser/types.ts b/src/browser/types.ts
index 4c0caa4b..5a9b762b 100644
--- a/src/browser/types.ts
+++ b/src/browser/types.ts
@@ -1,3 +1,5 @@
+import type {ConsoleEntry} from './console.js';
+
/**
* The boundary between NMSh and a real browser engine.
*
@@ -36,6 +38,11 @@ export interface OpenOptions {
/** Emulate prefers-color-scheme / prefers-reduced-motion so a page follows NMSh's theme and motion settings. */
colorScheme?: 'light' | 'dark';
reducedMotion?: boolean;
+ /**
+ * `preview` only: report what the page itself reports (exceptions, console messages, failed loads, policy refusals) as redacted
+ * `console` events for the document's Console. It changes nothing about scripts or the network; other pages never report.
+ */
+ console?: {documentName: string};
}
/** One rendered frame, PNG-encoded, at `viewport.width * scale` x `viewport.height * scale` pixels. */
@@ -64,6 +71,10 @@ export interface KeyEventInput {
}
export type PageEvent =
+ /** A redacted runtime record for the document's Console (only pages opened with `console`). */
+ | {type: 'console'; entry: ConsoleEntry}
+ /** A new document committed in the main frame (a load, reload or navigation): its Console starts again. */
+ | {type: 'document'}
| {type: 'navigated'; url: string}
| {type: 'title'; title: string}
| {type: 'loading'; loading: boolean}
diff --git a/src/diagrams/library.ts b/src/diagrams/library.ts
index 14feffd8..22dba84f 100644
--- a/src/diagrams/library.ts
+++ b/src/diagrams/library.ts
@@ -18,6 +18,10 @@ export const MAX_LIBRARY_BYTES = 16 * 1024 * 1024;
const BUNDLE = path.join('dist', 'mermaid.min.js');
export type LocatedLibrary = {ok: true; path: string; version: string; source: string} | {ok: false; reason: string};
+/** What `/doctor` reports: the install the locator would use, found by the same rules, without reading the bundle. */
+export type MermaidProbe = {ok: true; path: string; version: string} | {ok: false; reason: string};
+/** The release NMSh's tests draw with (CI fetches exactly this one). Other versions are used, not promised. */
+export const TESTED_MERMAID = '12.2.0';
export interface LocateOptions {
env: NodeJS.ProcessEnv;
@@ -43,11 +47,11 @@ export function libraryCandidates(options: Omit): s
return out;
}
-async function readBundle(file: string, packageDir: string | undefined): Promise {
+async function readBundle(file: string, packageDir: string | undefined, read = true): Promise {
let version = 'unknown';
if (packageDir) {
let manifest: {name?: unknown; version?: unknown};
- try { manifest = JSON.parse(await fs.readFile(path.join(packageDir, 'package.json'), 'utf8')) as typeof manifest; }
+ try { manifest = JSON.parse(await readManifest(path.join(packageDir, 'package.json'))) as typeof manifest; }
catch { return {ok: false, reason: `${packageDir} has no readable package.json.`}; }
if (manifest.name !== 'mermaid') return {ok: false, reason: `${packageDir} is not the mermaid package.`};
version = typeof manifest.version === 'string' ? manifest.version.slice(0, 40) : 'unknown';
@@ -66,6 +70,7 @@ async function readBundle(file: string, packageDir: string | undefined): Promise
if (!stat.isFile()) return {ok: false, reason: `${file} is not a regular file.`};
if (stat.size > MAX_LIBRARY_BYTES) return {ok: false, reason: `${file} is too large (${Math.round(stat.size / 1048576)} MB) to be the Mermaid bundle.`};
if ((stat.mode & 0o002) !== 0) return {ok: false, reason: `${file} can be changed by anyone on this machine, so NMSh does not run it.`};
+ if (!read) return {ok: true, path: file, version, source: ''};
const source = await handle.readFile({encoding: 'utf8'});
if (source.length === 0) return {ok: false, reason: `${file} is empty.`};
return {ok: true, path: file, version, source};
@@ -74,6 +79,16 @@ async function readBundle(file: string, packageDir: string | undefined): Promise
}
}
+/** package.json without blocking on a FIFO or reading a huge file: a regular file of at most 1 MB. */
+async function readManifest(file: string): Promise {
+ const handle = await fs.open(file, constants.O_RDONLY | constants.O_NONBLOCK);
+ try {
+ const stat = await handle.stat();
+ if (!stat.isFile() || stat.size > 1024 * 1024) throw new Error('not a package.json');
+ return await handle.readFile({encoding: 'utf8'});
+ } finally { await handle.close(); }
+}
+
/** The bundle path and package folder for a candidate: a package folder, or a file inside one's dist/. */
async function resolveCandidate(candidate: string): Promise<{file: string; packageDir?: string}> {
let real = candidate;
@@ -87,18 +102,28 @@ async function resolveCandidate(candidate: string): Promise<{file: string; packa
}
export async function locateMermaid(options: LocateOptions): Promise {
+ return find(options, true);
+}
+
+/** The same search and checks as `locateMermaid`, but the bundle is only opened and `fstat`ed, never read. */
+export async function probeMermaid(options: LocateOptions): Promise {
+ const found = await find(options, false);
+ return found.ok ? {ok: true, path: found.path, version: found.version} : found;
+}
+
+async function find(options: LocateOptions, read: boolean): Promise {
const explicit = options.env.NMSH_MERMAID?.trim();
if (explicit) {
if (!path.isAbsolute(explicit)) return {ok: false, reason: `NMSH_MERMAID must be an absolute path (it is ${JSON.stringify(explicit.slice(0, 200))}).`};
const {file, packageDir} = await resolveCandidate(explicit);
- const found = await readBundle(file, packageDir);
+ const found = await readBundle(file, packageDir, read);
return found.ok ? found : {ok: false, reason: `NMSH_MERMAID: ${found.reason}`};
}
for (const candidate of options.candidates ?? libraryCandidates(options)) {
const stat = await fs.stat(path.join(candidate, BUNDLE)).catch(() => undefined);
if (!stat) continue;
const {file, packageDir} = await resolveCandidate(candidate);
- return readBundle(file, packageDir);
+ return readBundle(file, packageDir, read);
}
return {ok: false, reason: `Mermaid is not installed. ${INSTALL_HINT}`};
}
diff --git a/src/doctor/documents.ts b/src/doctor/documents.ts
new file mode 100644
index 00000000..3bde6e79
--- /dev/null
+++ b/src/doctor/documents.ts
@@ -0,0 +1,81 @@
+import type {BrowserAvailability} from '../browser/types.js';
+import {cleanForDisplay} from '../browser/engine/preview.js';
+import {TESTED_MERMAID, type MermaidProbe} from '../diagrams/library.js';
+import type {BrowserSetting, DiagramSetting} from '../prompt/configuration.js';
+import type {DoctorCheck} from './doctor.js';
+
+/**
+ * /doctor's Documents section: what draws rendered previews and Mermaid diagrams, and why a diagram would not be drawn. The facts
+ * are gathered without starting anything: the browser executable is found on the filesystem (its version is shown only when a
+ * browser surface already checked it this session) and the Mermaid bundle is located and `fstat`ed, never read. Finding both is
+ * not proof they draw, and the rows say so.
+ */
+export interface DocumentRenderingFacts {
+ diagrams: DiagramSetting;
+ browser: BrowserSetting;
+ /** Settings → Images is not Off. */
+ images: boolean;
+ /** A browser process is running now (some surface opened one). */
+ running: boolean;
+ chromium: {state: 'checked'; availability: BrowserAvailability} | {state: 'found'; executable: string} | {state: 'missing'; reason: string};
+ mermaid: MermaidProbe;
+}
+
+const SECTION = 'Documents';
+const DIAGRAM_LABELS: Record = {manual: 'On request', auto: 'Automatic', off: 'Off'};
+const clean = (text: string, max = 300): string => cleanForDisplay(text, max);
+
+function browserCheck(facts: DocumentRenderingFacts): DoctorCheck {
+ if (facts.browser === 'off') return {section: SECTION, label: 'Real browser off: previews use NMSh\'s own renderer', state: 'info', detail: 'Settings → General → Real browser'};
+ const running = facts.running ? ' · running now' : '';
+ const {chromium} = facts;
+ if (chromium.state === 'checked') {
+ return chromium.availability.ok
+ ? {section: SECTION, label: clean(chromium.availability.version, 80), state: 'ok', detail: `${clean(chromium.availability.executable)} · version checked this session${running}`}
+ : {section: SECTION, label: 'The installed browser cannot be used', state: 'attention', detail: clean(chromium.availability.reason)};
+ }
+ if (chromium.state === 'found') {
+ return {section: SECTION, label: 'Chromium-family browser found', state: 'ok', detail: `${clean(chromium.executable)} · its version is checked when a browser surface first opens${running}`};
+ }
+ return {section: SECTION, label: 'No Chrome or Chromium: previews use NMSh\'s own renderer', state: 'info', detail: clean(chromium.reason)};
+}
+
+function mermaidCheck(probe: MermaidProbe): DoctorCheck {
+ if (!probe.ok) {
+ // A path the person set and that is wrong needs their attention; not having Mermaid at all is a choice.
+ const configured = probe.reason.startsWith('NMSH_MERMAID');
+ return {section: SECTION, label: configured ? 'Mermaid: NMSH_MERMAID cannot be used' : 'Mermaid not installed', state: configured ? 'attention' : 'info', detail: clean(probe.reason)};
+ }
+ const version = clean(probe.version, 40);
+ const testedMajor = TESTED_MERMAID.split('.')[0];
+ if (version === 'unknown') {
+ return {section: SECTION, label: 'Mermaid (version unknown)', state: 'attention', detail: `${clean(probe.path)} · no package.json beside the bundle, so its version cannot be compared; NMSh is tested with ${TESTED_MERMAID}`};
+ }
+ const major = version.split('.')[0];
+ return major === testedMajor
+ ? {section: SECTION, label: `Mermaid ${version}`, state: 'ok', detail: `${clean(probe.path)} · tested with ${TESTED_MERMAID}`}
+ : {section: SECTION, label: `Mermaid ${version}`, state: 'attention', detail: `${clean(probe.path)} · NMSh is tested with ${TESTED_MERMAID}; another major version may draw differently or refuse some diagrams`};
+}
+
+/** Why a diagram would not be drawn, in the order the diagram service applies its checks. */
+function blockedReason(facts: DocumentRenderingFacts): string | undefined {
+ if (!facts.images) return 'Images are off (Settings → General → Images), so diagrams cannot be shown.';
+ if (facts.browser === 'off') return 'Diagrams are drawn by the real browser, which is off (Settings → General → Real browser).';
+ if (facts.chromium.state === 'missing') return clean(facts.chromium.reason);
+ if (facts.chromium.state === 'checked' && !facts.chromium.availability.ok) return clean(facts.chromium.availability.reason);
+ if (!facts.mermaid.ok) return clean(facts.mermaid.reason);
+ return undefined;
+}
+
+function drawingCheck(facts: DocumentRenderingFacts): DoctorCheck {
+ const setting = DIAGRAM_LABELS[facts.diagrams];
+ if (facts.diagrams === 'off') return {section: SECTION, label: 'Mermaid diagrams off: fences stay code', state: 'info', detail: 'Settings → General → Mermaid diagrams'};
+ const reason = blockedReason(facts);
+ if (reason) return {section: SECTION, label: `Mermaid diagrams (${setting}) would not be drawn`, state: 'info', detail: reason};
+ return {section: SECTION, label: `Mermaid diagrams (${setting}) can be drawn`, state: 'ok',
+ detail: `${facts.diagrams === 'manual' ? 'Press D in a rendered Markdown view. ' : ''}Detection only: nothing was drawn to check this.`};
+}
+
+export function documentDoctorChecks(facts: DocumentRenderingFacts): DoctorCheck[] {
+ return [browserCheck(facts), mermaidCheck(facts.mermaid), drawingCheck(facts)];
+}
diff --git a/src/viewer/DocumentWorkspace.ts b/src/viewer/DocumentWorkspace.ts
index 26d881dc..31674f7e 100644
--- a/src/viewer/DocumentWorkspace.ts
+++ b/src/viewer/DocumentWorkspace.ts
@@ -1,6 +1,7 @@
import type {Key} from '../terminal/keys.js';
import type {TextOverlay} from '../arcade/Canvas.js';
-import {createEditor, editorKey, openProblems, renderEditor, type EditorAction, type EditorState} from './EditorPanel.js';
+import {createEditor, editorKey, openConsole, openProblems, renderEditor, type EditorAction, type EditorState} from './EditorPanel.js';
+import type {ConsoleFeed} from './consoleDialog.js';
import {formatForPath, type ViewDocument} from './document.js';
import type {EditableFile} from './fileEdit.js';
import {displayWidth, stripAnsi, truncateAnsi} from '../util/text.js';
@@ -75,6 +76,8 @@ export interface PreviewView {
refresh(text: string): Promise;
/** Scripts on or off for this file (a trusted project). */
setTrusted(trusted: boolean, text: string): Promise;
+ /** The page's runtime Console (#466); undefined when this preview has none (NMSh's own renderer runs no page). */
+ console?(): ConsoleFeed | undefined;
}
export interface DocumentSession {
@@ -189,6 +192,8 @@ export function documentSessionKey(state: DocumentSession, key: Key, columns: nu
// A save or conflict dialog belongs to the editor, whatever mode raised it.
if (editor.dialog || editor.review || editor.input || editor.busy) {
const action = fromEditor(state, editorKey(editor, key, columns, height));
+ // A jump from the browser preview's Console goes to the text itself: the page is not the text, so the editor shows it.
+ if (editor.consoleJump) { editor.consoleJump = false; if (state.mode === 'preview') { editor.lastJump = undefined; setDocumentMode(state, 'edit'); return action; } }
// A jump from Problems or the outline moved the caret: the read-only views follow it.
const jumped = editor.lastJump;
editor.lastJump = undefined;
@@ -216,6 +221,13 @@ export function documentSessionKey(state: DocumentSession, key: Key, columns: nu
if (text === 'e') { setDocumentMode(state, 'edit'); return undefined; }
if (text === 's') { setDocumentMode(state, 'split'); return undefined; }
if (text === 'P') { openProblems(editor); return undefined; }
+ if (text === 'C' && state.mode === 'preview') {
+ // The Console only reads what the page reported; opening it changes nothing about scripts or the network.
+ const feed = state.preview?.console?.();
+ if (feed) openConsole(editor, feed);
+ else state.note = state.preview ? 'This preview has no Console.' : 'The Console belongs to the browser preview (Chromium); NMSh\'s own renderer runs no page.';
+ return undefined;
+ }
if (text === 'H') { toggleHybrid(state); return undefined; }
if (key.kind === 'toggleDetails' && state.mode === 'preview') { setDocumentMode(state, state.before); return undefined; }
if (text === 'h' || key.kind === 'toggleDetails') {
diff --git a/src/viewer/EditorPanel.ts b/src/viewer/EditorPanel.ts
index dfdd6691..cb256624 100644
--- a/src/viewer/EditorPanel.ts
+++ b/src/viewer/EditorPanel.ts
@@ -14,6 +14,7 @@ import {reviewKey, renderReview, type ReviewState} from './ReviewPanel.js';
import {paletteEntries, type PaletteEntry, type PaletteFormat} from './EditorPalette.js';
import {DocumentIndex, SourceAnchor, type HeadingEntry} from '../markdown/sourceMap.js';
import {ProblemsRunner, SEVERITY_ORDER} from './problems.js';
+import {consoleKey, consoleRows, openConsoleDialog, type ConsoleDialog, type ConsoleFeed} from './consoleDialog.js';
import type {Diagnostic, Severity} from '../markdown/diagnostics.js';
import {advanceSnippet, insertFootnote, insertSnippet, insideSnippet, type SnippetSession} from './snippets.js';
import {continueList, insertLink, renumberFootnotes, renumberList, shiftListItems, toggleTask, wrapSelection, type EditResult, type WrapKind} from './markdownEdit.js';
@@ -32,7 +33,9 @@ const REVERSE = '\u001b[7m';
const WHEEL_ROWS = 3;
export interface EditorDialog {
- kind: 'unsaved' | 'conflict' | 'recover' | 'markdown' | 'snippets' | 'playground' | 'outline' | 'problems';
+ kind: 'unsaved' | 'conflict' | 'recover' | 'markdown' | 'snippets' | 'playground' | 'outline' | 'problems' | 'console';
+ /** The browser preview's runtime Console (#466). */
+ console?: ConsoleDialog;
/** Problems: which severities are listed (all, problems = errors and warnings, errors). */
filter?: 'all' | 'problems' | 'errors';
/** How many recoverable Playground documents the chooser can offer. */
@@ -76,6 +79,8 @@ export interface EditorState {
diagramsAsked?: boolean;
/** The caret moved without a key press in the editor (a jump from a list): the next draw scrolls it into view. */
revealPending?: boolean;
+ /** The last jump came from the browser preview's Console: the workspace shows the text (the page is not the text). */
+ consoleJump?: boolean;
/** The editor sits in a document workspace: its footer names the split key. */
workspace?: boolean;
note?: {text: string; tone: 'info' | 'error' | 'success'};
@@ -176,6 +181,23 @@ export function openProblems(state: EditorState): void {
state.dialog = {kind: 'problems', focus: 0, query: '', filter: 'all'};
}
+/** The browser preview's runtime Console (#466): a read-only list of what the page reported; see consoleDialog.ts. */
+export function openConsole(state: EditorState, feed: ConsoleFeed): void {
+ state.dialog = {kind: 'console', focus: 0, console: openConsoleDialog(feed)};
+}
+
+/** Move the caret to a position as a jump the editor's Back returns from. */
+function jumpToPosition(state: EditorState, line: number, column: number): void {
+ const jumps = state.jumps ??= [];
+ jumps.push(new SourceAnchor(state.buffer, state.buffer.caret));
+ while (jumps.length > MAX_JUMPS) jumps.shift()!.dispose();
+ const target = Math.max(0, Math.min(line, state.buffer.lines.length - 1));
+ state.buffer.caret = {line: target, column: Math.min(column, state.buffer.lines[target]!.length)};
+ state.buffer.anchor = undefined;
+ state.lastJump = target;
+ state.revealPending = true;
+}
+
const FILTERS: Array<'all' | 'problems' | 'errors'> = ['all', 'problems', 'errors'];
function problemList(state: EditorState, dialog: EditorDialog): Diagnostic[] {
@@ -355,6 +377,13 @@ function dialogKey(state: EditorState, dialog: EditorDialog, key: Key): EditorAc
if (dialog.kind === 'snippets') { paletteKey(state, dialog, key); return undefined; }
if (dialog.kind === 'outline') { outlineKey(state, dialog, key); return undefined; }
if (dialog.kind === 'problems') { problemsKey(state, dialog, key); return undefined; }
+ if (dialog.kind === 'console' && dialog.console) {
+ return consoleKey(dialog.console, key, {
+ text: () => state.buffer.lines.join('\n'), lines: () => state.buffer.lines,
+ jump: (line, column) => { jumpToPosition(state, line, column); state.consoleJump = true; },
+ close: () => { state.dialog = undefined; },
+ });
+ }
const controls = dialogControls(dialog);
const text = key.kind === 'text' ? key.value.toLowerCase() : undefined;
if (key.kind === 'left' || key.kind === 'focusPrevious') { dialog.focus = (dialog.focus + controls.length - 1) % controls.length; return undefined; }
@@ -602,6 +631,7 @@ function recoverMessage(state: EditorState, dialog: EditorDialog): string {
function footerRows(state: EditorState, columns: number): string[] {
const width = Math.max(1, columns - 4);
+ if (state.dialog?.kind === 'console' && state.dialog.console) return consoleRows(state.dialog.console, width);
if (state.dialog?.kind === 'problems') {
const dialog = state.dialog;
const runner = state.problems;
diff --git a/src/viewer/consoleDialog.ts b/src/viewer/consoleDialog.ts
new file mode 100644
index 00000000..212ea6a8
--- /dev/null
+++ b/src/viewer/consoleDialog.ts
@@ -0,0 +1,206 @@
+import type {Key} from '../terminal/keys.js';
+import {consoleCopyText, sourcePosition, type ConsoleLog, type ConsoleOrigin, type ConsoleRecord, type ConsoleSeverity} from '../browser/console.js';
+import {renderControlRows} from '../ui/controls.js';
+import {editText} from '../ui/formControls.js';
+import {getIconStyle} from '../ui/glyphs.js';
+import {foreground, UI_COLORS} from '../ui/palette.js';
+import {displayWidth, stripAnsi, truncateAnsi, truncateText} from '../util/text.js';
+import {inert} from './document.js';
+
+/**
+ * The runtime Console of a document's browser preview (#466), shown the way Problems is: a list under the editor's text, with
+ * the same keys (↑↓, Tab filters, typing searches, Enter goes to the source, Esc back). It reads a `ConsoleLog` the preview's
+ * page fills; it never changes the page (no script or network rule, no evaluation, no input to it) and never writes the
+ * buffer except moving the caret on Enter, so the text, selection history, dirty state and undo stay exactly as they were.
+ *
+ * Runtime entries are told apart from Problems' static findings: the header says Console and how the page ran, each row
+ * names where it came from (JS, log, net, CSP, NMSh, page), and an entry whose source cannot be mapped exactly says why
+ * instead of pretending to have a place in the file.
+ */
+
+/** What the workspace hands the Console when it opens: the live log and what is needed to map entries to the text. */
+export interface ConsoleFeed {
+ log: ConsoleLog;
+ documentName: string;
+ /** Scripts run in the page (the person trusted this file). */
+ scripts: boolean;
+ /** How the page was made from the file. */
+ kind: 'html' | 'markdown';
+ /** The text the loaded page was built from. */
+ servedText(): string | undefined;
+}
+
+export type ConsoleFilter = 'all' | 'problems' | 'errors';
+
+export interface ConsoleDialog {
+ feed: ConsoleFeed;
+ filter: ConsoleFilter;
+ query: string;
+ /** The focused entry, by record id, so entries arriving do not move the focus. */
+ focusId?: number;
+ /** Entries marked for copying (Shift+↑↓, select all). */
+ marked: Set;
+ /** The copy review: exactly the text that would be copied, waiting for Enter. */
+ review?: {text: string; count: number};
+ /** A one-line answer (why Enter cannot go to the source, what was copied). */
+ note?: string;
+}
+
+export interface ConsoleHost {
+ /** The buffer's current text and lines, for mapping an entry to the source. */
+ text(): string;
+ lines(): readonly string[];
+ /** Move the caret to a position (a jump the editor's Back can return from). */
+ jump(line: number, column: number): void;
+ close(): void;
+}
+
+export type ConsoleKeyResult = {kind: 'copy'; text: string} | undefined;
+
+const FILTERS: ConsoleFilter[] = ['all', 'problems', 'errors'];
+const FILTER_LABEL: Record = {all: 'all', problems: 'errors and warnings', errors: 'errors'};
+const ORIGIN_TAG: Record = {exception: 'JS', console: 'log', network: 'net', security: 'CSP', policy: 'NMSh', page: 'page'};
+const ROWS = 6;
+
+export function openConsoleDialog(feed: ConsoleFeed): ConsoleDialog {
+ return {feed, filter: 'all', query: '', marked: new Set()};
+}
+
+/** The entries shown: newest first, by severity filter and search words. */
+export function consoleList(dialog: ConsoleDialog): ConsoleRecord[] {
+ const allowed: ConsoleSeverity[] = dialog.filter === 'errors' ? ['error'] : dialog.filter === 'problems' ? ['error', 'warning'] : ['error', 'warning', 'info'];
+ const words = dialog.query.toLowerCase().split(/\s+/u).filter(Boolean);
+ return dialog.feed.log.entries()
+ .filter(record => allowed.includes(record.severity) && words.every(word => `${record.message} ${record.location?.label ?? ''} ${ORIGIN_TAG[record.origin]} ${record.severity}`.toLowerCase().includes(word)))
+ .reverse();
+}
+
+function focusIndex(dialog: ConsoleDialog, items: readonly ConsoleRecord[]): number {
+ const index = items.findIndex(record => record.id === dialog.focusId);
+ return index >= 0 ? index : 0;
+}
+
+function where(record: ConsoleRecord): string {
+ const location = record.location;
+ if (!location) return '';
+ return `${location.label}${location.line !== undefined ? `:${location.line + 1}${location.column !== undefined ? `:${location.column + 1}` : ''}` : ''}`;
+}
+
+export function consoleKey(dialog: ConsoleDialog, key: Key, host: ConsoleHost): ConsoleKeyResult {
+ const items = consoleList(dialog);
+ let index = focusIndex(dialog, items);
+ const focus = (next: number) => { index = Math.max(0, Math.min(items.length - 1, next)); dialog.focusId = items[index]?.id; };
+ dialog.note = undefined;
+ if (dialog.review) {
+ if (key.kind === 'enter') { const {text, count} = dialog.review; dialog.review = undefined; dialog.marked.clear(); dialog.note = `Copied ${count} entr${count === 1 ? 'y' : 'ies'}.`; return {kind: 'copy', text}; }
+ if (key.kind === 'escape' || key.kind === 'interrupt') dialog.review = undefined;
+ return undefined;
+ }
+ if (key.kind === 'escape') { host.close(); return undefined; }
+ if (key.kind === 'up' || key.kind === 'focusPrevious' || key.kind === 'suggestPrevious') { focus(index - 1); return undefined; }
+ if (key.kind === 'down' || key.kind === 'suggestNext') { focus(index + 1); return undefined; }
+ if (key.kind === 'pageUp') { focus(index - ROWS); return undefined; }
+ if (key.kind === 'pageDown') { focus(index + ROWS); return undefined; }
+ if (key.kind === 'selectUp' || key.kind === 'selectDown') {
+ const record = items[index];
+ if (record) { if (dialog.marked.has(record.id)) dialog.marked.delete(record.id); else dialog.marked.add(record.id); }
+ focus(index + (key.kind === 'selectUp' ? -1 : 1));
+ return undefined;
+ }
+ if (key.kind === 'selectAll') {
+ const all = items.length > 0 && items.every(record => dialog.marked.has(record.id));
+ for (const record of items) { if (all) dialog.marked.delete(record.id); else dialog.marked.add(record.id); }
+ return undefined;
+ }
+ if (key.kind === 'complete') { dialog.filter = FILTERS[(FILTERS.indexOf(dialog.filter) + 1) % FILTERS.length]!; dialog.focusId = undefined; return undefined; }
+ if (key.kind === 'delete') { dialog.feed.log.clear(); dialog.marked.clear(); dialog.focusId = undefined; dialog.note = 'Cleared. New entries appear as the page reports them.'; return undefined; }
+ if (key.kind === 'interrupt') {
+ const chosen = items.filter(record => dialog.marked.has(record.id));
+ const records = (chosen.length ? chosen : items.slice(index, index + 1)).slice().sort((a, b) => a.id - b.id);
+ if (!records.length) { dialog.note = 'Nothing to copy.'; return undefined; }
+ dialog.review = {text: consoleCopyText(records, dialog.feed.documentName), count: records.length};
+ return undefined;
+ }
+ if (key.kind === 'enter') {
+ const record = items[index];
+ if (!record) return undefined;
+ const position = sourcePosition(record, {kind: dialog.feed.kind, servedText: dialog.feed.servedText(), currentText: host.text(), lines: host.lines()});
+ if ('reason' in position) { dialog.note = `No place in the text to go to: ${position.reason}.`; return undefined; }
+ host.jump(position.line, position.column);
+ host.close();
+ return undefined;
+ }
+ const next = editText(dialog.query, key);
+ if (next !== undefined) { dialog.query = next.slice(0, 80); dialog.focusId = undefined; }
+ return undefined;
+}
+
+/** The Console's rows under the editor's text, `width` columns wide (the editor's footer area). */
+export function consoleRows(dialog: ConsoleDialog, width: number): string[] {
+ const accent = foreground(UI_COLORS.accent), subtle = foreground(UI_COLORS.subtle), reset = '\u001b[0m';
+ const safe = getIconStyle() === 'safe';
+ const glyph = (severity: ConsoleSeverity) => safe ? ({error: 'E', warning: 'W', info: 'i'})[severity] : ({error: '✖', warning: '▲', info: 'ℹ'})[severity];
+ const tone = (severity: ConsoleSeverity) => severity === 'error' ? foreground(UI_COLORS.failure) : severity === 'warning' ? foreground(UI_COLORS.warning) : subtle;
+ const log = dialog.feed.log;
+ const items = consoleList(dialog);
+ const index = focusIndex(dialog, items);
+ if (dialog.review) {
+ const lines = dialog.review.text.split('\n');
+ const shown = lines.slice(0, ROWS).map(line => `${subtle} ${truncateText(inert(line), Math.max(4, width - 2))}${reset}`);
+ if (lines.length > ROWS) shown.push(`${subtle} … ${lines.length - ROWS} more line${lines.length - ROWS === 1 ? '' : 's'}${reset}`);
+ const header = `${accent}Copy ${dialog.review.count} Console entr${dialog.review.count === 1 ? 'y' : 'ies'}?${reset}${subtle} Addresses and likely secrets are already replaced; this is exactly what will be copied.${reset}`;
+ return [truncateAnsi(header, width), ...shown.map(row => truncateAnsi(row, width)), ...renderControlRows([['Enter', 'copy'], ['Esc', 'back']], width)];
+ }
+ const counts = {error: 0, warning: 0, info: 0};
+ for (const record of log.entries()) counts[record.severity] += record.count;
+ const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`;
+ const how = dialog.feed.scripts ? 'scripts on' : 'scripts off';
+ const summary = [plural(counts.error, 'error'), plural(counts.warning, 'warning'), ...(counts.info ? [`${counts.info} message${counts.info === 1 ? '' : 's'}`] : []), ...(log.dropped ? [`${log.dropped} older dropped`] : [])].join(' · ');
+ // Runtime, not static: the word Console and the page's script state come first, so it is never mistaken for Problems.
+ const lead = `${accent}Console${reset}${subtle} (page runtime, ${how}) · ${summary} · ${FILTER_LABEL[dialog.filter]}${dialog.marked.size ? ` · ${dialog.marked.size} marked` : ''} · ${reset}`;
+ const header = `${lead}${truncateText(inert(dialog.query), Math.max(1, width - displayWidth(stripAnsi(lead)) - 1))}${accent}▏${reset}`;
+ const narrow = width < 60;
+ const start = Math.max(0, Math.min(index - Math.floor(ROWS / 2), items.length - ROWS));
+ const shown = items.slice(start, start + ROWS).map((record, offset) => {
+ const focused = start + offset === index;
+ const mark = dialog.marked.has(record.id) ? (safe ? '*' : '•') : ' ';
+ const tag = ORIGIN_TAG[record.origin].padEnd(4);
+ const count = record.count > 1 ? ` ×${record.count}` : '';
+ const place = narrow ? '' : where(record);
+ const placeWidth = narrow ? 0 : Math.min(28, Math.max(10, Math.floor(width * 0.25)));
+ const placeText = place ? `${subtle}${truncateText(inert(place), placeWidth).padEnd(placeWidth)}${reset} ` : narrow ? '' : `${' '.repeat(placeWidth)} `;
+ const lead = `${focused ? `${accent}▸` : ' '}${mark}${reset}${tone(record.severity)}${glyph(record.severity)}${reset} ${subtle}${tag}${reset} `;
+ const room = Math.max(4, width - 8 - (narrow ? 0 : placeWidth + 1) - count.length);
+ return `${lead}${placeText}${truncateText(inert(record.message.split('\n')[0] ?? ''), room)}${subtle}${count}${reset}`;
+ });
+ if (!items.length) {
+ const empty = log.size === 0
+ ? (dialog.feed.scripts ? 'Nothing reported yet. Entries appear as the page loads and runs.' : 'Nothing reported yet. Scripts are off, so only failed loads and refusals appear; T in the preview turns scripts on for this file.')
+ : 'Nothing matches this filter.';
+ for (const line of wrapWords(empty, Math.max(8, width - 2)).slice(0, 3)) shown.push(`${subtle} ${line}${reset}`);
+ }
+ // One line about the focused entry: where it is, or why it has no place in the text; or the latest answer.
+ const focused = items[index];
+ const detail = dialog.note ?? (focused ? detailLine(focused, width) : '');
+ return [truncateAnsi(header, width), ...shown.map(row => truncateAnsi(row, width)), truncateAnsi(`${subtle}${truncateText(inert(detail), Math.max(1, width))}${reset}`, width),
+ ...renderControlRows([['↑↓', 'choose'], ['Enter', 'go to source'], ['Tab', 'filter'], ['Shift+↑↓', 'mark'], ['Ctrl+C', 'copy…'], ['Del', 'clear'], ['Esc', 'back']], width)];
+}
+
+function wrapWords(text: string, width: number): string[] {
+ const lines: string[] = [];
+ let line = '';
+ for (const word of text.split(' ')) {
+ if (line && displayWidth(`${line} ${word}`) > width) { lines.push(line); line = word; } else line = line ? `${line} ${word}` : word;
+ }
+ if (line) lines.push(line);
+ return lines.map(item => truncateText(item, width));
+}
+
+function detailLine(record: ConsoleRecord, width: number): string {
+ const place = where(record);
+ const frame = record.stack?.[0];
+ const target = record.location?.target === 'document' ? '' : record.location?.target === 'local' ? ' (a file beside the document)' : record.location ? ' (outside the document)' : '';
+ // A row shows the first line of a message; a longer or multi-line one says how to read all of it.
+ const more = record.message.includes('\n') || displayWidth(record.message) > width - 40 ? 'Ctrl+C shows all of it' : '';
+ return [place ? `at ${place}${target}` : 'no source location', frame && !frame.endsWith(`(${place})`) ? `from ${frame}` : '', more].filter(Boolean).join(' · ');
+}
diff --git a/tests/browserConsole.test.ts b/tests/browserConsole.test.ts
new file mode 100644
index 00000000..9680a8ad
--- /dev/null
+++ b/tests/browserConsole.test.ts
@@ -0,0 +1,203 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import {classifyUrl, consoleCopyText, consoleEntry, ConsoleLog, MAX_BYTES, MAX_ENTRIES, MAX_MESSAGE, policyEntry, REDACTED, redactText, sourcePosition, type ConsoleEntry} from '../src/browser/console.js';
+
+/** The Console's core (#466): DevTools events to redacted, bounded entries, and when an entry may lead to the source. */
+
+const TOKEN = 'a1b2c3d4e5f60718293a4b5c6d7e8f90';
+const DOC = `http://127.0.0.1:41234/${TOKEN}/`;
+const context = {documentUrl: DOC, documentName: 'page.html', home: '/Users/someone'};
+const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f--]/u;
+
+test('addresses: the preview token never shows; files beside the document are relative; others lose credentials, query values and fragments', () => {
+ assert.deepEqual(classifyUrl(DOC, context), {target: 'document', label: 'page.html'});
+ assert.deepEqual(classifyUrl(`${DOC}index.html`, context), {target: 'local', label: 'index.html'}, 'a sibling index.html is a file on disk, not the buffer');
+ assert.deepEqual(classifyUrl(`${DOC}img/a%20b.png`, context), {target: 'local', label: 'img/a b.png'});
+ assert.deepEqual(classifyUrl(`http://127.0.0.1:41234/otherToken/x.js`, context), {target: 'external', label: `http://127.0.0.1:41234/${REDACTED}`});
+ const remote = classifyUrl('https://user:pw@cdn.example.com/lib.js?key=SECRET&v=2#access_token=abc', context);
+ assert.equal(remote.target, 'external');
+ assert.equal(remote.label, 'https://cdn.example.com/lib.js?key=…&v=…#…');
+ assert.equal(classifyUrl('data:image/png;base64,AAAA', context).label, 'data:image/png,…');
+ assert.equal(classifyUrl('blob:http://127.0.0.1/abc', context).label, 'blob:…');
+ assert.equal(classifyUrl('file:///Users/someone/secret/notes.txt', context).label, 'file://~/secret/notes.txt');
+ for (const value of [DOC, `${DOC}x.css`, 'https://a.example/?t=1']) assert.ok(!classifyUrl(value, context).label.includes(TOKEN));
+});
+
+test('messages: secrets, tokens, URLs and controls are redacted before anything is stored', () => {
+ const samples = [
+ 'Authorization: Bearer abcdefghijklmnop.qrstuv',
+ 'token=ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789',
+ '{"password": "hunter2hunter2", "user": "bob"}',
+ 'jwt eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0.c2lnbmF0dXJl',
+ 'key AKIAABCDEFGHIJKLMNOP leaked',
+ 'call sk-ant-api03-abcdefghijklmnopqrstuvwx failed',
+ `fetch ${DOC}data.json?session=xyz failed`,
+ `see ${TOKEN} in text`,
+ 'api_key: \'q1w2e3r4t5\'',
+ ];
+ const secrets = ['abcdefghijklmnop', 'ghp_ABCDEF', 'hunter2', 'eyJhbGci', 'AKIAABCDEF', 'sk-ant-api03', TOKEN, 'q1w2e3r4t5'];
+ for (const sample of samples) {
+ const out = redactText(sample, context);
+ for (const secret of secrets) assert.ok(!out.includes(secret), `${sample} -> ${out}`);
+ }
+ assert.match(redactText('"user": "bob"', context), /bob/u, 'ordinary values stay');
+ assert.match(redactText('author: Ada; design: x', context), /Ada.*design: x/u, 'words that merely contain a key name stay');
+ assert.equal(redactText('/Users/someone/project/a.js failed', context), '~/project/a.js failed');
+ assert.doesNotMatch(redactText('red \u001b[31mtext\u0007 evil', context), CONTROL);
+});
+
+test('exceptions: message, location and stack, from the DevTools event', () => {
+ const entry = consoleEntry('Runtime.exceptionThrown', {exceptionDetails: {
+ text: 'Uncaught', lineNumber: 11, columnNumber: 4, url: DOC,
+ exception: {description: 'TypeError: Cannot read properties of undefined (reading \'x\')\n at go (http://127.0.0.1:41234/' + TOKEN + '/:12:5)'},
+ stackTrace: {callFrames: [{functionName: 'go', url: DOC, lineNumber: 11, columnNumber: 4}, {functionName: '', url: `${DOC}lib.js`, lineNumber: 0, columnNumber: 9}]},
+ }}, context)!;
+ assert.equal(entry.severity, 'error');
+ assert.equal(entry.origin, 'exception');
+ assert.equal(entry.message, 'Uncaught TypeError: Cannot read properties of undefined (reading \'x\')');
+ assert.deepEqual(entry.location, {target: 'document', label: 'page.html', line: 11, column: 4});
+ assert.deepEqual(entry.stack, ['go (page.html:12:5)', '(anonymous) (lib.js:1:10)']);
+});
+
+test('console calls: severities, %-formatting, objects by description, nothing evaluated', () => {
+ const call = (type: string, args: unknown[]) => consoleEntry('Runtime.consoleAPICalled', {type, args, stackTrace: {callFrames: [{functionName: 'f', url: `${DOC}app.js`, lineNumber: 2, columnNumber: 1}]}}, context);
+ assert.equal(call('error', [{type: 'string', value: 'boom'}])!.severity, 'error');
+ assert.equal(call('warning', [{type: 'string', value: 'careful'}])!.severity, 'warning');
+ assert.equal(call('log', [{type: 'string', value: 'hello'}])!.severity, 'info');
+ assert.equal(call('assert', [{type: 'string', value: 'x'}])!.message, 'Assertion failed: x');
+ assert.equal(call('error', [{type: 'string', value: '%s has %d items %c!'}, {type: 'string', value: 'cart'}, {type: 'number', value: 3}, {type: 'string', value: 'color:red'}])!.message, 'cart has 3 items !');
+ assert.equal(call('error', [{type: 'object', className: 'Object', description: 'Object'}, {type: 'undefined'}, {type: 'object', subtype: 'null'}, {type: 'number', unserializableValue: 'NaN'}])!.message, 'Object undefined null NaN');
+ assert.deepEqual(call('error', [{type: 'string', value: 'x'}])!.location, {target: 'local', label: 'app.js', line: 2, column: 1});
+ assert.equal(call('clear', []), undefined);
+});
+
+test('log entries: failed loads and HTTP errors, CSP refusals; NMSh\'s own refusals are not echoed twice', () => {
+ const missing = consoleEntry('Log.entryAdded', {entry: {source: 'network', level: 'error', text: 'Failed to load resource: the server responded with a status of 404 (Not Found)', url: `${DOC}missing.png`}}, context)!;
+ assert.equal(missing.origin, 'network');
+ assert.equal(missing.severity, 'error');
+ assert.deepEqual(missing.location, {target: 'local', label: 'missing.png'});
+ const csp = consoleEntry('Log.entryAdded', {entry: {source: 'security', level: 'error', text: 'Refused to load the script \'https://cdn.example.com/x.js?k=1\' because it violates the following Content Security Policy directive: "script-src \'none\'"', url: DOC}}, context)!;
+ assert.equal(csp.origin, 'security');
+ assert.match(csp.message, /https:\/\/cdn\.example\.com\/x\.js\?k=…/u);
+ assert.equal(consoleEntry('Log.entryAdded', {entry: {source: 'network', level: 'error', text: 'Failed to load resource: net::ERR_BLOCKED_BY_CLIENT', url: 'https://x.example/'}}, context), undefined);
+ assert.equal(consoleEntry('Log.entryAdded', {entry: {source: 'violation', level: 'verbose', text: 'x'}}, context), undefined);
+ const policy = policyEntry('https://tracker.example/p.gif?uid=42', 'a preview loads nothing from outside its own folder', context);
+ assert.equal(policy.origin, 'policy');
+ assert.equal(policy.location?.label, 'https://tracker.example/p.gif?uid=…');
+});
+
+test('hostile input: huge, malformed and control-laden events are bounded and never throw', () => {
+ const huge = consoleEntry('Runtime.consoleAPICalled', {type: 'error', args: [{type: 'string', value: 'x'.repeat(5_000_000)}]}, context)!;
+ assert.ok(huge.message.length <= MAX_MESSAGE);
+ const started = process.hrtime.bigint();
+ consoleEntry('Runtime.consoleAPICalled', {type: 'error', args: [{type: 'string', value: 'password='.repeat(100_000)}]}, context);
+ consoleEntry('Runtime.consoleAPICalled', {type: 'error', args: [{type: 'string', value: `https://${'a'.repeat(200_000)}`}]}, context);
+ assert.ok(Number(process.hrtime.bigint() - started) / 1e6 < 1000, 'redaction stays fast on hostile text');
+ for (const params of [undefined, null, {}, {exceptionDetails: null}, {args: 'nope'}, {entry: 5}, {exceptionDetails: {lineNumber: -4, url: 7}}]) {
+ for (const method of ['Runtime.exceptionThrown', 'Runtime.consoleAPICalled', 'Log.entryAdded']) assert.doesNotThrow(() => consoleEntry(method, params, context));
+ }
+ const odd = consoleEntry('Runtime.exceptionThrown', {exceptionDetails: {text: 'Uncaught', url: DOC, lineNumber: 1e12, columnNumber: -1}}, context)!;
+ assert.equal(odd.location?.line, undefined);
+ const controls = consoleEntry('Runtime.consoleAPICalled', {type: 'error', args: [{type: 'string', value: '\u001b]52;c;Zm9v\u0007\u001b[2Jx'}], stackTrace: {callFrames: [{functionName: '\u001b[31mf', url: DOC, lineNumber: 0, columnNumber: 0}]}}, context)!;
+ assert.doesNotMatch(controls.message + (controls.stack ?? []).join(''), CONTROL);
+});
+
+test('the log: ordered, repeats collapse, bounded by count and bytes, cleared and reset', () => {
+ const log = new ConsoleLog();
+ let changes = 0;
+ const stop = log.onChange(() => { changes += 1; });
+ const entry = (message: string): ConsoleEntry => ({severity: 'error', origin: 'console', message});
+ log.add(entry('a'));
+ log.add(entry('a'));
+ log.add(entry('b'));
+ assert.deepEqual(log.entries().map(record => [record.message, record.count]), [['a', 2], ['b', 1]]);
+ assert.ok(log.entries()[0]!.id < log.entries()[1]!.id);
+ for (let index = 0; index < MAX_ENTRIES + 50; index++) log.add(entry(`n${index}`));
+ assert.equal(log.size, MAX_ENTRIES);
+ assert.equal(log.entries().at(-1)!.message, `n${MAX_ENTRIES + 49}`);
+ assert.ok(log.dropped >= 50);
+ log.clear();
+ assert.equal(log.size, 0);
+ assert.equal(log.dropped, 0);
+ for (let index = 0; index < 400; index++) log.add(entry(`${index}`.padEnd(MAX_MESSAGE, 'x')));
+ const bytes = log.entries().reduce((sum, record) => sum + record.message.length * 2, 0);
+ assert.ok(bytes <= MAX_BYTES, `${bytes}`);
+ log.reset();
+ assert.equal(log.size, 0);
+ assert.ok(changes > 0);
+ stop();
+ log.dispose();
+});
+
+test('source positions are offered only when certain', () => {
+ const lines = ['', ''];
+ const text = lines.join('\n');
+ const at = (location: ConsoleEntry['location'], extra: Partial[1]> = {}) =>
+ sourcePosition({severity: 'error', origin: 'exception', message: 'x', ...(location ? {location} : {})}, {kind: 'html', servedText: text, currentText: text, lines, ...extra});
+ assert.deepEqual(at({target: 'document', label: 'page.html', line: 2, column: 2}), {line: 2, column: 2});
+ assert.deepEqual(at({target: 'document', label: 'page.html', line: 2, column: 99}), {line: 2, column: 9}, 'the column is clamped to the line');
+ assert.match((at(undefined) as {reason: string}).reason, /no source location/u);
+ assert.match((at({target: 'external', label: 'https://x/'}) as {reason: string}).reason, /outside/u);
+ assert.match((at({target: 'local', label: 'app.js', line: 1}) as {reason: string}).reason, /app\.js/u);
+ assert.match((at({target: 'document', label: 'p', line: 2}, {kind: 'markdown'}) as {reason: string}).reason, /Markdown/u);
+ assert.match((at({target: 'document', label: 'p', line: 2}, {currentText: `${text}\n`}) as {reason: string}).reason, /changed/u);
+ assert.match((at({target: 'document', label: 'p', line: 2}, {servedText: undefined}) as {reason: string}).reason, /changed/u);
+ assert.match((at({target: 'document', label: 'p', line: 40}) as {reason: string}).reason, /past the end/u);
+ assert.match((at({target: 'document', label: 'p'}) as {reason: string}).reason, /no line/u);
+});
+
+test('copy text is plain, redacted and says what it is', () => {
+ const log = new ConsoleLog();
+ log.add(consoleEntry('Runtime.exceptionThrown', {exceptionDetails: {text: 'Uncaught', url: DOC, lineNumber: 2, columnNumber: 0, exception: {description: 'Error: token=abc123secret'}, stackTrace: {callFrames: [{functionName: 'f', url: DOC, lineNumber: 2, columnNumber: 0}]}}}, context)!);
+ log.add(policyEntry('https://x.example/a?b=c', 'remote', context));
+ const text = consoleCopyText(log.entries(), 'page.html');
+ assert.match(text, /^Console of page\.html \(NMSh browser preview, redacted\)/u);
+ assert.match(text, /\[error\] exception page\.html:3:1: Uncaught Error: token=‹redacted›/u);
+ assert.match(text, / {4}at f \(page\.html:3:1\)/u);
+ assert.ok(!text.includes('abc123secret') && !text.includes(TOKEN));
+ assert.doesNotMatch(text, /\u001b/u);
+});
+
+test('review regressions: whole URLs are redacted, unparsable ones lose credentials and queries, the token never reaches a label', () => {
+ const leaks = [
+ ['Refused to load https://api.example/x?q=(hello)&user=alice&id=42', ['alice', 'hello', '=42']],
+ ['see https://api.example/x?filter[name]=bob&email=bob@x.io now', ['bob']],
+ [`long https://a.example/?a=${'x'.repeat(2100)}&user=alice`, ['alice']],
+ ['xhttps://admin:hunter2@host.example/x?user=alice', ['hunter2', 'alice']],
+ ['https://admin:hunter2@host.example:99999/x?user=alice', ['hunter2', 'alice']],
+ ] as const;
+ for (const [input, secrets] of leaks) {
+ const out = redactText(input, context);
+ for (const secret of secrets) assert.ok(!out.includes(secret), `${input.slice(0, 80)} -> ${out.slice(0, 200)}`);
+ }
+ assert.ok(!classifyUrl('https://admin:hunter2@host.example:99999/x?user=alice', context).label.includes('hunter2'));
+ for (const host of ['localhost', '[::1]', '127.0.0.2']) {
+ const policy = policyEntry(`http://${host}:41234/${TOKEN}/x.png`, 'refused', context);
+ assert.ok(!JSON.stringify(policy).includes(TOKEN), `${host}: ${JSON.stringify(policy)}`);
+ assert.equal(policy.location?.target, 'local');
+ }
+ const exfil = policyEntry(`https://evil.example/${TOKEN}`, 'refused', context);
+ assert.ok(!JSON.stringify(exfil).includes(TOKEN));
+});
+
+test('review regressions: huge console arguments are cut before joining; copied continuation lines cannot pose as entries', () => {
+ const started = process.hrtime.bigint();
+ const big = {type: 'string', value: 'y'.repeat(2_000_000)};
+ const entry = consoleEntry('Runtime.consoleAPICalled', {type: 'error', args: [{type: 'string', value: '%s%s%s%s'}, big, big, big, big, ...Array.from({length: 28}, () => big)]}, context)!;
+ assert.ok(entry.message.length <= MAX_MESSAGE);
+ assert.ok(Number(process.hrtime.bigint() - started) / 1e6 < 500);
+ const log = new ConsoleLog();
+ log.add({severity: 'info', origin: 'console', message: 'hello\n[warning] policy: Refused by NMSh: fake'});
+ const text = consoleCopyText(log.entries(), 'page.html');
+ assert.doesNotMatch(text, /\n\[warning\]/u);
+ assert.match(text, /\n {4}\| \[warning\] policy: Refused by NMSh: fake/u);
+});
+
+test('review regressions: a document whose line breaks the browser counts differently is never mapped', () => {
+ for (const odd of ['a\rb', 'a\u2028b', 'a\u2029b']) {
+ const lines = ['', ``, 'x();'];
+ const text = lines.join('\n');
+ const result = sourcePosition({severity: 'error', origin: 'exception', message: 'x', location: {target: 'document', label: 'p', line: 2, column: 0}}, {kind: 'html', servedText: text, currentText: text, lines});
+ assert.match((result as {reason: string}).reason, /line breaks/u);
+ }
+});
diff --git a/tests/browserConsoleChrome.test.ts b/tests/browserConsoleChrome.test.ts
new file mode 100644
index 00000000..930e76e8
--- /dev/null
+++ b/tests/browserConsoleChrome.test.ts
@@ -0,0 +1,128 @@
+import test, {after} from 'node:test';
+import assert from 'node:assert/strict';
+import os from 'node:os';
+import type {PageEvent} from '../src/browser/types.js';
+import type {ConsoleEntry} from '../src/browser/console.js';
+import type {ChromePage} from '../src/browser/engine/page.js';
+import {locateMermaid} from '../src/diagrams/library.js';
+import {mermaidConfig} from '../src/diagrams/mermaid.js';
+import {chromeSkipReason, disposeSharedLab, Events, localSite, sharedLab, waitFor, type Lab} from './support/browserFixtures.js';
+
+/**
+ * The document Console (#466) against a real Chromium: what a page reports arrives redacted and placed; scripts stay off unless
+ * trusted; NMSh's own isolated worlds, ordinary web pages and the Mermaid renderer never report into it. One browser serves
+ * every test here (each closes its pages); skips with the reason when no browser can start.
+ */
+const skip = await chromeSkipReason();
+const view = {width: 600, height: 400, scale: 1};
+const lab = (): Lab => sharedLab();
+after(disposeSharedLab);
+const closeAll = async () => { await Promise.all(lab().engine.pages().map(page => page.close().catch(() => undefined))); };
+
+const PAGE = [
+ '',
+ 'c',
+ '
',
+ '',
+ 'text
',
+].join('\n');
+
+const entries = (events: Events): ConsoleEntry[] => events.of('console').map(event => event.entry);
+const open = async (html: string, trusted: boolean, files: Record = {}) => {
+ const handle = await lab().preview(html, {trusted, files});
+ const page = await lab().engine.open({kind: 'preview', viewport: view, url: handle.url, scripts: trusted, console: {documentName: 'page.html'}});
+ return {page, handle, events: new Events(page), token: new URL(handle.url).pathname.split('/')[1]!};
+};
+
+test('a trusted page: exception, console calls and failed loads arrive redacted, with the document\'s own lines', {skip}, async () => {
+ try {
+ const {events, token} = await open(PAGE, true);
+ const exception = await waitFor(() => entries(events).find(entry => entry.origin === 'exception'), 'the exception');
+ assert.match(exception.message, /ReferenceError: undefinedFunction is not defined/u);
+ assert.equal(exception.location?.target, 'document');
+ assert.equal(exception.location?.label, 'page.html');
+ assert.equal(exception.location?.line, 7, 'the line in the HTML document itself (0-based)');
+ assert.equal(typeof exception.location?.column, 'number');
+ const error = await waitFor(() => entries(events).find(entry => entry.origin === 'console' && entry.severity === 'error'), 'console.error');
+ assert.equal(error.message, 'bad thing');
+ assert.equal(error.location?.line, 5);
+ const warning = entries(events).find(entry => entry.origin === 'console' && entry.severity === 'warning')!;
+ assert.match(warning.message, /password=‹redacted›/u);
+ assert.doesNotMatch(warning.message, /hunter2/u);
+ const missing = await waitFor(() => entries(events).find(entry => entry.origin === 'network' && entry.location?.label === 'missing.png'), 'the missing image');
+ assert.match(missing.message, /404/u);
+ await waitFor(() => entries(events).find(entry => entry.origin === 'network' && entry.location?.label === 'nope.json'), 'the failed fetch');
+ for (const entry of entries(events)) assert.ok(!JSON.stringify(entry).includes(token), `the preview token leaked: ${JSON.stringify(entry)}`);
+ } finally { await closeAll(); }
+});
+
+test('scripts off: nothing runs and nothing is turned on; failed loads and policy refusals still appear', {skip}, async () => {
+ const remote = await localSite();
+ try {
+ const html = `${PAGE}\n
`;
+ const {page, events} = await open(html, false);
+ await waitFor(() => entries(events).find(entry => entry.origin === 'network' && entry.location?.label === 'missing.png'), 'the missing image');
+ const refusal = await waitFor(() => entries(events).find(entry => (entry.origin === 'security' || entry.origin === 'policy') && entry.message.includes('track.png') || entry.location?.label?.includes('track.png')), 'the refusal of the remote image');
+ assert.doesNotMatch(JSON.stringify(refusal), /uid=42/u);
+ await new Promise(resolve => setTimeout(resolve, 300));
+ assert.ok(!entries(events).some(entry => entry.origin === 'exception' || entry.origin === 'console'), 'no script ran');
+ assert.ok(!entries(events).some(entry => entry.location?.label === 'nope.json'), 'the script\'s fetch never happened');
+ assert.equal(page.url.startsWith('http://127.0.0.1:'), true);
+ assert.deepEqual(remote.hits, [], 'nothing left the machine');
+ } finally { await closeAll(); await remote.close(); }
+});
+
+test('a reload starts a new document; NMSh\'s find world, web pages and the Mermaid renderer never report', {skip}, async () => {
+ const site = await localSite({'/noisy': ''});
+ try {
+ const {page, events} = await open(PAGE, true);
+ await waitFor(() => entries(events).find(entry => entry.origin === 'exception'), 'the first exception');
+ const before = events.all.length;
+ // Find runs in an isolated world: even a failing evaluation there is not the page's.
+ await page.find('text');
+ await (page as ChromePage).protocol('Runtime.evaluate', {expression: 'console.error("isolated noise"); throw new Error("isolated")', contextId: await isolatedWorld(page as ChromePage)}).catch(() => undefined);
+ const web = await lab().engine.open({kind: 'web', viewport: view, url: `${site.origin}/noisy`});
+ const webEvents = new Events(web);
+ await waitFor(() => !web.loading && web.url.endsWith('/noisy') && true, 'the web page');
+ await page.reload();
+ await waitFor(() => events.all.slice(before).some(event => event.type === 'document'), 'a new document');
+ const after = events.all.slice(events.all.findIndex((event, index) => index >= before && event.type === 'document'));
+ await waitFor(() => after.length && events.all.slice(before).filter(event => event.type === 'console' && event.entry.origin === 'exception').length >= 1 && true, 'the exception again');
+ const all = entries(events);
+ assert.ok(!all.some(entry => /isolated noise|isolated|web page/u.test(entry.message)), JSON.stringify(all.map(entry => entry.message)));
+ assert.equal(webEvents.of('console').length, 0, 'a web page reports nothing');
+ const library = await locateMermaid({env: {...process.env, ...(process.env.NMSH_TEST_MERMAID ? {NMSH_MERMAID: process.env.NMSH_TEST_MERMAID} : {})}, home: os.homedir(), execPath: process.execPath});
+ if (library.ok) {
+ const renderer = await lab().engine.openDiagramRenderer!({source: library.source, version: library.version});
+ const rendererPage = lab().engine.pages().at(-1)!;
+ const rendererEvents: PageEvent[] = [];
+ rendererPage.onEvent(event => rendererEvents.push(event));
+ const result = await renderer.render({source: 'flowchart LR\n A --> B\n B --> %%{init: bad}%% C\n click A call alert()', config: mermaidConfig({scheme: 'dark', text: '#ffffff', accent: '#aaaaff', line: '#888888'}), scale: 1, timeoutMs: 10_000});
+ assert.ok(result.ok || result.code === 'syntax');
+ assert.equal(rendererEvents.filter(event => event.type === 'console').length, 0, 'the diagram renderer reports nothing');
+ assert.ok(!entries(events).some(entry => /mermaid/iu.test(entry.message)));
+ await renderer.close();
+ }
+ } finally { await closeAll(); await site.close(); }
+});
+
+async function isolatedWorld(page: ChromePage): Promise {
+ const tree = await page.protocol<{frameTree: {frame: {id: string}}}>('Page.getFrameTree');
+ return (await page.protocol<{executionContextId: number}>('Page.createIsolatedWorld', {frameId: tree.frameTree.frame.id, worldName: 'test-isolated'})).executionContextId;
+}
+
+test('a hostile page: a flood and huge messages are cut down before they are kept', {skip}, async () => {
+ try {
+ const html = '';
+ const {events} = await open(html, true);
+ await waitFor(() => entries(events).some(entry => entry.message.endsWith('end')), 'the last message', 30_000);
+ const all = entries(events);
+ assert.ok(all.every(entry => entry.message.length <= 2000));
+ assert.ok(all.every(entry => !/[\u0000-\u0008\u000b-\u001f\u007f-\u009f-]/u.test(entry.message)));
+ } finally { await closeAll(); }
+});
diff --git a/tests/browserConsoleView.test.ts b/tests/browserConsoleView.test.ts
new file mode 100644
index 00000000..79cb005f
--- /dev/null
+++ b/tests/browserConsoleView.test.ts
@@ -0,0 +1,364 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import type {GraphicsDecision} from '../src/graphics/capabilities.js';
+import {FrameStream} from '../src/graphics/stream.js';
+import {BrowserPreviewView, type DocumentPreviewDeps} from '../src/browser/documentPreview.js';
+import {BrowserSession} from '../src/browser/session.js';
+import {MAX_ENTRIES, type ConsoleEntry} from '../src/browser/console.js';
+import type {PreviewDocument, PreviewHandle, PreviewServer} from '../src/browser/types.js';
+import {createDocumentSession, documentSessionKey, renderDocumentSession, setDocumentMode, type DocumentSession, type DocumentSessionAction} from '../src/viewer/DocumentWorkspace.js';
+import {openForEdit} from '../src/viewer/fileEdit.js';
+import {decodeKeys} from '../src/terminal/keys.js';
+import {setIconStyle} from '../src/ui/glyphs.js';
+import {displayWidth, stripAnsi} from '../src/util/text.js';
+import {FakeClock, FakeEngine, settle, type FakePage} from './support/fakeBrowser.js';
+
+/**
+ * The document Console (#466) against a fake engine: what reaches it, what never does, the dialog's keys and rows, and that
+ * opening, using and closing it changes nothing about the page's rules or the document's text, selection, dirty state or undo.
+ */
+
+const KITTY: GraphicsDecision = {backend: 'kitty', evidence: 'query', inline: 'placeholder', unicode: 'quadrant', cell: {width: 8, height: 16, source: 'estimated'}, reason: ''};
+
+class FakePreviews implements PreviewServer {
+ served: PreviewDocument[] = [];
+ async serve(document: PreviewDocument): Promise {
+ this.served.push(document);
+ return {url: `http://127.0.0.1:9/tok${this.served.length}tok${this.served.length}/`, update: () => {}, problems: () => [], dispose: () => {}};
+ }
+ async close(): Promise {}
+}
+
+function rig() {
+ const engine = new FakeEngine();
+ const clock = new FakeClock();
+ const previews = new FakePreviews();
+ let id = 0x4e4d00;
+ const session = new BrowserSession({
+ engine, clock, scale: 1, cell: () => ({width: 8, height: 16}),
+ openStream: () => new FrameStream({decision: () => KITTY, send: () => {}, allocateId: () => ++id, color: () => 'truecolor', safeGlyphs: () => false, now: () => clock.time}),
+ });
+ const deps: DocumentPreviewDeps = {session, previews, html: text => text, geometry: () => ({top: 1, left: 1, cell: {width: 8, height: 16}, scale: 1}), openLink: () => {}, dirty: () => false};
+ return {engine, clock, previews, session, deps};
+}
+
+const HTML = ['', 't', '', 'Hello
'];
+const scratch = () => fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMPDIR ?? os.tmpdir(), 'nmsh-console-')));
+
+async function workspace(options: {kind?: 'html' | 'markdown'; trusted?: boolean; text?: string[]} = {}) {
+ const dir = scratch();
+ const kind = options.kind ?? 'html';
+ const file = path.join(dir, kind === 'html' ? 'page.html' : 'page.md');
+ const lines = options.text ?? (kind === 'html' ? HTML : ['# Title', '', 'text']);
+ fs.writeFileSync(file, `${lines.join('\n')}\n`);
+ const opened = await openForEdit(file, '/');
+ if (!opened.ok) throw new Error(opened.reason);
+ const state = createDocumentSession(opened.file, 'source');
+ const r = rig();
+ setDocumentMode(state, 'preview');
+ const view = await BrowserPreviewView.open(r.deps, {name: path.basename(file), root: dir, text: state.editor.buffer.lines.join('\n'), trusted: options.trusted === true, kind});
+ if (!(view instanceof BrowserPreviewView)) throw new Error(view.error);
+ state.preview = view;
+ const page = r.engine.opened[0]!;
+ const actions: DocumentSessionAction[] = [];
+ const press = (bytes: string, columns = 100, height = 30) => { for (const key of decodeKeys(bytes)) { const action = documentSessionKey(state, key, columns, height); if (action) actions.push(action); } };
+ const screen = (columns = 100, height = 30) => renderDocumentSession(state, columns, height).map(row => stripAnsi(row)).join('\n');
+ return {...r, state, view, page, press, screen, actions, dir, cleanup: () => { view.dispose(); fs.rmSync(dir, {recursive: true, force: true}); }};
+}
+
+const error = (message: string, location?: ConsoleEntry['location']): ConsoleEntry => ({severity: 'error', origin: 'exception', message, ...(location ? {location} : {})});
+const warn = (message: string): ConsoleEntry => ({severity: 'warning', origin: 'network', message, location: {target: 'local', label: 'missing.png'}});
+const info = (message: string): ConsoleEntry => ({severity: 'info', origin: 'console', message});
+const SHIFT_DOWN = '\u001b[1;2B', DEL = '\u001b[3~', CTRL_C = '\u0003', TAB = '\t', ESC = '\u001b', UP = '\u001b[A', DOWN = '\u001b[B';
+
+test('the preview page is opened with a Console and scripts as the trust decision says; web tabs get none', async () => {
+ const w = await workspace();
+ try {
+ assert.deepEqual(w.engine.options[0]!.console, {documentName: 'page.html'});
+ assert.equal(w.engine.options[0]!.scripts, false);
+ assert.ok(w.session.console());
+ const browser = rig();
+ await browser.session.open('https://example.com/');
+ assert.equal(browser.engine.options[0]!.console, undefined);
+ assert.equal(browser.session.console(), undefined);
+ // A page that reports anyway (it cannot: the engine only reports for previews opened with a Console) is ignored.
+ browser.engine.opened[0]!.emit({type: 'console', entry: error('from a web page')});
+ assert.equal(browser.session.console(), undefined);
+ browser.session.dispose();
+ } finally { w.cleanup(); }
+});
+
+test('opening and using the Console never turns scripts or the network on, sends nothing to the page and keeps the document as it was', async () => {
+ const w = await workspace();
+ try {
+ // An edit, a selection and a dirty buffer, made before the Console opens.
+ setDocumentMode(w.state, 'edit');
+ w.press('x');
+ const buffer = w.state.editor.buffer;
+ buffer.anchor = {line: 1, column: 0};
+ buffer.caret = {line: 1, column: 5};
+ setDocumentMode(w.state, 'preview');
+ const before = {text: buffer.lines.join('\n'), caret: {...buffer.caret}, anchor: {...buffer.anchor!}, dirty: buffer.dirty};
+ const calls = w.page.calls.length;
+ w.page.emit({type: 'console', entry: error('Uncaught ReferenceError: missing is not defined', {target: 'document', label: 'page.html', line: 3, column: 2})});
+ w.press('C');
+ assert.equal(w.state.editor.dialog?.kind, 'console');
+ w.press('miss'); w.press(TAB); w.press(TAB); w.press(TAB); w.press(UP); w.press(DOWN); w.press(SHIFT_DOWN); w.press(CTRL_C); w.press(ESC); w.press(DEL); w.press(ESC);
+ assert.equal(w.state.editor.dialog, undefined);
+ assert.equal(w.state.mode, 'preview');
+ assert.equal(w.page.calls.length, calls, `the page was asked for nothing: ${JSON.stringify(w.page.calls.slice(calls))}`);
+ assert.equal(w.page.count('setScripts'), 0);
+ assert.equal(w.view.trusted, false);
+ assert.deepEqual({text: buffer.lines.join('\n'), caret: buffer.caret, anchor: buffer.anchor, dirty: buffer.dirty}, before);
+ // The edit made before is still undoable.
+ assert.ok(buffer.undo());
+ assert.equal(buffer.lines.join('\n'), HTML.join('\n'));
+ assert.ok(!w.actions.some(action => action.kind === 'trust' || action.kind === 'preview'));
+ } finally { w.cleanup(); }
+});
+
+test('keys belong to the Console while it is open: letters search, nothing reaches the page or the text', async () => {
+ const w = await workspace();
+ try {
+ w.page.emit({type: 'console', entry: error('alpha failure')});
+ w.page.emit({type: 'console', entry: warn('beta missing')});
+ w.press('C');
+ w.press('beta eh');
+ assert.equal(w.state.editor.dialog?.console?.query, 'beta eh');
+ assert.equal(w.page.count('key'), 0);
+ assert.equal(w.state.editor.buffer.dirty, false);
+ w.press('\u007f\u007f\u007f');
+ assert.match(w.screen(), /beta missing/u);
+ assert.doesNotMatch(w.screen(), /alpha failure/u);
+ } finally { w.cleanup(); }
+});
+
+test('filters, search, newest first, clear and the counts in the header', async () => {
+ const w = await workspace({trusted: true});
+ try {
+ for (const entry of [info('first log'), warn('a 404'), error('boom one'), error('boom one'), error('boom two')]) w.page.emit({type: 'console', entry});
+ w.press('C');
+ const screen = w.screen();
+ assert.match(screen, /Console \(page runtime, scripts on\) · 3 errors · 1 warning · 1 message/u);
+ const rows = screen.split('\n');
+ assert.ok(rows.findIndex(row => row.includes('boom two')) < rows.findIndex(row => row.includes('first log')), 'newest first');
+ assert.match(screen, /boom one ×2/u);
+ w.press(TAB);
+ assert.doesNotMatch(w.screen(), /first log/u);
+ assert.match(w.screen(), /errors and warnings/u);
+ w.press(TAB);
+ assert.doesNotMatch(w.screen(), /a 404/u);
+ w.press(TAB);
+ w.press('two');
+ assert.match(w.screen(), /boom two/u);
+ assert.doesNotMatch(w.screen(), /boom one/u);
+ w.press(DEL);
+ assert.equal(w.session.console()!.size, 0);
+ assert.match(w.screen(), /Cleared/u);
+ w.page.emit({type: 'console', entry: error('after clearing')});
+ w.press('\u0015');
+ assert.match(w.screen(), /after clearing/u);
+ } finally { w.cleanup(); }
+});
+
+test('copy shows exactly what will be copied first, marked entries in order, and Esc copies nothing', async () => {
+ const w = await workspace();
+ try {
+ w.page.emit({type: 'console', entry: error('one')});
+ w.page.emit({type: 'console', entry: error('two')});
+ w.page.emit({type: 'console', entry: error('three')});
+ w.press('C');
+ w.press(CTRL_C);
+ assert.match(w.screen(), /Copy 1 Console entry\?/u);
+ w.press(ESC);
+ assert.equal(w.actions.length, 0);
+ assert.equal(w.state.editor.dialog?.kind, 'console', 'Esc leaves the review, not the Console');
+ w.press(SHIFT_DOWN); w.press(SHIFT_DOWN);
+ w.press(CTRL_C);
+ const review = w.screen();
+ assert.match(review, /Copy 2 Console entries\?/u);
+ assert.match(review, /\[error\] exception: two/u);
+ w.press('\r');
+ const copy = w.actions.at(-1);
+ assert.equal(copy?.kind, 'copy');
+ const text = (copy as {text: string}).text;
+ assert.equal(text, 'Console of page.html (NMSh browser preview, redacted)\n[error] exception: two\n[error] exception: three');
+ assert.match(w.screen(), /Copied 2 entries/u);
+ } finally { w.cleanup(); }
+});
+
+test('Enter goes to the source only when the place is certain, and then shows the text', async () => {
+ const w = await workspace();
+ try {
+ w.page.emit({type: 'console', entry: error('elsewhere', {target: 'external', label: 'https://cdn.example/x.js', line: 1})});
+ w.press('C');
+ w.press('\r');
+ assert.match(w.screen(), /No place in the text to go to: it comes from outside the document/u);
+ assert.equal(w.state.mode, 'preview');
+ w.press(ESC);
+ w.page.emit({type: 'console', entry: error('beside', {target: 'local', label: 'app.js', line: 0})});
+ w.press('C'); w.press('\r');
+ assert.match(w.screen(), /it is in app\.js, not this document/u);
+ w.press(ESC);
+ w.page.emit({type: 'console', entry: error('Uncaught ReferenceError: missing is not defined', {target: 'document', label: 'page.html', line: 3, column: 2})});
+ w.press('C'); w.press('\r');
+ assert.equal(w.state.editor.dialog, undefined);
+ assert.equal(w.state.mode, 'edit');
+ assert.deepEqual(w.state.editor.buffer.caret, {line: 3, column: 2});
+ assert.ok(w.state.editor.jumps?.length, 'Back can return');
+ } finally { w.cleanup(); }
+});
+
+test('a changed text or a Markdown page is never treated as an exact place', async () => {
+ const md = await workspace({kind: 'markdown'});
+ try {
+ md.page.emit({type: 'console', entry: error('x', {target: 'document', label: 'page.md', line: 1, column: 0})});
+ md.press('C'); md.press('\r');
+ assert.match(md.screen(), /generated from Markdown/u);
+ } finally { md.cleanup(); }
+ const html = await workspace();
+ try {
+ html.state.editor.buffer.insert('changed ');
+ html.page.emit({type: 'console', entry: error('x', {target: 'document', label: 'page.html', line: 3, column: 0})});
+ html.press('C'); html.press('\r');
+ assert.match(html.screen(), /the text changed since the page loaded/u);
+ assert.equal(html.state.mode, 'preview');
+ } finally { html.cleanup(); }
+});
+
+test('a new document load starts the Console again; a crash is recorded; a refresh after it reopens the page', async () => {
+ const w = await workspace();
+ try {
+ w.page.emit({type: 'console', entry: error('old load')});
+ w.page.emit({type: 'document'});
+ w.page.emit({type: 'console', entry: error('new load')});
+ assert.deepEqual(w.session.console()!.entries().map(record => record.message), ['new load']);
+ w.page.emit({type: 'crashed'});
+ assert.match(w.session.console()!.entries().at(-1)!.message, /crashed/u);
+ await w.view.refresh(w.state.editor.buffer.lines.join('\n'));
+ await settle();
+ assert.equal(w.engine.opened.length, 2, 'a new page');
+ const second = w.engine.opened[1]!;
+ second.emit({type: 'document'});
+ second.emit({type: 'console', entry: error('from the new page')});
+ assert.deepEqual(w.session.console()!.entries().map(record => record.message), ['from the new page']);
+ // The crashed page's listeners are gone: it cannot add to the Console any more.
+ w.page.emit({type: 'console', entry: error('ghost')});
+ assert.ok(!w.session.console()!.entries().some(record => record.message === 'ghost'));
+ } finally { w.cleanup(); }
+});
+
+test('two documents\' previews never see each other\'s entries, and a closed preview has no Console', async () => {
+ const a = await workspace();
+ const b = await workspace();
+ try {
+ a.page.emit({type: 'console', entry: error('only in A')});
+ b.page.emit({type: 'console', entry: error('only in B')});
+ assert.deepEqual(a.view.console()!.log.entries().map(record => record.message), ['only in A']);
+ assert.deepEqual(b.view.console()!.log.entries().map(record => record.message), ['only in B']);
+ a.view.dispose();
+ assert.equal(a.view.console(), undefined);
+ assert.equal(a.page.eventListeners.size, 0, 'no listener outlives the preview');
+ } finally { a.cleanup(); b.cleanup(); }
+});
+
+test('repeated open and close keeps no listeners; a flood stays bounded and quick to draw', async () => {
+ const w = await workspace();
+ try {
+ const listeners = w.page.eventListeners.size;
+ for (let index = 0; index < 50; index++) { w.press('C'); w.press('x'); w.press(ESC); }
+ assert.equal(w.page.eventListeners.size, listeners);
+ for (let index = 0; index < 20_000; index++) w.page.emit({type: 'console', entry: error(`flood ${index} ${'z'.repeat(index % 300)}`)});
+ assert.ok(w.session.console()!.size <= MAX_ENTRIES);
+ w.press('C');
+ const started = process.hrtime.bigint();
+ for (let index = 0; index < 20; index++) w.screen();
+ assert.ok(Number(process.hrtime.bigint() - started) / 1e6 < 1000);
+ assert.match(w.screen(), /older dropped/u);
+ } finally { w.cleanup(); }
+});
+
+test('rows fit 30, 50, 80 and 120 columns with colour, NO_COLOR and Safe Glyphs; severity is never colour alone', async () => {
+ const w = await workspace();
+ const saved = process.env.NO_COLOR;
+ try {
+ w.page.emit({type: 'console', entry: warn('Failed to load resource: the server responded with a status of 404 (Not Found)')});
+ w.page.emit({type: 'console', entry: error(`Uncaught TypeError: ${'long '.repeat(60)} 日本語 😀`, {target: 'document', label: 'page.html', line: 3, column: 2})});
+ w.press('C');
+ for (const safe of [false, true]) {
+ setIconStyle(safe ? 'safe' : 'nerd');
+ for (const noColor of [false, true]) {
+ if (noColor) process.env.NO_COLOR = '1'; else delete process.env.NO_COLOR;
+ for (const columns of [30, 50, 80, 120]) {
+ const rows = renderDocumentSession(w.state, columns, 24);
+ for (const row of rows) assert.ok(displayWidth(stripAnsi(row)) <= columns, `${columns}: ${stripAnsi(row)}`);
+ const text = rows.map(row => stripAnsi(row)).join('\n');
+ assert.match(text, safe ? /E (JS|net)/u : /✖ (JS|net)/u);
+ if (safe) assert.doesNotMatch(text, /[✖▲ℹ•]/u);
+ if (columns >= 80) assert.match(text, /page\.html:4:3/u); else assert.match(text, /at page\.html:4:3/u);
+ }
+ }
+ }
+ } finally {
+ setIconStyle('nerd');
+ if (saved === undefined) delete process.env.NO_COLOR; else process.env.NO_COLOR = saved;
+ w.cleanup();
+ }
+});
+
+test('without the browser preview the Console says why; scripts off says what still appears', async () => {
+ const dir = scratch();
+ try {
+ const file = path.join(dir, 'p.html');
+ fs.writeFileSync(file, 'x
\n');
+ const opened = await openForEdit(file, '/');
+ if (!opened.ok) throw new Error(opened.reason);
+ const state = createDocumentSession(opened.file, 'source');
+ setDocumentMode(state, 'preview');
+ for (const key of decodeKeys('C')) documentSessionKey(state, key, 100, 30);
+ assert.equal(state.editor.dialog, undefined);
+ assert.match(state.note ?? '', /browser preview \(Chromium\)/u);
+ } finally { fs.rmSync(dir, {recursive: true, force: true}); }
+ const w = await workspace();
+ try {
+ w.press('C');
+ assert.match(w.screen(), /Console \(page runtime, scripts off\)/u);
+ assert.match(w.screen().replace(/\s+/gu, ' '), /T in the preview turns scripts on/u);
+ } finally { w.cleanup(); }
+});
+
+test('a multi-line or long message shows its first line and says how to read all of it', async () => {
+ const w = await workspace();
+ try {
+ w.page.emit({type: 'console', entry: info('first line\nsecond line\nthird line')});
+ w.press('C');
+ const screen = w.screen();
+ assert.match(screen, /first line/u);
+ assert.doesNotMatch(screen, /second line/u);
+ assert.match(screen, /Ctrl\+C shows all of it/u);
+ w.press(CTRL_C);
+ assert.match(w.screen(), /second line[\s\S]*third line/u);
+ } finally { w.cleanup(); }
+});
+
+test('review regression: a refresh forgets the old load at once, so its entries are never mapped against the new text', async () => {
+ const w = await workspace();
+ try {
+ w.page.emit({type: 'console', entry: error('old', {target: 'document', label: 'page.html', line: 3, column: 0})});
+ w.state.editor.buffer.insert('new ');
+ await w.view.refresh(w.state.editor.buffer.lines.join('\n'));
+ assert.equal(w.session.console()!.size, 0);
+ } finally { w.cleanup(); }
+});
+
+test('the preview status line counts Console errors and names the key', async () => {
+ const w = await workspace();
+ try {
+ w.page.emit({type: 'console', entry: error('e1')});
+ w.page.emit({type: 'console', entry: warn('w1')});
+ assert.match(w.view.status() ?? '', /Console: 1 error, 1 warning \(C\)/u);
+ } finally { w.cleanup(); }
+});
diff --git a/tests/doctorDocuments.test.ts b/tests/doctorDocuments.test.ts
new file mode 100644
index 00000000..79368ccf
--- /dev/null
+++ b/tests/doctorDocuments.test.ts
@@ -0,0 +1,121 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import {documentDoctorChecks, type DocumentRenderingFacts} from '../src/doctor/documents.js';
+import {probeMermaid, TESTED_MERMAID} from '../src/diagrams/library.js';
+import {findBrowserExecutable, type LocatorHost} from '../src/browser/engine/chrome.js';
+
+/** /doctor's Documents section: what renders documents and diagrams, found without launching or loading anything. */
+
+function scratch(): string {
+ return fs.realpathSync(fs.mkdtempSync(path.join(process.env.TMPDIR ?? os.tmpdir(), 'nmsh-doctor-docs-')));
+}
+
+function fakePackage(root: string, version = '12.2.0', body = 'globalThis.mermaid = {};'): string {
+ const dir = path.join(root, 'mermaid');
+ fs.mkdirSync(path.join(dir, 'dist'), {recursive: true});
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({name: 'mermaid', version}));
+ fs.writeFileSync(path.join(dir, 'dist', 'mermaid.min.js'), body);
+ return dir;
+}
+
+const base: DocumentRenderingFacts = {
+ diagrams: 'manual', browser: 'auto', images: true, running: false,
+ chromium: {state: 'found', executable: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'},
+ mermaid: {ok: true, path: '/opt/homebrew/lib/node_modules/mermaid/dist/mermaid.min.js', version: '12.2.0'},
+};
+const byLabel = (facts: DocumentRenderingFacts) => documentDoctorChecks(facts);
+
+test('probeMermaid finds the same install as the locator but never reads the bundle', async () => {
+ const root = scratch();
+ try {
+ const dir = fakePackage(root, '11.4.0');
+ const probe = await probeMermaid({env: {NMSH_MERMAID: dir}, home: root, execPath: '/nonexistent/bin/node'});
+ assert.deepEqual(probe, {ok: true, path: path.join(dir, 'dist', 'mermaid.min.js'), version: '11.4.0'});
+ assert.ok(!('source' in probe));
+ // Unreadable content does not matter to a probe (nothing is read), but the safety checks still apply.
+ fs.chmodSync(path.join(dir, 'dist', 'mermaid.min.js'), 0o666);
+ const writable = await probeMermaid({env: {NMSH_MERMAID: dir}, home: root, execPath: '/nonexistent/bin/node'});
+ assert.equal(writable.ok, false);
+ assert.match(!writable.ok ? writable.reason : '', /anyone/u);
+ const missing = await probeMermaid({env: {}, home: root, execPath: '/nonexistent/bin/node', candidates: [path.join(root, 'nope')]});
+ assert.equal(missing.ok, false);
+ assert.match(!missing.ok ? missing.reason : '', /not installed/u);
+ const relative = await probeMermaid({env: {NMSH_MERMAID: 'mermaid'}, home: root, execPath: '/nonexistent/bin/node'});
+ assert.match(!relative.ok ? relative.reason : '', /absolute/u);
+ } finally { fs.rmSync(root, {recursive: true, force: true}); }
+});
+
+test('findBrowserExecutable looks at the filesystem only: --version is never run', async () => {
+ let versions = 0;
+ const host = (executables: string[], env: NodeJS.ProcessEnv = {}): LocatorHost => ({
+ platform: 'darwin', env,
+ isExecutable: async file => executables.includes(file),
+ version: async () => { versions += 1; return 'Google Chrome 156.0.1.2'; },
+ });
+ const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
+ assert.deepEqual(await findBrowserExecutable(host([chrome])), {ok: true, executable: chrome});
+ assert.deepEqual(await findBrowserExecutable(host(['/Applications/Chromium.app/Contents/MacOS/Chromium'])), {ok: true, executable: '/Applications/Chromium.app/Contents/MacOS/Chromium'});
+ const none = await findBrowserExecutable(host([]));
+ assert.equal(none.ok, false);
+ const wrong = await findBrowserExecutable(host([chrome], {NMSH_BROWSER: '/nope/chrome'}));
+ assert.match(!wrong.ok ? wrong.reason : '', /NMSH_BROWSER/u);
+ assert.equal(versions, 0);
+});
+
+test('everything present: browser found, Mermaid found, diagrams can be drawn; detection is not called a test', () => {
+ const checks = byLabel(base);
+ assert.ok(checks.every(check => check.section === 'Documents'));
+ const browser = checks.find(check => /Chrom|browser/iu.test(check.label))!;
+ assert.match(browser.label, /found/iu);
+ assert.match(browser.detail ?? '', /version is checked when a browser surface first opens/u);
+ const mermaid = checks.find(check => /^Mermaid \d/u.test(check.label))!;
+ assert.equal(mermaid.state, 'ok');
+ assert.match(mermaid.detail ?? '', /mermaid\.min\.js/u);
+ const drawing = checks.find(check => /diagrams/iu.test(check.label) && check !== mermaid)!;
+ assert.equal(drawing.state, 'ok');
+ assert.match(drawing.label, /On request/u);
+ assert.match(drawing.detail ?? '', /nothing was drawn/iu);
+ for (const check of checks) assert.doesNotMatch(`${check.label} ${check.detail ?? ''}`, /\b(verified|validated|works)\b/iu);
+});
+
+test('a version checked this session is shown; a running browser is said to be running', () => {
+ const checks = byLabel({...base, running: true, chromium: {state: 'checked', availability: {ok: true, executable: '/usr/bin/chromium', version: 'Chromium 156.0.7000.1'}}});
+ const browser = checks[0]!;
+ assert.match(browser.label, /Chromium 156/u);
+ assert.match(browser.detail ?? '', /\/usr\/bin\/chromium/u);
+ assert.match(browser.detail ?? '', /running now/u);
+});
+
+test('each reason a diagram would not be drawn is named, in the order the service applies them', () => {
+ const reason = (facts: Partial) => byLabel({...base, ...facts}).at(-1)!;
+ assert.match(reason({diagrams: 'off'}).label, /off/iu);
+ assert.equal(reason({diagrams: 'off'}).state, 'info');
+ assert.match(reason({images: false}).detail ?? '', /Images are off/u);
+ assert.match(reason({browser: 'off'}).detail ?? '', /Real browser/u);
+ assert.match(reason({chromium: {state: 'missing', reason: 'No Chrome, Chromium or Edge was found.'}}).detail ?? '', /No Chrome/u);
+ assert.match(reason({chromium: {state: 'checked', availability: {ok: false, reason: 'chrome is version 90; NMSh needs 110'}}}).detail ?? '', /version 90/u);
+ assert.match(reason({mermaid: {ok: false, reason: 'Mermaid is not installed. Install it with `npm install -g mermaid`'}}).detail ?? '', /npm install -g mermaid/u);
+ assert.equal(reason({mermaid: {ok: false, reason: 'x'}}).state, 'info');
+ assert.match(reason({diagrams: 'auto'}).label, /Automatic/u);
+});
+
+test('compatibility: other Mermaid majors and unknown versions are flagged; a broken NMSH_MERMAID needs attention', () => {
+ assert.equal(TESTED_MERMAID, '12.2.0');
+ const other = byLabel({...base, mermaid: {ok: true, path: '/x/dist/mermaid.min.js', version: '11.4.0'}}).find(check => /^Mermaid 11/u.test(check.label))!;
+ assert.equal(other.state, 'attention');
+ assert.match(other.detail ?? '', /tested with 12\.2\.0/u);
+ const minor = byLabel({...base, mermaid: {ok: true, path: '/x/dist/mermaid.min.js', version: '12.4.1'}}).find(check => /^Mermaid 12/u.test(check.label))!;
+ assert.equal(minor.state, 'ok');
+ const unknown = byLabel({...base, mermaid: {ok: true, path: '/x/mermaid.min.js', version: 'unknown'}}).find(check => /^Mermaid/u.test(check.label))!;
+ assert.equal(unknown.state, 'attention');
+ const configured = byLabel({...base, mermaid: {ok: false, reason: 'NMSH_MERMAID: /x is not the mermaid package.'}}).find(check => /Mermaid/u.test(check.label))!;
+ assert.equal(configured.state, 'attention');
+});
+
+test('control characters in paths and reasons never reach the doctor rows', () => {
+ const checks = byLabel({...base, chromium: {state: 'found', executable: '/opt/\u001b[31mchrome'}, mermaid: {ok: false, reason: 'NMSH_MERMAID: /x\u0007 is wrong'}});
+ for (const check of checks) assert.doesNotMatch(`${check.label}${check.detail ?? ''}`, /[\u0000-\u001f\u007f-\u009f--]/u);
+});