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>
270 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|