From 4582df87372ed2ebdc6cf6e373e38f47d8b46bad Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 28 Aug 2026 14:28:27 -0700 Subject: [PATCH 1/3] fix(vscode): let the webview CSP survive a code-split bundle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Dormouse panel rendered as an empty tab. Two regressions, both after v1.1.0 and three days apart, stacked on the same cause: `script-src` was a bare nonce, and a nonce does not reach chunks the entry loads. Vite 8.1.5 -> 8.2.1 (10468f71, a lockfile-only renovate bump) started splitting shared chunks and emitting `` for them. A preload is fetched as a script, so `script-src` gates it, and only the element's own nonce can satisfy it. The blocked preload left an errored entry in the module map that the entry chunk's static import resolved to, so nothing mounted. Behind that, b824e485 made `main.tsx` pass `enableRemoteHost={isVscode}`, so the webview began mounting the lazy `RemotePairingModalHost` at boot — the first `import()` it had ever issued. A nonce is not inherited through the module graph, so that fetch was blocked too, surfacing as a render error naming a chunk that was sitting on disk. Nonce the preload links, and pair the nonce with `strict-dynamic` for the imports. Inline scripts stay blocked either way. Neither regression reached a release: v1.1.0 predates the vite bump and never set `enableRemoteHost`. Standalone is immune — its CSP is `script-src 'self'`, a host-source rather than a nonce. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011imCAAwd1M6nSFhJyNNLB4 --- docs/specs/vscode.md | 16 +++- vscode-ext/src/webview-html.ts | 30 ++++++- vscode-ext/test/vscode-stub.ts | 16 +++- vscode-ext/test/webview-html.test.ts | 114 +++++++++++++++++++++++++++ 4 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 vscode-ext/test/webview-html.test.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 863246cba..e22918292 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -225,7 +225,21 @@ Source of truth: `vscode-ext/src/webview-html.ts` assembles the CSP directives ( That allowlist is still a build-time constant, not a runtime value: `vscode-ext/scripts/esbuild.mjs` substitutes `__DORMOUSE_REMOTE_CONNECT_SRC__` into `dist/extension.js`, defaulting to the SaaS origin (`https://*.dormouse.sh wss://*.dormouse.sh`), and `assertConnectSrcBaked` fails the build if the define did not reach the bundle — a lost define would otherwise surface only as a Host silently using the shipped default. `lib/src/host/remote/connect-src.ts` reads it through `bakedConnectSrc()`, as a `declare const` rather than an import, so the value is a literal in the bundle and nothing at runtime can move it. A selfhoster widens it for their own build with `DORMOUSE_REMOTE_CONNECT_SRC='https://*.ts.net wss://*.ts.net' pnpm dogfood:vscode` — the same variable and the same per-build opt-in as the standalone binary (`docs/specs/server.md` → "Where a Host may reach a relay server"). -`unsafe-inline` for styles is needed because VS Code injects theme CSS variables via inline styles on the body element. Scripts remain nonce-gated, with a fresh per-render nonce of 24 CSPRNG bytes (`node:crypto` `randomBytes`) base64url-encoded to 32 characters — a nonce that is guessable is a nonce that is not there, so `Math.random()` is not acceptable here. The webview HTML is built by Vite from the `lib` package, then at runtime `webview-html.ts` rewrites asset URLs to webview URIs, injects the CSP meta tag, applies nonces to all script tags, and injects initial state via a nonce-gated inline script. +`unsafe-inline` for styles is needed because VS Code injects theme CSS variables via inline styles on the body element. Scripts remain nonce-gated, with a fresh per-render nonce of 24 CSPRNG bytes (`node:crypto` `randomBytes`) base64url-encoded to 32 characters — a nonce that is guessable is a nonce that is not there, so `Math.random()` is not acceptable here. The webview HTML is built by Vite from the `lib` package, then at runtime `webview-html.ts` rewrites asset URLs to webview URIs, injects the CSP meta tag, applies nonces to every tag that loads a script, and injects initial state via a nonce-gated inline script. + +**A nonce alone does not survive code splitting; `'strict-dynamic'` is what carries it.** Vite splits the bundle, and a split bundle loads scripts three ways, each gated by `script-src` and each needing a different thing to pass: + +| How the chunk loads | What satisfies `script-src` | +| --- | --- | +| The entry ` + + + + + +
+ + +`; + +const CSP_SOURCE = 'https://file+.vscode-resource.vscode-cdn.net'; + +const webview = { + cspSource: CSP_SOURCE, + asWebviewUri: (uri: { fsPath: string }) => `${CSP_SOURCE}${uri.fsPath}`, +} as never; + +let mediaPath: string; + +beforeEach(() => { + mediaPath = mkdtempSync(join(tmpdir(), 'dormouse-webview-html-')); + writeFileSync(join(mediaPath, 'index.html'), VITE_INDEX_HTML); +}); + +afterEach(() => { + rmSync(mediaPath, { recursive: true, force: true }); +}); + +/** The single nonce the document was served with, read back off its CSP. */ +function nonceOf(html: string): string { + const match = /script-src 'nonce-([A-Za-z0-9_-]+)'/.exec(html); + if (!match) throw new Error('no script-src nonce in the CSP'); + return match[1]; +} + +describe('getWebviewHtml', () => { + it("pairs the nonce with 'strict-dynamic' so split chunks can load", () => { + const { html } = getWebviewHtml(webview, mediaPath); + // Vite code-splits, and neither a static import of a shared chunk nor a lazy + // `import()` carries the nonce. Without `strict-dynamic` both are blocked: + // the first blanks the panel, the second surfaces as a render error naming a + // chunk that is present on disk. + expect(html).toContain(`script-src 'nonce-${nonceOf(html)}' 'strict-dynamic'`); + // Inline scripts must stay blocked — `strict-dynamic` widens what a trusted + // script may load, not what may be written into the document. + expect(/script-src[^;]*'unsafe-inline'/.test(html)).toBe(false); + }); + + it('nonces every tag that loads a script, links included', () => { + const { html } = getWebviewHtml(webview, mediaPath); + const nonce = nonceOf(html); + + // The regression that shipped a blank panel: `script-src` gates a preload + // too, and `strict-dynamic` does not reach it — a parser-started fetch is + // not a script the nonce vouched for. A blocked modulepreload errors the + // module map entry that the entry chunk's own static import then resolves + // to, so nothing mounts. + for (const tag of html.match(/<(?:script|link)\b[^>]*>/g) ?? []) { + const loadsScript = / { + const { html } = getWebviewHtml(webview, mediaPath); + const stylesheet = /]*rel="stylesheet"[^>]*>/.exec(html)?.[0] ?? ''; + expect(stylesheet).not.toContain('nonce='); + }); + + it('gives each tag exactly one nonce', () => { + const { html } = getWebviewHtml(webview, mediaPath); + for (const tag of html.match(/<(?:script|link)\b[^>]*>/g) ?? []) { + expect((tag.match(/nonce=/g) ?? []).length, `duplicate nonce: ${tag}`).toBeLessThanOrEqual(1); + } + }); + + it('rewrites asset paths onto the webview URI', () => { + const { html } = getWebviewHtml(webview, mediaPath); + expect(html).not.toContain('"./assets/'); + expect(html).toContain(`${CSP_SOURCE}${mediaPath}/assets/index-AAAAAAAA.js`); + expect(html).toContain(`${CSP_SOURCE}${mediaPath}/assets/rolldown-runtime-BBBBBBBB.js`); + }); + + it('mints a fresh nonce and message token per document', () => { + const first = getWebviewHtml(webview, mediaPath); + const second = getWebviewHtml(webview, mediaPath); + expect(nonceOf(first.html)).not.toBe(nonceOf(second.html)); + expect(first.messageToken).not.toBe(second.messageToken); + // The two secrets are deliberately distinct: one authorizes script + // execution, the other authenticates a message sender. + expect(first.messageToken).not.toBe(nonceOf(first.html)); + }); +}); From 97a25c43bfb95db75ca10e1ad73b033fa6d750c8 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 28 Aug 2026 14:51:38 -0700 Subject: [PATCH 2/3] simplify(vscode-csp): let Vite mark the nonce instead of regexes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /simplify findings. The nonce was applied by hand-rolled regexes over Vite's built HTML — the same guessing at bundler output shape that caused the bug. Vite 8.2.1 ships `html.cspNonce`: it walks its own output with a real HTML parser and marks every script/style tag, so `webview-html.ts` now substitutes one placeholder and matches no tags at all. Net deletion. That also closes a gap the regexes could not reach. Vite's runtime preload helper is already in the shipped bundle and looks for a `` before injecting a preload for a lazy chunk; nothing emitted that meta tag, so it found none. `html.cspNonce` emits it. `getWebviewHtml` now throws when the placeholder is absent rather than serving un-nonced scripts against a nonce-gated policy — the same reasoning as `assertConnectSrcBaked`, since that failure looks like a blank panel. Also from the review: the test re-derived the source's own tag-matching rule, so it agreed with it by construction and could not catch that rule being wrong — it now names the tags it expects. Reuse `tempStorageDir`/`removeDir` from test/helpers.ts, drop a dead `toString` from the vscode stub, collapse three tag loops into one, and trim comments the spec now owns. Dropped the "stylesheet link carries no nonce" case: Vite nonces stylesheet links by design, and `style-src` has no nonce to satisfy either way, so it pinned an artifact of the old workaround rather than an invariant. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011imCAAwd1M6nSFhJyNNLB4 --- docs/specs/vscode.md | 15 ++-- vscode-ext/src/csp-nonce-placeholder.ts | 14 ++++ vscode-ext/src/webview-html.ts | 61 +++++++------- vscode-ext/test/vscode-stub.ts | 6 +- vscode-ext/test/webview-html.test.ts | 101 +++++++++++++++--------- vscode-ext/vite.config.ts | 11 +++ 6 files changed, 123 insertions(+), 85 deletions(-) create mode 100644 vscode-ext/src/csp-nonce-placeholder.ts diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index e22918292..bd1de8485 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -227,19 +227,14 @@ That allowlist is still a build-time constant, not a runtime value: `vscode-ext/ `unsafe-inline` for styles is needed because VS Code injects theme CSS variables via inline styles on the body element. Scripts remain nonce-gated, with a fresh per-render nonce of 24 CSPRNG bytes (`node:crypto` `randomBytes`) base64url-encoded to 32 characters — a nonce that is guessable is a nonce that is not there, so `Math.random()` is not acceptable here. The webview HTML is built by Vite from the `lib` package, then at runtime `webview-html.ts` rewrites asset URLs to webview URIs, injects the CSP meta tag, applies nonces to every tag that loads a script, and injects initial state via a nonce-gated inline script. -**A nonce alone does not survive code splitting; `'strict-dynamic'` is what carries it.** Vite splits the bundle, and a split bundle loads scripts three ways, each gated by `script-src` and each needing a different thing to pass: +**A nonce alone does not survive code splitting.** Vite splits the webview bundle, and `script-src` gates each way a chunk loads separately. Two mechanisms cover them, and the split is not negotiable — a nonce is **not** inherited through the module graph, and `'strict-dynamic'` does not vouch for a parser-started fetch: -| How the chunk loads | What satisfies `script-src` | -| --- | --- | -| The entry `\n `, diff --git a/vscode-ext/test/vscode-stub.ts b/vscode-ext/test/vscode-stub.ts index ead3da575..06a12409d 100644 --- a/vscode-ext/test/vscode-stub.ts +++ b/vscode-ext/test/vscode-stub.ts @@ -18,9 +18,9 @@ export const window = { /** * `getWebviewHtml` resolves the media directory through `Uri.file` before - * handing it to `asWebviewUri`. The tests only ever compare the result as a - * string, so a plain `fsPath` carrier is the whole contract. + * handing it to `asWebviewUri`, which is the only thing that reads it back. A + * plain `fsPath` carrier is the whole contract. */ export const Uri = { - file: (fsPath: string) => ({ fsPath, toString: () => `file://${fsPath}` }), + file: (fsPath: string) => ({ fsPath }), }; diff --git a/vscode-ext/test/webview-html.test.ts b/vscode-ext/test/webview-html.test.ts index e9a1faa36..1a9bdef6d 100644 --- a/vscode-ext/test/webview-html.test.ts +++ b/vscode-ext/test/webview-html.test.ts @@ -1,26 +1,29 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +import { writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { CSP_NONCE_PLACEHOLDER } from '../src/csp-nonce-placeholder'; import { getWebviewHtml } from '../src/webview-html'; +import { removeDir, tempStorageDir } from './helpers'; /** - * `getWebviewHtml` post-processes whatever HTML Vite built into `media/`, so - * these tests feed it that file's real shape from a temp directory rather than - * mocking the read. The shape below is Vite 8 output: rolldown splits its shared - * runtime into its own chunk, and the entry both statically imports it *and* - * carries a `` for it. + * `getWebviewHtml` post-processes whatever Vite built into `media/`, so these + * tests feed it that file's real shape from a temp directory rather than mocking + * the read. Below is verbatim Vite 8 output with `html.cspNonce` set: rolldown + * splits its shared runtime into its own chunk and the entry both imports it and + * carries a `` for it, and Vite marks every tag plus + * the `` that its runtime preload helper reads. */ const VITE_INDEX_HTML = ` Dormouse - - - - + + + + +
@@ -35,15 +38,16 @@ const webview = { asWebviewUri: (uri: { fsPath: string }) => `${CSP_SOURCE}${uri.fsPath}`, } as never; +/** Shared across the suite: every test only reads through `getWebviewHtml`. */ let mediaPath: string; -beforeEach(() => { - mediaPath = mkdtempSync(join(tmpdir(), 'dormouse-webview-html-')); - writeFileSync(join(mediaPath, 'index.html'), VITE_INDEX_HTML); +beforeAll(async () => { + mediaPath = await tempStorageDir(); + await writeFile(join(mediaPath, 'index.html'), VITE_INDEX_HTML); }); -afterEach(() => { - rmSync(mediaPath, { recursive: true, force: true }); +afterAll(async () => { + await removeDir(mediaPath); }); /** The single nonce the document was served with, read back off its CSP. */ @@ -56,45 +60,64 @@ function nonceOf(html: string): string { describe('getWebviewHtml', () => { it("pairs the nonce with 'strict-dynamic' so split chunks can load", () => { const { html } = getWebviewHtml(webview, mediaPath); - // Vite code-splits, and neither a static import of a shared chunk nor a lazy - // `import()` carries the nonce. Without `strict-dynamic` both are blocked: - // the first blanks the panel, the second surfaces as a render error naming a - // chunk that is present on disk. + // A lazy `import()` carries no nonce — a nonce is not inherited through the + // module graph — so without `strict-dynamic` it is blocked, surfacing as a + // render error naming a chunk that is present on disk. expect(html).toContain(`script-src 'nonce-${nonceOf(html)}' 'strict-dynamic'`); - // Inline scripts must stay blocked — `strict-dynamic` widens what a trusted - // script may load, not what may be written into the document. + // `strict-dynamic` widens what a trusted script may load, never what may be + // written into the document. expect(/script-src[^;]*'unsafe-inline'/.test(html)).toBe(false); }); - it('nonces every tag that loads a script, links included', () => { + it('carries the real nonce on every tag Vite marked, and leaves no placeholder', () => { const { html } = getWebviewHtml(webview, mediaPath); const nonce = nonceOf(html); - // The regression that shipped a blank panel: `script-src` gates a preload - // too, and `strict-dynamic` does not reach it — a parser-started fetch is - // not a script the nonce vouched for. A blocked modulepreload errors the - // module map entry that the entry chunk's own static import then resolves - // to, so nothing mounts. - for (const tag of html.match(/<(?:script|link)\b[^>]*>/g) ?? []) { - const loadsScript = /]*${marker.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}[^>]*>`).exec(html)?.[0]; + expect(tag, `no tag found for ${marker}`).toBeDefined(); + expect(tag, `un-nonced: ${tag}`).toContain(`nonce="${nonce}"`); } - }); - it('leaves the stylesheet link alone — style-src carries no nonce to satisfy', () => { - const { html } = getWebviewHtml(webview, mediaPath); - const stylesheet = /]*rel="stylesheet"[^>]*>/.exec(html)?.[0] ?? ''; - expect(stylesheet).not.toContain('nonce='); + // The placeholder is a build artifact; letting one reach a browser would + // mean a tag whose nonce matches nothing. + expect(html).not.toContain(CSP_NONCE_PLACEHOLDER); }); it('gives each tag exactly one nonce', () => { const { html } = getWebviewHtml(webview, mediaPath); - for (const tag of html.match(/<(?:script|link)\b[^>]*>/g) ?? []) { + for (const tag of html.match(/<(?:script|link|meta)\b[^>]*>/g) ?? []) { expect((tag.match(/nonce=/g) ?? []).length, `duplicate nonce: ${tag}`).toBeLessThanOrEqual(1); } }); + it('refuses to serve HTML the build never marked', async () => { + // A dropped `html.cspNonce` would otherwise yield a document whose every + // script is un-nonced against a nonce-gated policy — a blank panel with no + // error outside the webview console. + const unmarked = await tempStorageDir(); + try { + await writeFile( + join(unmarked, 'index.html'), + VITE_INDEX_HTML.replaceAll(CSP_NONCE_PLACEHOLDER, ''), + ); + expect(() => getWebviewHtml(webview, unmarked)).toThrow(/cspNonce/); + } finally { + await removeDir(unmarked); + } + }); + it('rewrites asset paths onto the webview URI', () => { const { html } = getWebviewHtml(webview, mediaPath); expect(html).not.toContain('"./assets/'); diff --git a/vscode-ext/vite.config.ts b/vscode-ext/vite.config.ts index 82d0e196f..391d9a0b4 100644 --- a/vscode-ext/vite.config.ts +++ b/vscode-ext/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import path from "path"; +import { CSP_NONCE_PLACEHOLDER } from "./src/csp-nonce-placeholder"; /** * Builds the lib frontend for embedding in the VSCode extension webview. @@ -9,6 +10,16 @@ import path from "path"; */ export default defineConfig({ plugins: [react(), tailwindcss()], + html: { + // Vite stamps this placeholder onto every tag it emits that loads a script + // or style, and onto a `` that its own runtime + // preload helper reads. `webview-html.ts` swaps it for a fresh per-render + // nonce when it serves the document — the placeholder must never reach a + // browser. Letting Vite mark the tags keeps nonce coverage tied to the + // bundler's own output shape instead of to regexes that have to guess it + // (docs/specs/vscode.md → "CSP policy"). + cspNonce: CSP_NONCE_PLACEHOLDER, + }, resolve: { dedupe: ["react", "react-dom"], }, From 34de27e9ff69a4b272586384a0373613945f7250 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 28 Aug 2026 15:13:41 -0700 Subject: [PATCH 3/3] docs(vscode): fold the new test file into the spec's testing section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR review: the section still said "Six files" and described the stub as providing only the output channel, both of which this branch made untrue. `webview-html.test.ts` is a seventh, and it is the one file there whose subject is not real I/O — the section's stated organizing principle — so it gets a line saying why it lives there anyway. `vitest.config.mts` carried the same stale claim about the stub in its own header comment. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_011imCAAwd1M6nSFhJyNNLB4 --- docs/specs/vscode.md | 7 ++++--- vscode-ext/vitest.config.mts | 3 ++- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index bd1de8485..f99cca0b9 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -382,20 +382,21 @@ Source of truth: `vscode-ext/src/peer-link.ts` for the sockets and arbitration; ### Testing the extension host -`vscode-ext` runs vitest (`pnpm --filter dormouse test`, which typechecks first). The `vscode` module only exists inside a running editor, so `vitest.config.mts` aliases it to a stub providing just the output channel `log.ts` opens — most modules worth testing import `vscode` as `import type`, which erases. +`vscode-ext` runs vitest (`pnpm --filter dormouse test`, which typechecks first). The `vscode` module only exists inside a running editor, so `vitest.config.mts` aliases it to a stub. Most modules worth testing import `vscode` as `import type`, which erases; the stub covers what is left, which is the output channel `log.ts` opens and the `Uri.file` that `webview-html.ts` calls at runtime. Nothing else is stubbed on purpose — a test that reaches further should fail loudly rather than pass against a fake that quietly does nothing. -The tests that matter here are the ones that need real I/O, since the pure halves already live in `lib`. Six files, all under `vscode-ext/test/`: +Mostly these are the tests that need real I/O, since the pure halves already live in `lib`. Seven files, all under `vscode-ext/test/`: - **`peer-link.test.ts`** stands up a broker and a client over a real socket: bind-as-lease (first binder wins, idempotent re-announce, taking over a socket whose broker died without unlinking, re-binding when the reclaimed socket is unlinked out from under it, a reclaimed bind answering no role until it is verified, two windows racing for one corpse settling into a broker and a client, handing the Host to a surviving window when the broker dies, an accept-time server error logged rather than thrown, and the permanent stand-down when the shared token can be neither read nor created), the handshake (the three frames over a raw socket with the token never on the wire, a wrong-token proof dropped, a proof replayed from another connection rejected, and a squatter that took the path being served nothing), the socket directory being kept private, cross-window directory and surface ops, provider-local handles for colliding PTY ids, PTY routing and streaming with two viewers, route survival across unsubscribe and re-attach, what a disconnect does to in-flight terminals, forwarded commands, and requests still outstanding against it, and that a client whose socket died reports *unsettled* before its `close` lands, so it agrees with `forwardCommand`. - **`peer-link-protocol.test.ts`** is that link's socket-free half: frame shapes and framing (splits, oversized frames, malformed lines), the PTY routing table, the handshake proof primitives, and the guard that keeps `PEER_REPLY_BUDGET_MS` strictly larger than the `ASK_BUDGET_MS` fan-out it contains. - **`remote-host.test.ts`** covers the VS Code half of the service: `VsCodeHostStateStore` round-tripping through the stubbed `SecretStorage`/`globalState` (including reading what the webview-resident Host left behind, re-reading after a cross-window change, and serializing ACL snapshots), the enroll bootstrap, commands held while the contention settles and refused at once when it can never settle, the read-only commands answered exactly as a real un-enrolled `RemoteHostService` answers them, contending when another window enrolls, command forwarding and answering, the status event a joining window is greeted with, the relay-socket factory's `ws` fallback, and the provider's streaming, duplicate restored-id owner binding, asking, and directory invalidation. - **`message-router.test.ts`** covers the in-window fan-out with the link and the service stubbed out: one answer counted per webview however many it sends, and a late answer for a settled request marking the directory stale instead of being dropped. - **`processed-pty-streams.test.ts`** covers the window's one keyed registry: exactly one listener pair however many attachments exist, none at all with none, per-PTY fan-out, and teardown on exit. +- **`webview-html.test.ts`** is the exception to the real-I/O rule above: its subject is a pure string transform, and it is here because the thing it guards cannot be checked anywhere else. It feeds `getWebviewHtml` a fixture of real Vite output from a temp directory and pins the CSP contract — `'strict-dynamic'` present, `script-src` never gaining `'unsafe-inline'`, each named script-loading tag carrying the real nonce, no placeholder surviving, no tag carrying two, and an unmarked document refused. See "CSP policy": every failure in that contract is invisible outside the webview console, so a unit test is the only place it can go red early. - **`helpers.ts`** holds what the socket suites need — a throwaway `globalStorageUri`, the mirrored socket-path derivation, a poll-with-deadline, `freshModule`, and `fakeWindow`, one window as the link sees it. Separate module instances come from `vi.resetModules()` plus a dynamic import, which is what makes one process able to play two windows. -Not covered: anything needing the real editor — command registration, webview hosting, the theme observer. Those would need `@vscode/test-electron`. +Not covered: anything needing the real editor — command registration, webview *hosting* (as opposed to the document `webview-html.ts` builds, covered above), the theme observer. Those would need `@vscode/test-electron`. ### Build and development diff --git a/vscode-ext/vitest.config.mts b/vscode-ext/vitest.config.mts index 0bcb7ab72..3ceb6d521 100644 --- a/vscode-ext/vitest.config.mts +++ b/vscode-ext/vitest.config.mts @@ -3,7 +3,8 @@ import { defineConfig } from 'vitest/config'; /** * Unit tests for the extension host. The `vscode` module only exists inside a * running VS Code, so it is aliased to a stub; everything under test either - * imports it as a type (erased) or goes through `log.ts`. + * imports it as a type (erased) or touches the small surface the stub carries + * (the `log.ts` output channel, and `Uri.file` for `webview-html.ts`). * * Modules that genuinely need the real editor — commands, webview hosting — are * not covered here and would need `@vscode/test-electron`.