Files
mokosh/tests/background/sw-bundle-import.test.ts
Mark c75854cbef 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>
2026-05-17 09:52:45 +02:00

209 lines
8.3 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.
//
// 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);
});
});