// tests/background/sw-bundle-import.test.ts // // Tier-1 SW-bundle-loadability gate (Debug session 01-08-sw-incompatibility). // // This test closes the orchestrator-side gap that produced the 01-08 init // crash: prior to this gate, the only SW-compat assertion was // `tests/background/webm-remux-deps.test.ts`, which loaded the SOURCE // packages (`ts-ebml`, `webm-muxer`) under default Node globals. That test // passed because the raw packages import fine in Node — but it never // loaded the BUNDLED output emitted by `vite build`, and Vite's CJS-interop // pipeline tree-shook the transitive `ebml` package out of the bundle while // leaving a dangling destructure reference (`{tools:f}=Pc` against an empty // placeholder `Pc`). The result: SW dies at top-level module init with // `TypeError: Cannot read properties of undefined (reading 'readVint')` // 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. // // Implementation note: the strip+import happens in a SPAWNED Node child // process, not in-process. Vitest's own RPC layer references both `Buffer` // (`node_modules/vitest/dist/chunks/rpc.*.js`) and `process.nextTick`, so // stripping those on the test runner's globalThis crashes vitest itself. // A child process gives us a fresh V8 isolate where we can strip cleanly. // // chrome.* mock: the SW bundle's top-level module init calls // `chrome.runtime.onMessage.addListener(...)`, `chrome.action.onClicked // .addListener(...)`, etc. In the real Service Worker runtime, Chrome // provides the `chrome.*` global. In our Node-simulated isolate, we must // provide a no-op stub or those calls will throw `ReferenceError: chrome // is not defined` — which is a TEST-ENVIRONMENT incompleteness, NOT a // bundle bug. The mock is a recursive Proxy that returns callable no-ops // for any `chrome..(...)` chain. It does NOT exercise // any chrome.* behavior — it only proves that the bundle's top-level // module evaluation completes without throwing. Verifying actual // chrome.* behavior is the responsibility of the offscreen-handshake // and end-to-end smoke tests, not this gate. // // 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. // // Per `feedback-pre-checkpoint-bundle-gates.md` (auto-loaded memory): any // future plan executor whose work surfaces a SW must run this test before // any operator-empirical checkpoint. // // Reference for SW-restricted globals: // https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope // Reference for Node ESM dynamic-import accepting file:// URLs: // https://nodejs.org/api/esm.html#import-expressions // Reference for `child_process.execFile`: // 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 import { execFile } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; import { resolve as resolvePath } from 'node:path'; import { pathToFileURL } from 'node:url'; import { promisify } from 'node:util'; 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 = [ 'window', 'document', 'Buffer', '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__'; // 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; interface ChildImportResult { readonly ok: boolean; readonly errorMessage: string; readonly errorStackFirstLine: 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 * one-liner of the form `import './assets/index.ts-.js';` whose hash * changes per build; parsing it avoids hard-coding a content hash that * would break on every rebuild. * * @returns Absolute file:// URL of the SW chunk, ready for dynamic import. * @throws If `dist/` is missing or the loader shim cannot be parsed. */ function resolveBuiltSwChunkUrl(): string { const distDir = resolvePath(process.cwd(), 'dist'); const loaderPath = resolvePath(distDir, 'service-worker-loader.js'); if (!existsSync(loaderPath)) { throw new Error( `dist/service-worker-loader.js not found at ${loaderPath}. ` + `Run \`npm run build\` before running this test.`, ); } const loaderSource = readFileSync(loaderPath, 'utf8'); // crxjs emits exactly: `import './assets/index.ts-.js';\n` const importMatch = loaderSource.match(/^\s*import\s+['"](.+?)['"]\s*;?\s*$/m); if (importMatch === null) { throw new Error( `Could not parse SW import path from service-worker-loader.js. ` + `Loader content was: ${JSON.stringify(loaderSource)}`, ); } const chunkRelativePath = importMatch[1]; const chunkAbsolutePath = resolvePath(distDir, chunkRelativePath); return pathToFileURL(chunkAbsolutePath).href; } /** * Build the source for the recursive `chrome.*` Proxy mock the child * installs on its own `globalThis` before importing the SW bundle. * * The mock proves a minimal contract: any `chrome..(...)` chain * evaluated at module-init time returns a callable no-op (or no-op object * with `addListener`/`removeListener` shims) rather than throwing * `ReferenceError: chrome is not defined` or `TypeError: ... is not a * function`. It does NOT exercise any chrome.* behavior — this is purely * a gate that the bundle's top-level evaluation reaches completion. * * Kept as a string-emitting helper so the mock is in scope inside the * spawned child's ESM body, where it must execute BEFORE the dynamic * import of the SW chunk. * * @returns A line of ESM source that installs `globalThis.chrome` as a * recursive Proxy returning no-op callables. */ function buildChromeMockSource(): string { // The Proxy's `get` trap returns another Proxy whose target is a // no-op function, so the returned value is both callable (`chrome.api()`) // and indexable (`chrome.api.subprop`). The inner `get` shims well-known // event-listener methods to silent no-ops; everything else recurses. // // Symbol.toPrimitive is short-circuited to `undefined` so the JS engine // doesn't try to coerce the Proxy itself when, e.g., string-concatenating // a chrome.* reference — it would throw "Cannot convert object to // primitive value" without this guard. return [ `const chromeNoop = () => undefined;`, `const makeProxy = () => new Proxy(chromeNoop, {`, ` get(_, p) {`, ` if (p === Symbol.toPrimitive) return undefined;`, ` if (p === 'then') return undefined;`, ` if (p === 'addListener' || p === 'removeListener' || p === 'hasListener') return () => {};`, ` return makeProxy();`, ` },`, ` apply() { return undefined; },`, `});`, `globalThis.chrome = makeProxy();`, ].join(' '); } /** * Build the inline ESM source that the spawned child will execute. The * child strips the listed globals on its own isolate, installs a no-op * `chrome.*` mock, then dynamically imports the SW chunk, reporting back * via stdout sentinels. * * Kept as a function (not a template literal at module top) so the * sentinel/global lists stay synced with the constants above. * * @param chunkUrl - file:// URL the child will import. * @returns ESM source the child runs under `node --input-type=module`. */ function buildChildSource(chunkUrl: string): string { const stripLines = SW_FORBIDDEN_GLOBALS.map( (key) => `delete globalThis['${key}'];`, ).join(' '); const chromeMockLine = buildChromeMockSource(); return [ stripLines, chromeMockLine, `try {`, ` await import(${JSON.stringify(chunkUrl)});`, ` console.log(${JSON.stringify(CHILD_OK_SENTINEL)});`, `} catch (e) {`, ` const msg = e && e.message ? String(e.message) : String(e);`, ` const stack = e && e.stack ? String(e.stack).split('\\n')[0] : '(no stack)';`, ` console.log(${JSON.stringify(CHILD_FAIL_SENTINEL)});`, ` console.log('MSG:' + msg);`, ` console.log('STK:' + 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 * failure — the failure is the data the test asserts on. * * @param chunkUrl - file:// URL the child will import. * @returns Structured result; `ok: false` means the bundle threw. */ async function runSwBundleImportInChild(chunkUrl: string): Promise { const source = buildChildSource(chunkUrl); const { stdout } = await execFileAsync( process.execPath, ['--input-type=module', '-e', source], { timeout: CHILD_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, }, ); if (stdout.includes(CHILD_OK_SENTINEL)) { return { ok: true, errorMessage: '', errorStackFirstLine: '' }; } if (stdout.includes(CHILD_FAIL_SENTINEL)) { const msgMatch = stdout.match(/^MSG:(.*)$/m); const stkMatch = stdout.match(/^STK:(.*)$/m); return { ok: false, errorMessage: msgMatch?.[1] ?? '(no message)', errorStackFirstLine: stkMatch?.[1] ?? '(no stack)', }; } throw new Error( `Child process produced neither OK nor FAIL 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(); it('built SW chunk imports without throwing under SW-simulated globals', async () => { const result = await runSwBundleImportInChild(swChunkUrl); expect( result.ok, result.ok ? 'unreachable' : `Built SW bundle throws at top-level module init under ` + `SW-simulated globals (${SW_FORBIDDEN_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` + `Full message: ${result.errorMessage}\n\n` + `Bundle URL: ${swChunkUrl}`, ).toBe(true); }); });