Files
mokosh/tests/background/sw-bundle-import.test.ts
Mark 74400ae6ac test(debug-01-08): complete SW-bundle-import gate — mock chrome.* surface
The Tier-1 SW-bundle-loadability gate (c75854c) stripped
Buffer/process/window/document from the spawned Node isolate
but did not mock chrome.*. A correctly-bundled SW that reaches
addListener calls at module init would (correctly) progress to
chrome.runtime.onMessage.addListener(...) and throw
ReferenceError because chrome was undefined — a false-positive
RED.

This commit adds a minimal Proxy-based chrome.* stub that
no-ops any chrome.<api>.<method>(...) chain. The gate now
verifies what its file-header comment claims: "bundled artifact
reaches module-init completion under SW-simulated globals."

RED->GREEN: the gate now correctly passes against the post-fix
bundle and would catch any future regression in SW
bundle-loadability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:16:05 +02:00

270 lines
11 KiB
TypeScript

// 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.<anything>.<anything>(...)` 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<string> = [
'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-<hash>.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-<hash>.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.<api>.<method>(...)` 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<ChildImportResult> {
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);
});
});