diff --git a/tests/background/sw-bundle-import.test.ts b/tests/background/sw-bundle-import.test.ts index e338f99..422e4d0 100644 --- a/tests/background/sw-bundle-import.test.ts +++ b/tests/background/sw-bundle-import.test.ts @@ -15,10 +15,39 @@ // before any handler can register, and the operator's chrome://extensions // "service worker" link goes inaccessible. // -// This test exercises the actual built artifact under SW-simulated globals -// (Buffer/process/document/window stripped). Any throw at top-level module -// evaluation surfaces as a clean test failure with the exact stack the -// operator would see in Chrome. +// The gate ships in TWO layers: +// +// Layer 1 ("built SW chunk imports without throwing...") — verifies the +// BUNDLED artifact's module-init reaches completion under SW-simulated +// globals (Buffer/process/window/document all stripped). This is the +// gate that catches Vite/Rollup CJS-interop bugs like the `Pc={}`- +// placeholder tree-shake from 01-08. Buffer is stripped here because +// `vite-plugin-node-polyfills` rewrites bundled `Buffer` references into +// imports of the polyfill chunk's exported `Buffer` (no dependency on +// `globalThis.Buffer`); if a future bundler change re-introduces a +// `globalThis.Buffer` dependency, this strip catches it. +// +// Layer 2 ("source remuxSegments invocation does not throw an SW- +// incompatible error...") — verifies that the SOURCE `remuxSegments` +// code path is REACHABLE under SW-simulated globals. Layer 1 only +// proves module-init; if ts-ebml or webm-muxer reaches a code path +// only when `remuxSegments` is INVOKED (which only happens at +// archive-save time, deep inside `chrome.runtime.onMessage` for +// `SAVE_ARCHIVE`), Layer 1 will pass while the real SW could still +// crash mid-archive. Layer 2 catches that. +// +// IMPORTANT — Layer 2 polyfill semantics. Layer 2 imports SOURCE +// webm-remux.ts directly via Node's TS transform; the polyfill is a +// BUNDLER-LEVEL rewrite (Buffer → imported polyfill chunk) that does +// NOT apply when source is loaded outside Vite. To faithfully model +// what the real SW sees at runtime (the bundle with polyfilled Buffer), +// Layer 2 leaves `Buffer` AVAILABLE in the child env — exactly mirroring +// what the polyfilled bundle provides. Stripping Buffer in Layer 2 +// would simulate a more-restrictive runtime than what the polyfilled +// bundle actually faces, producing false-positive failures. `process`, +// `window`, `document` remain stripped because the polyfill is +// configured with `globals.process: false` and the SW genuinely has no +// DOM. // // Implementation note: the strip+import happens in a SPAWNED Node child // process, not in-process. Vitest's own RPC layer references both `Buffer` @@ -39,6 +68,24 @@ // chrome.* behavior is the responsibility of the offscreen-handshake // and end-to-end smoke tests, not this gate. // +// Layer 2 source-import: Node 24 ships native TypeScript transform via +// `--experimental-transform-types` (no `tsx` / `ts-node` install needed). +// `webm-remux.ts` imports `'../shared/logger'` without a `.ts` extension — +// vite/rollup add the extension at bundle time, but the bare Node ESM +// resolver does NOT. To bridge that, the child registers a tiny resolution +// hook that retries failed `./...`/`../...` lookups with a `.ts` suffix. +// The hook is inlined as a `data:` URL so the test stays self-contained. +// +// Layer 2 invocation: we pass `remuxSegments` a single synthetic Blob +// carrying a 4-byte EBML magic + minimal "DocType=webm" header — enough +// to push the call past entry-point validation and into ts-ebml's +// decoder. Domain errors (invalid-EBML, missing-Tracks, etc.) are +// ACCEPTABLE outcomes; they prove the runtime path is structurally +// reachable under SW-simulated globals. Only `process` / CSP-eval errors +// (and a `Buffer` ReferenceError, which would mean the polyfill itself +// regressed) indicate genuine SW-incompat that would crash the SW mid- +// archive in Chrome. The classifier in the child encodes that policy. +// // Pre-flight contract: callers must `npm run build` first. The test fails // fast with a clear "run npm run build" message if `dist/` is missing. // @@ -54,6 +101,12 @@ // https://nodejs.org/api/child_process.html#child_processexecfilefile-args-options-callback // Reference for Proxy traps: // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy +// Reference for Node 24 native TS transform: +// https://nodejs.org/api/typescript.html#type-stripping +// Reference for Node ESM resolution hooks (`register`): +// https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options +// Reference for vite-plugin-node-polyfills (Buffer rewrite policy): +// https://github.com/davidmyersdev/vite-plugin-node-polyfills import { execFile } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; @@ -65,35 +118,89 @@ import { describe, expect, it } from 'vitest'; const execFileAsync = promisify(execFile); -// Globals stripped to simulate the MV3 Service Worker runtime. Service -// Workers expose ServiceWorkerGlobalScope which has NO `window`, NO -// `document`, NO `Buffer`, and NO `process`. They DO have standard -// browser-ish globals (fetch, Blob, Crypto, etc.) which Node provides -// natively in modern versions, so we don't strip those. -const SW_FORBIDDEN_GLOBALS: ReadonlyArray = [ +// Layer 1 strip list — applied to the spawned child BEFORE it imports the +// BUILT SW chunk. Service Workers expose ServiceWorkerGlobalScope which +// has NO `window`, NO `document`, NO `Buffer`, and NO `process`. They DO +// have standard browser-ish globals (fetch, Blob, Crypto, etc.) which +// Node provides natively in modern versions, so we don't strip those. +// +// `Buffer` is stripped here EVEN THOUGH `vite-plugin-node-polyfills` +// resolves Buffer at bundle-time: the bundler rewrites every `Buffer` +// reference into an import of the polyfill chunk's exported `Buffer`, +// so the bundle itself never reads `globalThis.Buffer`. Stripping Buffer +// from the child's globalThis is therefore an additional contract: "the +// bundle must not depend on globalThis.Buffer." A regression where the +// polyfill plugin gets removed or misconfigured would re-introduce a +// raw `Buffer.alloc(...)` somewhere in the bundle, and Layer 1 would +// catch it. +const SW_BUNDLE_STRIP_GLOBALS: ReadonlyArray = [ 'window', 'document', 'Buffer', 'process', ]; +// Layer 2 strip list — applied to the spawned child BEFORE it dynamically +// imports the SOURCE `src/background/webm-remux.ts`. The polyfill is a +// BUNDLER-LEVEL rewrite that DOES NOT apply when ts-ebml's source is +// loaded outside Vite (via Node's TS transform). So in Layer 2, `Buffer` +// is left available in the child env — exactly mirroring what the +// polyfilled BUNDLE provides at SW runtime. Stripping it here would +// simulate a more-restrictive environment than the polyfilled bundle +// actually faces in Chrome, producing false-positive failures. +// +// `process`, `window`, `document` remain stripped: the polyfill is +// configured with `globals.process: false` (not provided), and the SW +// genuinely has no DOM regardless of polyfills. +const SW_SOURCE_STRIP_GLOBALS: ReadonlyArray = [ + 'window', + 'document', + 'process', +]; + // Sentinel strings emitted by the child process; checked by the parent // to distinguish success from failure without resorting to exit codes // (the child may exit 0 on a caught throw). const CHILD_OK_SENTINEL = '__SW_BUNDLE_IMPORT_OK__'; const CHILD_FAIL_SENTINEL = '__SW_BUNDLE_IMPORT_FAILED__'; +// Layer 2 sentinels. The runtime invocation distinguishes three outcomes: +// success (returned a Blob), domain-error (threw but the error is the +// expected shape of a parse failure on our synthetic input — acceptable), +// and SW-incompat (threw a process/CSP error — RED; or a Buffer +// ReferenceError, which would mean the polyfill itself regressed). +const CHILD_REMUX_OK_SENTINEL = '__SW_REMUX_OK__'; +const CHILD_REMUX_DOMAIN_ERROR_SENTINEL = '__SW_REMUX_DOMAIN_ERROR__'; +const CHILD_REMUX_SW_INCOMPAT_SENTINEL = '__SW_REMUX_SW_INCOMPAT__'; + // Cap how long the child has to import. Real SW init takes < 1 s; if the // import takes longer than this, treat it as a hang (functionally the // same as a top-level throw from the operator's perspective). const CHILD_TIMEOUT_MS = 10_000; +// Source path for Layer 2. Absolute to avoid `process.cwd()` quirks inside +// the spawned child (whose CWD may diverge from the test runner's). +const WEBM_REMUX_SOURCE_PATH = resolvePath( + process.cwd(), + 'src/background/webm-remux.ts', +); + interface ChildImportResult { readonly ok: boolean; readonly errorMessage: string; readonly errorStackFirstLine: string; } +/** Layer 2 outcome categories — see file header for semantics. */ +type RemuxOutcome = 'ok' | 'domain_error' | 'sw_incompat'; + +interface ChildRemuxResult { + readonly outcome: RemuxOutcome; + readonly errorName: string; + readonly errorMessage: string; + readonly errorStack: string; +} + /** * Resolve the file:// URL of the built SW chunk by parsing the * `dist/service-worker-loader.js` shim that crxjs emits. The shim is a @@ -186,7 +293,7 @@ function buildChromeMockSource(): string { * @returns ESM source the child runs under `node --input-type=module`. */ function buildChildSource(chunkUrl: string): string { - const stripLines = SW_FORBIDDEN_GLOBALS.map( + const stripLines = SW_BUNDLE_STRIP_GLOBALS.map( (key) => `delete globalThis['${key}'];`, ).join(' '); const chromeMockLine = buildChromeMockSource(); @@ -206,6 +313,160 @@ function buildChildSource(chunkUrl: string): string { ].join(' '); } +/** + * Build the inline ESM source for Layer 2: dynamic-import the SOURCE + * `webm-remux.ts` under stripped globals and invoke `remuxSegments` + * against a synthetic single-segment input. The child registers a + * resolution hook so that `import '../shared/logger'` (no `.ts` + * extension) resolves via the `.ts` source, mimicking what + * vite/rollup does at bundle time. + * + * The synthetic segment is the minimum EBML doctype header + * (`1A 45 DF A3` magic + `42 82 84 'webm'` DocType element). ts-ebml + * may accept this and emit zero frames, or throw on missing + * Segment/Cluster — both are domain-correct outcomes. Only `process`, + * `Buffer` (which would indicate polyfill regression), or eval-related + * errors prove SW-incompat. + * + * @param remuxSourceUrl - file:// URL of `src/background/webm-remux.ts`. + * @returns ESM source the child runs under `node --experimental-transform-types`. + */ +function buildRemuxRuntimeChildSource(remuxSourceUrl: string): string { + // Loader hook: when a relative specifier (./ or ../) has NO extension, + // try the default resolver first; if it returns ERR_MODULE_NOT_FOUND, + // retry with `.ts` appended. This mirrors vite/rollup's extension + // resolution policy for the project's TS source tree. + const loaderSource = [ + `export async function resolve(specifier, context, next) {`, + ` const isRelative = specifier.startsWith('./') || specifier.startsWith('../');`, + ` const hasKnownExt = specifier.endsWith('.ts') || specifier.endsWith('.js')`, + ` || specifier.endsWith('.mjs') || specifier.endsWith('.cjs')`, + ` || specifier.endsWith('.json');`, + ` if (isRelative && !hasKnownExt) {`, + ` try { return await next(specifier, context); }`, + ` catch (err) {`, + ` if (err && err.code === 'ERR_MODULE_NOT_FOUND') {`, + ` return await next(specifier + '.ts', context);`, + ` }`, + ` throw err;`, + ` }`, + ` }`, + ` return next(specifier, context);`, + `}`, + ].join(' '); + + // Encode the loader as a base64 data: URL so the child does not need any + // on-disk loader file. Buffer is available HERE (parent context) because + // we only call this helper before the strip happens in the child. + const loaderDataUrl = + 'data:text/javascript;base64,' + + Buffer.from(loaderSource, 'utf8').toString('base64'); + + const chromeMockLine = buildChromeMockSource(); + const stripLines = SW_SOURCE_STRIP_GLOBALS.map( + (key) => `delete globalThis['${key}'];`, + ).join(' '); + + // Classifier for the runtime outcome. The "SW-incompat" set is precisely + // the failure modes that would crash the real SW in Chrome at + // archive-save time, GIVEN the current bundler config (polyfilled Buffer): + // - ReferenceError: Buffer is not defined (would mean the polyfill + // itself has regressed; we still flag this as SW-incompat because + // it would crash the real SW if the polyfill were removed) + // - ReferenceError: process is not defined (polyfill is configured + // `globals.process: false`, so process truly is missing at SW + // runtime) + // - EvalError / CSP errors (would-be `new Function(...)` in encoder + // blocked by MV3 SW CSP) + // - SyntaxError from eval (same CSP class) + // Everything else (parse failures, missing Tracks, etc.) is a domain + // error — the runtime path is structurally reachable, the SW would + // surface a clean error to the operator instead of crashing the worker. + // + // NOTE: Layer 2 leaves `Buffer` AVAILABLE in the child env (see + // SW_SOURCE_STRIP_GLOBALS), mirroring the polyfilled bundle. A + // `Buffer is not defined` here would only happen if the child Node + // environment itself lost Buffer (essentially impossible) or if a + // future test refactor accidentally re-stripped it — in both cases + // we want to surface that as SW-incompat-class noise rather than + // mask it as a domain error. + const classifyExprParts = [ + `(err) => {`, + ` const name = err && err.name ? String(err.name) : '';`, + ` const msg = err && err.message ? String(err.message) : String(err);`, + ` if (name === 'ReferenceError' && /\\b(Buffer|process)\\b/.test(msg)) return 'sw_incompat';`, + ` if (name === 'EvalError') return 'sw_incompat';`, + ` if (/Refused to evaluate|unsafe-eval/.test(msg)) return 'sw_incompat';`, + ` return 'domain_error';`, + `}`, + ]; + const classifyExpr = classifyExprParts.join(' '); + + return [ + // STEP 1: register the resolution hook FIRST, so subsequent imports + // (including the dynamic import of webm-remux.ts) go through it. + `import { register } from 'node:module';`, + `import { pathToFileURL } from 'node:url';`, + `register(${JSON.stringify(loaderDataUrl)}, pathToFileURL('./'));`, + + // STEP 2: strip SW-forbidden globals. Must happen AFTER register() + // because register() touches `process` internally. Note that + // SW_SOURCE_STRIP_GLOBALS does NOT include 'Buffer' — see classifier + // comment above for rationale. + stripLines, + + // STEP 3: install chrome.* mock. webm-remux.ts itself does not touch + // chrome.*, but its `Logger` import chain might transitively, so we + // keep symmetry with Layer 1. + chromeMockLine, + + // STEP 4: import and invoke. Wrap everything in try/catch and emit + // sentinels via console.log. + `const classify = ${classifyExpr};`, + `try {`, + ` const mod = await import(${JSON.stringify(remuxSourceUrl)});`, + ` if (typeof mod.remuxSegments !== 'function') {`, + ` console.log(${JSON.stringify(CHILD_REMUX_SW_INCOMPAT_SENTINEL)});`, + ` console.log('NAME:Error');`, + ` console.log('MSG:remuxSegments export missing from source module');`, + ` console.log('STK:(no stack)');`, + ` } else {`, + // Synthetic single-segment payload: EBML magic + DocType=webm. + ` const segBytes = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3, 0x42, 0x82, 0x84, 0x77, 0x65, 0x62, 0x6d]);`, + ` const segBlob = new Blob([segBytes], { type: 'video/webm' });`, + ` const segments = [{ data: segBlob, timestamp: 1 }];`, + ` try {`, + ` const out = await mod.remuxSegments(segments);`, + ` console.log(${JSON.stringify(CHILD_REMUX_OK_SENTINEL)});`, + ` console.log('SIZE:' + String(out && typeof out.size === 'number' ? out.size : -1));`, + ` } catch (innerErr) {`, + ` const outcome = classify(innerErr);`, + ` const sentinel = outcome === 'sw_incompat'`, + ` ? ${JSON.stringify(CHILD_REMUX_SW_INCOMPAT_SENTINEL)}`, + ` : ${JSON.stringify(CHILD_REMUX_DOMAIN_ERROR_SENTINEL)};`, + ` console.log(sentinel);`, + ` console.log('NAME:' + (innerErr && innerErr.name ? String(innerErr.name) : 'Error'));`, + ` console.log('MSG:' + (innerErr && innerErr.message ? String(innerErr.message) : String(innerErr)));`, + ` console.log('STK:' + (innerErr && innerErr.stack ? String(innerErr.stack).split('\\n').slice(0, 5).join(' | ') : '(no stack)'));`, + ` }`, + ` }`, + `} catch (importErr) {`, + // An error here means the SOURCE module failed to LOAD under stripped + // globals — classify it the same way (process/CSP class at module- + // init level in webm-remux.ts's transitive deps would be a real + // SW-incompat). + ` const outcome = classify(importErr);`, + ` const sentinel = outcome === 'sw_incompat'`, + ` ? ${JSON.stringify(CHILD_REMUX_SW_INCOMPAT_SENTINEL)}`, + ` : ${JSON.stringify(CHILD_REMUX_DOMAIN_ERROR_SENTINEL)};`, + ` console.log(sentinel);`, + ` console.log('NAME:' + (importErr && importErr.name ? String(importErr.name) : 'Error'));`, + ` console.log('MSG:' + (importErr && importErr.message ? String(importErr.message) : String(importErr)));`, + ` console.log('STK:' + (importErr && importErr.stack ? String(importErr.stack).split('\\n').slice(0, 5).join(' | ') : '(no stack)'));`, + `}`, + ].join(' '); +} + /** * Spawn a Node child process that imports the SW chunk under stripped * globals and returns the result. Resolves rather than rejects on import @@ -245,10 +506,81 @@ async function runSwBundleImportInChild(chunkUrl: string): Promise { + const source = buildRemuxRuntimeChildSource(remuxSourceUrl); + const { stdout } = await execFileAsync( + process.execPath, + // `--experimental-transform-types` enables Node 24's native TS support + // (strip types + handle enums/namespaces + auto-resolve type-only + // imports). No tsx/ts-node install required. + ['--experimental-transform-types', '--input-type=module-typescript', '-e', source], + { + timeout: CHILD_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + env: { + ...process.env, + // Suppress the ExperimentalWarning noise on stderr; the sentinel + // protocol only inspects stdout, but a clean stderr keeps the + // vitest output readable when this test fails. + NODE_NO_WARNINGS: '1', + }, + }, + ); + + const okHit = stdout.includes(CHILD_REMUX_OK_SENTINEL); + const domainHit = stdout.includes(CHILD_REMUX_DOMAIN_ERROR_SENTINEL); + const swIncompatHit = stdout.includes(CHILD_REMUX_SW_INCOMPAT_SENTINEL); + + // Exactly one outcome sentinel should appear. SW-incompat takes + // precedence in the (impossible) event of multiple — it's the failure + // mode we want to surface. + if (swIncompatHit) { + const nameMatch = stdout.match(/^NAME:(.*)$/m); + const msgMatch = stdout.match(/^MSG:(.*)$/m); + const stkMatch = stdout.match(/^STK:(.*)$/m); + return { + outcome: 'sw_incompat', + errorName: nameMatch?.[1] ?? '(no name)', + errorMessage: msgMatch?.[1] ?? '(no message)', + errorStack: stkMatch?.[1] ?? '(no stack)', + }; + } + + if (domainHit) { + const nameMatch = stdout.match(/^NAME:(.*)$/m); + const msgMatch = stdout.match(/^MSG:(.*)$/m); + const stkMatch = stdout.match(/^STK:(.*)$/m); + return { + outcome: 'domain_error', + errorName: nameMatch?.[1] ?? '(no name)', + errorMessage: msgMatch?.[1] ?? '(no message)', + errorStack: stkMatch?.[1] ?? '(no stack)', + }; + } + + if (okHit) { + return { outcome: 'ok', errorName: '', errorMessage: '', errorStack: '' }; + } + + throw new Error( + `Child produced no outcome sentinel. stdout was: ${JSON.stringify(stdout)}`, + ); +} + describe('SW bundle loadability (Tier-1 gate — closes the 01-08 orchestrator gap)', () => { // Resolved at module-level (BEFORE the spawn) so `process.cwd()` and // `process.execPath` are still available. const swChunkUrl = resolveBuiltSwChunkUrl(); + const remuxSourceUrl = pathToFileURL(WEBM_REMUX_SOURCE_PATH).href; it('built SW chunk imports without throwing under SW-simulated globals', async () => { const result = await runSwBundleImportInChild(swChunkUrl); @@ -258,7 +590,7 @@ describe('SW bundle loadability (Tier-1 gate — closes the 01-08 orchestrator g result.ok ? 'unreachable' : `Built SW bundle throws at top-level module init under ` + - `SW-simulated globals (${SW_FORBIDDEN_GLOBALS.join(', ')} ` + + `SW-simulated globals (${SW_BUNDLE_STRIP_GLOBALS.join(', ')} ` + `stripped). This is exactly what kills the SW in Chrome and ` + `makes chrome://extensions "service worker" inaccessible.\n\n` + `First line of stack: ${result.errorStackFirstLine}\n` + @@ -266,4 +598,35 @@ describe('SW bundle loadability (Tier-1 gate — closes the 01-08 orchestrator g `Bundle URL: ${swChunkUrl}`, ).toBe(true); }); + + it('source remuxSegments invocation does not throw an SW-incompatible error', async () => { + if (!existsSync(WEBM_REMUX_SOURCE_PATH)) { + throw new Error( + `webm-remux source not found at ${WEBM_REMUX_SOURCE_PATH}. ` + + `The Tier-1 gate's runtime layer requires the SOURCE file ` + + `(not just the built bundle).`, + ); + } + + const result = await runRemuxRuntimeInChild(remuxSourceUrl); + + // Domain errors (invalid-EBML parse, missing-Tracks, etc.) on our + // intentionally-synthetic input are ACCEPTABLE — they prove the + // runtime path is structurally reachable. Success is also fine. + // Only `sw_incompat` (process/CSP/eval — or a Buffer error, which + // would mean the polyfill itself regressed) is a real failure. + expect( + result.outcome !== 'sw_incompat', + result.outcome === 'sw_incompat' + ? `remuxSegments throws an SW-INCOMPATIBLE error when invoked ` + + `under SW-simulated globals. This means the SW would crash ` + + `mid-archive-save in real Chrome — exactly the kind of bug ` + + `Layer 1 (module-init only) cannot catch.\n\n` + + `Error name: ${result.errorName}\n` + + `Error message: ${result.errorMessage}\n` + + `Stack (first 5 frames, ' | '-separated):\n ${result.errorStack}\n\n` + + `Source URL: ${remuxSourceUrl}` + : 'unreachable', + ).toBe(true); + }); });