The original Layer 1 gate (74400ae) verified module-init under
SW-simulated globals. It did not exercise remuxSegments — the
actual runtime code path the SW reaches on SAVE_ARCHIVE.
Layer 2 imports webm-remux.ts as SOURCE in a spawned Node child
under SW-simulated globals, invokes remuxSegments with a synthetic
single-segment EBML payload, and classifies the outcome:
- `ok` (returned a Blob) or `domain_error` (e.g. invalid EBML
header — proves runtime path is structurally reachable) → PASS
- `sw_incompat` (ReferenceError for Node globals, EvalError /
unsafe-eval for CSP) → FAIL with the specific error surfaced
This is the gate that empirically caught the ts-ebml Buffer issue
addressed by the preceding polyfill commit; it closes the loop
between "bundle loads" (Layer 1) and "bundle works at runtime"
(Layer 2).
Polyfill-aware design: Layer 2 leaves `Buffer` AVAILABLE in the
child env (split strip list: SW_SOURCE_STRIP_GLOBALS omits
'Buffer'). The vite-plugin-node-polyfills rewrite is BUNDLER-LEVEL
(Buffer → imported polyfill chunk) and does not apply when source
is loaded outside Vite. Leaving Buffer available faithfully
models what the polyfilled bundle provides at SW runtime, while
keeping the classifier ready to flag Buffer regressions if the
polyfill ever gets removed. `process`/`window`/`document` remain
stripped (polyfill is configured globals.process: false; SW
genuinely lacks DOM).
Node 24 native TS transform (`--experimental-transform-types`)
is used for source loading; a tiny inline resolution hook
appends `.ts` to extensionless relative specifiers, mimicking
vite/rollup's extension policy. Hook is base64-encoded as a
data: URL so the test stays self-contained (no on-disk hook file).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
633 lines
29 KiB
TypeScript
633 lines
29 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.
|
|
//
|
|
// The gate ships in TWO layers:
|
|
//
|
|
// Layer 1 ("built SW chunk imports without throwing...") — verifies the
|
|
// BUNDLED artifact's module-init reaches completion under SW-simulated
|
|
// globals (Buffer/process/window/document all stripped). This is the
|
|
// gate that catches Vite/Rollup CJS-interop bugs like the `Pc={}`-
|
|
// placeholder tree-shake from 01-08. Buffer is stripped here because
|
|
// `vite-plugin-node-polyfills` rewrites bundled `Buffer` references into
|
|
// imports of the polyfill chunk's exported `Buffer` (no dependency on
|
|
// `globalThis.Buffer`); if a future bundler change re-introduces a
|
|
// `globalThis.Buffer` dependency, this strip catches it.
|
|
//
|
|
// Layer 2 ("source remuxSegments invocation does not throw an SW-
|
|
// incompatible error...") — verifies that the SOURCE `remuxSegments`
|
|
// code path is REACHABLE under SW-simulated globals. Layer 1 only
|
|
// proves module-init; if ts-ebml or webm-muxer reaches a code path
|
|
// only when `remuxSegments` is INVOKED (which only happens at
|
|
// archive-save time, deep inside `chrome.runtime.onMessage` for
|
|
// `SAVE_ARCHIVE`), Layer 1 will pass while the real SW could still
|
|
// crash mid-archive. Layer 2 catches that.
|
|
//
|
|
// IMPORTANT — Layer 2 polyfill semantics. Layer 2 imports SOURCE
|
|
// webm-remux.ts directly via Node's TS transform; the polyfill is a
|
|
// BUNDLER-LEVEL rewrite (Buffer → imported polyfill chunk) that does
|
|
// NOT apply when source is loaded outside Vite. To faithfully model
|
|
// what the real SW sees at runtime (the bundle with polyfilled Buffer),
|
|
// Layer 2 leaves `Buffer` AVAILABLE in the child env — exactly mirroring
|
|
// what the polyfilled bundle provides. Stripping Buffer in Layer 2
|
|
// would simulate a more-restrictive runtime than what the polyfilled
|
|
// bundle actually faces, producing false-positive failures. `process`,
|
|
// `window`, `document` remain stripped because the polyfill is
|
|
// configured with `globals.process: false` and the SW genuinely has no
|
|
// DOM.
|
|
//
|
|
// 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.
|
|
//
|
|
// Layer 2 source-import: Node 24 ships native TypeScript transform via
|
|
// `--experimental-transform-types` (no `tsx` / `ts-node` install needed).
|
|
// `webm-remux.ts` imports `'../shared/logger'` without a `.ts` extension —
|
|
// vite/rollup add the extension at bundle time, but the bare Node ESM
|
|
// resolver does NOT. To bridge that, the child registers a tiny resolution
|
|
// hook that retries failed `./...`/`../...` lookups with a `.ts` suffix.
|
|
// The hook is inlined as a `data:` URL so the test stays self-contained.
|
|
//
|
|
// Layer 2 invocation: we pass `remuxSegments` a single synthetic Blob
|
|
// carrying a 4-byte EBML magic + minimal "DocType=webm" header — enough
|
|
// to push the call past entry-point validation and into ts-ebml's
|
|
// decoder. Domain errors (invalid-EBML, missing-Tracks, etc.) are
|
|
// ACCEPTABLE outcomes; they prove the runtime path is structurally
|
|
// reachable under SW-simulated globals. Only `process` / CSP-eval errors
|
|
// (and a `Buffer` ReferenceError, which would mean the polyfill itself
|
|
// regressed) indicate genuine SW-incompat that would crash the SW mid-
|
|
// archive in Chrome. The classifier in the child encodes that policy.
|
|
//
|
|
// 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
|
|
// Reference for Node 24 native TS transform:
|
|
// https://nodejs.org/api/typescript.html#type-stripping
|
|
// Reference for Node ESM resolution hooks (`register`):
|
|
// https://nodejs.org/api/module.html#moduleregisterspecifier-parenturl-options
|
|
// Reference for vite-plugin-node-polyfills (Buffer rewrite policy):
|
|
// https://github.com/davidmyersdev/vite-plugin-node-polyfills
|
|
|
|
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);
|
|
|
|
// Layer 1 strip list — applied to the spawned child BEFORE it imports the
|
|
// BUILT SW chunk. 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.
|
|
//
|
|
// `Buffer` is stripped here EVEN THOUGH `vite-plugin-node-polyfills`
|
|
// resolves Buffer at bundle-time: the bundler rewrites every `Buffer`
|
|
// reference into an import of the polyfill chunk's exported `Buffer`,
|
|
// so the bundle itself never reads `globalThis.Buffer`. Stripping Buffer
|
|
// from the child's globalThis is therefore an additional contract: "the
|
|
// bundle must not depend on globalThis.Buffer." A regression where the
|
|
// polyfill plugin gets removed or misconfigured would re-introduce a
|
|
// raw `Buffer.alloc(...)` somewhere in the bundle, and Layer 1 would
|
|
// catch it.
|
|
const SW_BUNDLE_STRIP_GLOBALS: ReadonlyArray<string> = [
|
|
'window',
|
|
'document',
|
|
'Buffer',
|
|
'process',
|
|
];
|
|
|
|
// Layer 2 strip list — applied to the spawned child BEFORE it dynamically
|
|
// imports the SOURCE `src/background/webm-remux.ts`. The polyfill is a
|
|
// BUNDLER-LEVEL rewrite that DOES NOT apply when ts-ebml's source is
|
|
// loaded outside Vite (via Node's TS transform). So in Layer 2, `Buffer`
|
|
// is left available in the child env — exactly mirroring what the
|
|
// polyfilled BUNDLE provides at SW runtime. Stripping it here would
|
|
// simulate a more-restrictive environment than the polyfilled bundle
|
|
// actually faces in Chrome, producing false-positive failures.
|
|
//
|
|
// `process`, `window`, `document` remain stripped: the polyfill is
|
|
// configured with `globals.process: false` (not provided), and the SW
|
|
// genuinely has no DOM regardless of polyfills.
|
|
const SW_SOURCE_STRIP_GLOBALS: ReadonlyArray<string> = [
|
|
'window',
|
|
'document',
|
|
'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__';
|
|
|
|
// Layer 2 sentinels. The runtime invocation distinguishes three outcomes:
|
|
// success (returned a Blob), domain-error (threw but the error is the
|
|
// expected shape of a parse failure on our synthetic input — acceptable),
|
|
// and SW-incompat (threw a process/CSP error — RED; or a Buffer
|
|
// ReferenceError, which would mean the polyfill itself regressed).
|
|
const CHILD_REMUX_OK_SENTINEL = '__SW_REMUX_OK__';
|
|
const CHILD_REMUX_DOMAIN_ERROR_SENTINEL = '__SW_REMUX_DOMAIN_ERROR__';
|
|
const CHILD_REMUX_SW_INCOMPAT_SENTINEL = '__SW_REMUX_SW_INCOMPAT__';
|
|
|
|
// 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;
|
|
|
|
// Source path for Layer 2. Absolute to avoid `process.cwd()` quirks inside
|
|
// the spawned child (whose CWD may diverge from the test runner's).
|
|
const WEBM_REMUX_SOURCE_PATH = resolvePath(
|
|
process.cwd(),
|
|
'src/background/webm-remux.ts',
|
|
);
|
|
|
|
interface ChildImportResult {
|
|
readonly ok: boolean;
|
|
readonly errorMessage: string;
|
|
readonly errorStackFirstLine: string;
|
|
}
|
|
|
|
/** Layer 2 outcome categories — see file header for semantics. */
|
|
type RemuxOutcome = 'ok' | 'domain_error' | 'sw_incompat';
|
|
|
|
interface ChildRemuxResult {
|
|
readonly outcome: RemuxOutcome;
|
|
readonly errorName: string;
|
|
readonly errorMessage: string;
|
|
readonly errorStack: 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_BUNDLE_STRIP_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(' ');
|
|
}
|
|
|
|
/**
|
|
* Build the inline ESM source for Layer 2: dynamic-import the SOURCE
|
|
* `webm-remux.ts` under stripped globals and invoke `remuxSegments`
|
|
* against a synthetic single-segment input. The child registers a
|
|
* resolution hook so that `import '../shared/logger'` (no `.ts`
|
|
* extension) resolves via the `.ts` source, mimicking what
|
|
* vite/rollup does at bundle time.
|
|
*
|
|
* The synthetic segment is the minimum EBML doctype header
|
|
* (`1A 45 DF A3` magic + `42 82 84 'webm'` DocType element). ts-ebml
|
|
* may accept this and emit zero frames, or throw on missing
|
|
* Segment/Cluster — both are domain-correct outcomes. Only `process`,
|
|
* `Buffer` (which would indicate polyfill regression), or eval-related
|
|
* errors prove SW-incompat.
|
|
*
|
|
* @param remuxSourceUrl - file:// URL of `src/background/webm-remux.ts`.
|
|
* @returns ESM source the child runs under `node --experimental-transform-types`.
|
|
*/
|
|
function buildRemuxRuntimeChildSource(remuxSourceUrl: string): string {
|
|
// Loader hook: when a relative specifier (./ or ../) has NO extension,
|
|
// try the default resolver first; if it returns ERR_MODULE_NOT_FOUND,
|
|
// retry with `.ts` appended. This mirrors vite/rollup's extension
|
|
// resolution policy for the project's TS source tree.
|
|
const loaderSource = [
|
|
`export async function resolve(specifier, context, next) {`,
|
|
` const isRelative = specifier.startsWith('./') || specifier.startsWith('../');`,
|
|
` const hasKnownExt = specifier.endsWith('.ts') || specifier.endsWith('.js')`,
|
|
` || specifier.endsWith('.mjs') || specifier.endsWith('.cjs')`,
|
|
` || specifier.endsWith('.json');`,
|
|
` if (isRelative && !hasKnownExt) {`,
|
|
` try { return await next(specifier, context); }`,
|
|
` catch (err) {`,
|
|
` if (err && err.code === 'ERR_MODULE_NOT_FOUND') {`,
|
|
` return await next(specifier + '.ts', context);`,
|
|
` }`,
|
|
` throw err;`,
|
|
` }`,
|
|
` }`,
|
|
` return next(specifier, context);`,
|
|
`}`,
|
|
].join(' ');
|
|
|
|
// Encode the loader as a base64 data: URL so the child does not need any
|
|
// on-disk loader file. Buffer is available HERE (parent context) because
|
|
// we only call this helper before the strip happens in the child.
|
|
const loaderDataUrl =
|
|
'data:text/javascript;base64,' +
|
|
Buffer.from(loaderSource, 'utf8').toString('base64');
|
|
|
|
const chromeMockLine = buildChromeMockSource();
|
|
const stripLines = SW_SOURCE_STRIP_GLOBALS.map(
|
|
(key) => `delete globalThis['${key}'];`,
|
|
).join(' ');
|
|
|
|
// Classifier for the runtime outcome. The "SW-incompat" set is precisely
|
|
// the failure modes that would crash the real SW in Chrome at
|
|
// archive-save time, GIVEN the current bundler config (polyfilled Buffer):
|
|
// - ReferenceError: Buffer is not defined (would mean the polyfill
|
|
// itself has regressed; we still flag this as SW-incompat because
|
|
// it would crash the real SW if the polyfill were removed)
|
|
// - ReferenceError: process is not defined (polyfill is configured
|
|
// `globals.process: false`, so process truly is missing at SW
|
|
// runtime)
|
|
// - EvalError / CSP errors (would-be `new Function(...)` in encoder
|
|
// blocked by MV3 SW CSP)
|
|
// - SyntaxError from eval (same CSP class)
|
|
// Everything else (parse failures, missing Tracks, etc.) is a domain
|
|
// error — the runtime path is structurally reachable, the SW would
|
|
// surface a clean error to the operator instead of crashing the worker.
|
|
//
|
|
// NOTE: Layer 2 leaves `Buffer` AVAILABLE in the child env (see
|
|
// SW_SOURCE_STRIP_GLOBALS), mirroring the polyfilled bundle. A
|
|
// `Buffer is not defined` here would only happen if the child Node
|
|
// environment itself lost Buffer (essentially impossible) or if a
|
|
// future test refactor accidentally re-stripped it — in both cases
|
|
// we want to surface that as SW-incompat-class noise rather than
|
|
// mask it as a domain error.
|
|
const classifyExprParts = [
|
|
`(err) => {`,
|
|
` const name = err && err.name ? String(err.name) : '';`,
|
|
` const msg = err && err.message ? String(err.message) : String(err);`,
|
|
` if (name === 'ReferenceError' && /\\b(Buffer|process)\\b/.test(msg)) return 'sw_incompat';`,
|
|
` if (name === 'EvalError') return 'sw_incompat';`,
|
|
` if (/Refused to evaluate|unsafe-eval/.test(msg)) return 'sw_incompat';`,
|
|
` return 'domain_error';`,
|
|
`}`,
|
|
];
|
|
const classifyExpr = classifyExprParts.join(' ');
|
|
|
|
return [
|
|
// STEP 1: register the resolution hook FIRST, so subsequent imports
|
|
// (including the dynamic import of webm-remux.ts) go through it.
|
|
`import { register } from 'node:module';`,
|
|
`import { pathToFileURL } from 'node:url';`,
|
|
`register(${JSON.stringify(loaderDataUrl)}, pathToFileURL('./'));`,
|
|
|
|
// STEP 2: strip SW-forbidden globals. Must happen AFTER register()
|
|
// because register() touches `process` internally. Note that
|
|
// SW_SOURCE_STRIP_GLOBALS does NOT include 'Buffer' — see classifier
|
|
// comment above for rationale.
|
|
stripLines,
|
|
|
|
// STEP 3: install chrome.* mock. webm-remux.ts itself does not touch
|
|
// chrome.*, but its `Logger` import chain might transitively, so we
|
|
// keep symmetry with Layer 1.
|
|
chromeMockLine,
|
|
|
|
// STEP 4: import and invoke. Wrap everything in try/catch and emit
|
|
// sentinels via console.log.
|
|
`const classify = ${classifyExpr};`,
|
|
`try {`,
|
|
` const mod = await import(${JSON.stringify(remuxSourceUrl)});`,
|
|
` if (typeof mod.remuxSegments !== 'function') {`,
|
|
` console.log(${JSON.stringify(CHILD_REMUX_SW_INCOMPAT_SENTINEL)});`,
|
|
` console.log('NAME:Error');`,
|
|
` console.log('MSG:remuxSegments export missing from source module');`,
|
|
` console.log('STK:(no stack)');`,
|
|
` } else {`,
|
|
// Synthetic single-segment payload: EBML magic + DocType=webm.
|
|
` const segBytes = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3, 0x42, 0x82, 0x84, 0x77, 0x65, 0x62, 0x6d]);`,
|
|
` const segBlob = new Blob([segBytes], { type: 'video/webm' });`,
|
|
` const segments = [{ data: segBlob, timestamp: 1 }];`,
|
|
` try {`,
|
|
` const out = await mod.remuxSegments(segments);`,
|
|
` console.log(${JSON.stringify(CHILD_REMUX_OK_SENTINEL)});`,
|
|
` console.log('SIZE:' + String(out && typeof out.size === 'number' ? out.size : -1));`,
|
|
` } catch (innerErr) {`,
|
|
` const outcome = classify(innerErr);`,
|
|
` const sentinel = outcome === 'sw_incompat'`,
|
|
` ? ${JSON.stringify(CHILD_REMUX_SW_INCOMPAT_SENTINEL)}`,
|
|
` : ${JSON.stringify(CHILD_REMUX_DOMAIN_ERROR_SENTINEL)};`,
|
|
` console.log(sentinel);`,
|
|
` console.log('NAME:' + (innerErr && innerErr.name ? String(innerErr.name) : 'Error'));`,
|
|
` console.log('MSG:' + (innerErr && innerErr.message ? String(innerErr.message) : String(innerErr)));`,
|
|
` console.log('STK:' + (innerErr && innerErr.stack ? String(innerErr.stack).split('\\n').slice(0, 5).join(' | ') : '(no stack)'));`,
|
|
` }`,
|
|
` }`,
|
|
`} catch (importErr) {`,
|
|
// An error here means the SOURCE module failed to LOAD under stripped
|
|
// globals — classify it the same way (process/CSP class at module-
|
|
// init level in webm-remux.ts's transitive deps would be a real
|
|
// SW-incompat).
|
|
` const outcome = classify(importErr);`,
|
|
` const sentinel = outcome === 'sw_incompat'`,
|
|
` ? ${JSON.stringify(CHILD_REMUX_SW_INCOMPAT_SENTINEL)}`,
|
|
` : ${JSON.stringify(CHILD_REMUX_DOMAIN_ERROR_SENTINEL)};`,
|
|
` console.log(sentinel);`,
|
|
` console.log('NAME:' + (importErr && importErr.name ? String(importErr.name) : 'Error'));`,
|
|
` console.log('MSG:' + (importErr && importErr.message ? String(importErr.message) : String(importErr)));`,
|
|
` console.log('STK:' + (importErr && importErr.stack ? String(importErr.stack).split('\\n').slice(0, 5).join(' | ') : '(no 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)}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Spawn a Node child process that source-imports `src/background/
|
|
* webm-remux.ts` under SW-simulated globals and invokes `remuxSegments`
|
|
* against a synthetic single-segment payload. Resolves with a structured
|
|
* outcome — `sw_incompat` is the test failure mode.
|
|
*
|
|
* @param remuxSourceUrl - file:// URL of the webm-remux.ts source.
|
|
* @returns Structured outcome with name/message/stack on errors.
|
|
*/
|
|
async function runRemuxRuntimeInChild(remuxSourceUrl: string): Promise<ChildRemuxResult> {
|
|
const source = buildRemuxRuntimeChildSource(remuxSourceUrl);
|
|
const { stdout } = await execFileAsync(
|
|
process.execPath,
|
|
// `--experimental-transform-types` enables Node 24's native TS support
|
|
// (strip types + handle enums/namespaces + auto-resolve type-only
|
|
// imports). No tsx/ts-node install required.
|
|
['--experimental-transform-types', '--input-type=module-typescript', '-e', source],
|
|
{
|
|
timeout: CHILD_TIMEOUT_MS,
|
|
maxBuffer: 4 * 1024 * 1024,
|
|
env: {
|
|
...process.env,
|
|
// Suppress the ExperimentalWarning noise on stderr; the sentinel
|
|
// protocol only inspects stdout, but a clean stderr keeps the
|
|
// vitest output readable when this test fails.
|
|
NODE_NO_WARNINGS: '1',
|
|
},
|
|
},
|
|
);
|
|
|
|
const okHit = stdout.includes(CHILD_REMUX_OK_SENTINEL);
|
|
const domainHit = stdout.includes(CHILD_REMUX_DOMAIN_ERROR_SENTINEL);
|
|
const swIncompatHit = stdout.includes(CHILD_REMUX_SW_INCOMPAT_SENTINEL);
|
|
|
|
// Exactly one outcome sentinel should appear. SW-incompat takes
|
|
// precedence in the (impossible) event of multiple — it's the failure
|
|
// mode we want to surface.
|
|
if (swIncompatHit) {
|
|
const nameMatch = stdout.match(/^NAME:(.*)$/m);
|
|
const msgMatch = stdout.match(/^MSG:(.*)$/m);
|
|
const stkMatch = stdout.match(/^STK:(.*)$/m);
|
|
return {
|
|
outcome: 'sw_incompat',
|
|
errorName: nameMatch?.[1] ?? '(no name)',
|
|
errorMessage: msgMatch?.[1] ?? '(no message)',
|
|
errorStack: stkMatch?.[1] ?? '(no stack)',
|
|
};
|
|
}
|
|
|
|
if (domainHit) {
|
|
const nameMatch = stdout.match(/^NAME:(.*)$/m);
|
|
const msgMatch = stdout.match(/^MSG:(.*)$/m);
|
|
const stkMatch = stdout.match(/^STK:(.*)$/m);
|
|
return {
|
|
outcome: 'domain_error',
|
|
errorName: nameMatch?.[1] ?? '(no name)',
|
|
errorMessage: msgMatch?.[1] ?? '(no message)',
|
|
errorStack: stkMatch?.[1] ?? '(no stack)',
|
|
};
|
|
}
|
|
|
|
if (okHit) {
|
|
return { outcome: 'ok', errorName: '', errorMessage: '', errorStack: '' };
|
|
}
|
|
|
|
throw new Error(
|
|
`Child produced no outcome 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();
|
|
const remuxSourceUrl = pathToFileURL(WEBM_REMUX_SOURCE_PATH).href;
|
|
|
|
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_BUNDLE_STRIP_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);
|
|
});
|
|
|
|
it('source remuxSegments invocation does not throw an SW-incompatible error', async () => {
|
|
if (!existsSync(WEBM_REMUX_SOURCE_PATH)) {
|
|
throw new Error(
|
|
`webm-remux source not found at ${WEBM_REMUX_SOURCE_PATH}. ` +
|
|
`The Tier-1 gate's runtime layer requires the SOURCE file ` +
|
|
`(not just the built bundle).`,
|
|
);
|
|
}
|
|
|
|
const result = await runRemuxRuntimeInChild(remuxSourceUrl);
|
|
|
|
// Domain errors (invalid-EBML parse, missing-Tracks, etc.) on our
|
|
// intentionally-synthetic input are ACCEPTABLE — they prove the
|
|
// runtime path is structurally reachable. Success is also fine.
|
|
// Only `sw_incompat` (process/CSP/eval — or a Buffer error, which
|
|
// would mean the polyfill itself regressed) is a real failure.
|
|
expect(
|
|
result.outcome !== 'sw_incompat',
|
|
result.outcome === 'sw_incompat'
|
|
? `remuxSegments throws an SW-INCOMPATIBLE error when invoked ` +
|
|
`under SW-simulated globals. This means the SW would crash ` +
|
|
`mid-archive-save in real Chrome — exactly the kind of bug ` +
|
|
`Layer 1 (module-init only) cannot catch.\n\n` +
|
|
`Error name: ${result.errorName}\n` +
|
|
`Error message: ${result.errorMessage}\n` +
|
|
`Stack (first 5 frames, ' | '-separated):\n ${result.errorStack}\n\n` +
|
|
`Source URL: ${remuxSourceUrl}`
|
|
: 'unreachable',
|
|
).toBe(true);
|
|
});
|
|
});
|