// 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. // // 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 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 inline ESM source that the spawned child will execute. The * child strips the listed globals on its own isolate and 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(' '); return [ stripLines, `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); }); });