test(debug-01-08): RED Tier-1 SW-bundle-loadability gate + corrected hypothesis
Adds tests/background/sw-bundle-import.test.ts that loads the built SW
chunk under SW-simulated globals (Buffer/process/window/document stripped)
via a spawned Node child process. Pins the orchestrator-side gap that
caused Plan 01-08's SW init crash: the prior deps test only checked
SOURCE packages under default Node globals, never the bundled output, so
Vite/Rollup's CJS-interop bug (tree-shaking the `ebml` package while
leaving a dangling `{tools:f}=Pc` destructure against an empty Pc) went
undetected until operator empirical smoke.
RED against HEAD aabbd0c — failure surfaces the exact production error
("Cannot read properties of undefined (reading 'readVint')"), proving
the test is a true regression gate, not a tautology.
Also rewrites .planning/debug/01-08-sw-incompatibility.md to reflect the
actual root cause (Vite/Rollup CJS interop) rather than the orchestrator's
initial falsified hypothesis (new Function + Buffer globals — disproven
by Node simulation showing the throw fires at module-init line 12:33809
before any CSP-eval or Buffer-ref code path executes).
Full vitest: 60 passing + 3 RED (this gate + the 2 pre-existing Task 5
fixture-dependent duration tests). No regressions.
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.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
208
tests/background/sw-bundle-import.test.ts
Normal file
208
tests/background/sw-bundle-import.test.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
// 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<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 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<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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user