test(debug-01-08): extend Tier-1 gate to Layer 2 (exercises remuxSegments)
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>
This commit is contained in:
@@ -15,10 +15,39 @@
|
|||||||
// before any handler can register, and the operator's chrome://extensions
|
// before any handler can register, and the operator's chrome://extensions
|
||||||
// "service worker" link goes inaccessible.
|
// "service worker" link goes inaccessible.
|
||||||
//
|
//
|
||||||
// This test exercises the actual built artifact under SW-simulated globals
|
// The gate ships in TWO layers:
|
||||||
// (Buffer/process/document/window stripped). Any throw at top-level module
|
//
|
||||||
// evaluation surfaces as a clean test failure with the exact stack the
|
// Layer 1 ("built SW chunk imports without throwing...") — verifies the
|
||||||
// operator would see in Chrome.
|
// 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
|
// Implementation note: the strip+import happens in a SPAWNED Node child
|
||||||
// process, not in-process. Vitest's own RPC layer references both `Buffer`
|
// process, not in-process. Vitest's own RPC layer references both `Buffer`
|
||||||
@@ -39,6 +68,24 @@
|
|||||||
// chrome.* behavior is the responsibility of the offscreen-handshake
|
// chrome.* behavior is the responsibility of the offscreen-handshake
|
||||||
// and end-to-end smoke tests, not this gate.
|
// 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
|
// 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.
|
// fast with a clear "run npm run build" message if `dist/` is missing.
|
||||||
//
|
//
|
||||||
@@ -54,6 +101,12 @@
|
|||||||
// https://nodejs.org/api/child_process.html#child_processexecfilefile-args-options-callback
|
// https://nodejs.org/api/child_process.html#child_processexecfilefile-args-options-callback
|
||||||
// Reference for Proxy traps:
|
// Reference for Proxy traps:
|
||||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
|
// 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 { execFile } from 'node:child_process';
|
||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
@@ -65,35 +118,89 @@ import { describe, expect, it } from 'vitest';
|
|||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
// Globals stripped to simulate the MV3 Service Worker runtime. Service
|
// Layer 1 strip list — applied to the spawned child BEFORE it imports the
|
||||||
// Workers expose ServiceWorkerGlobalScope which has NO `window`, NO
|
// BUILT SW chunk. Service Workers expose ServiceWorkerGlobalScope which
|
||||||
// `document`, NO `Buffer`, and NO `process`. They DO have standard
|
// has NO `window`, NO `document`, NO `Buffer`, and NO `process`. They DO
|
||||||
// browser-ish globals (fetch, Blob, Crypto, etc.) which Node provides
|
// have standard browser-ish globals (fetch, Blob, Crypto, etc.) which
|
||||||
// natively in modern versions, so we don't strip those.
|
// Node provides natively in modern versions, so we don't strip those.
|
||||||
const SW_FORBIDDEN_GLOBALS: ReadonlyArray<string> = [
|
//
|
||||||
|
// `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',
|
'window',
|
||||||
'document',
|
'document',
|
||||||
'Buffer',
|
'Buffer',
|
||||||
'process',
|
'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
|
// Sentinel strings emitted by the child process; checked by the parent
|
||||||
// to distinguish success from failure without resorting to exit codes
|
// to distinguish success from failure without resorting to exit codes
|
||||||
// (the child may exit 0 on a caught throw).
|
// (the child may exit 0 on a caught throw).
|
||||||
const CHILD_OK_SENTINEL = '__SW_BUNDLE_IMPORT_OK__';
|
const CHILD_OK_SENTINEL = '__SW_BUNDLE_IMPORT_OK__';
|
||||||
const CHILD_FAIL_SENTINEL = '__SW_BUNDLE_IMPORT_FAILED__';
|
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
|
// 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
|
// import takes longer than this, treat it as a hang (functionally the
|
||||||
// same as a top-level throw from the operator's perspective).
|
// same as a top-level throw from the operator's perspective).
|
||||||
const CHILD_TIMEOUT_MS = 10_000;
|
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 {
|
interface ChildImportResult {
|
||||||
readonly ok: boolean;
|
readonly ok: boolean;
|
||||||
readonly errorMessage: string;
|
readonly errorMessage: string;
|
||||||
readonly errorStackFirstLine: 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
|
* 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
|
* `dist/service-worker-loader.js` shim that crxjs emits. The shim is a
|
||||||
@@ -186,7 +293,7 @@ function buildChromeMockSource(): string {
|
|||||||
* @returns ESM source the child runs under `node --input-type=module`.
|
* @returns ESM source the child runs under `node --input-type=module`.
|
||||||
*/
|
*/
|
||||||
function buildChildSource(chunkUrl: string): string {
|
function buildChildSource(chunkUrl: string): string {
|
||||||
const stripLines = SW_FORBIDDEN_GLOBALS.map(
|
const stripLines = SW_BUNDLE_STRIP_GLOBALS.map(
|
||||||
(key) => `delete globalThis['${key}'];`,
|
(key) => `delete globalThis['${key}'];`,
|
||||||
).join(' ');
|
).join(' ');
|
||||||
const chromeMockLine = buildChromeMockSource();
|
const chromeMockLine = buildChromeMockSource();
|
||||||
@@ -206,6 +313,160 @@ function buildChildSource(chunkUrl: string): string {
|
|||||||
].join(' ');
|
].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
|
* Spawn a Node child process that imports the SW chunk under stripped
|
||||||
* globals and returns the result. Resolves rather than rejects on import
|
* globals and returns the result. Resolves rather than rejects on import
|
||||||
@@ -245,10 +506,81 @@ async function runSwBundleImportInChild(chunkUrl: string): Promise<ChildImportRe
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)', () => {
|
describe('SW bundle loadability (Tier-1 gate — closes the 01-08 orchestrator gap)', () => {
|
||||||
// Resolved at module-level (BEFORE the spawn) so `process.cwd()` and
|
// Resolved at module-level (BEFORE the spawn) so `process.cwd()` and
|
||||||
// `process.execPath` are still available.
|
// `process.execPath` are still available.
|
||||||
const swChunkUrl = resolveBuiltSwChunkUrl();
|
const swChunkUrl = resolveBuiltSwChunkUrl();
|
||||||
|
const remuxSourceUrl = pathToFileURL(WEBM_REMUX_SOURCE_PATH).href;
|
||||||
|
|
||||||
it('built SW chunk imports without throwing under SW-simulated globals', async () => {
|
it('built SW chunk imports without throwing under SW-simulated globals', async () => {
|
||||||
const result = await runSwBundleImportInChild(swChunkUrl);
|
const result = await runSwBundleImportInChild(swChunkUrl);
|
||||||
@@ -258,7 +590,7 @@ describe('SW bundle loadability (Tier-1 gate — closes the 01-08 orchestrator g
|
|||||||
result.ok
|
result.ok
|
||||||
? 'unreachable'
|
? 'unreachable'
|
||||||
: `Built SW bundle throws at top-level module init under ` +
|
: `Built SW bundle throws at top-level module init under ` +
|
||||||
`SW-simulated globals (${SW_FORBIDDEN_GLOBALS.join(', ')} ` +
|
`SW-simulated globals (${SW_BUNDLE_STRIP_GLOBALS.join(', ')} ` +
|
||||||
`stripped). This is exactly what kills the SW in Chrome and ` +
|
`stripped). This is exactly what kills the SW in Chrome and ` +
|
||||||
`makes chrome://extensions "service worker" inaccessible.\n\n` +
|
`makes chrome://extensions "service worker" inaccessible.\n\n` +
|
||||||
`First line of stack: ${result.errorStackFirstLine}\n` +
|
`First line of stack: ${result.errorStackFirstLine}\n` +
|
||||||
@@ -266,4 +598,35 @@ describe('SW bundle loadability (Tier-1 gate — closes the 01-08 orchestrator g
|
|||||||
`Bundle URL: ${swChunkUrl}`,
|
`Bundle URL: ${swChunkUrl}`,
|
||||||
).toBe(true);
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user