Milestone v1 (v2.0.0): Mokosh — Session Capture #1
@@ -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