test(debug-01-08): complete SW-bundle-import gate — mock chrome.* surface

The Tier-1 SW-bundle-loadability gate (c75854c) stripped
Buffer/process/window/document from the spawned Node isolate
but did not mock chrome.*. A correctly-bundled SW that reaches
addListener calls at module init would (correctly) progress to
chrome.runtime.onMessage.addListener(...) and throw
ReferenceError because chrome was undefined — a false-positive
RED.

This commit adds a minimal Proxy-based chrome.* stub that
no-ops any chrome.<api>.<method>(...) chain. The gate now
verifies what its file-header comment claims: "bundled artifact
reaches module-init completion under SW-simulated globals."

RED->GREEN: the gate now correctly passes against the post-fix
bundle and would catch any future regression in SW
bundle-loadability.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-17 11:16:05 +02:00
parent 52c76362ae
commit 74400ae6ac

View File

@@ -26,6 +26,19 @@
// stripping those on the test runner's globalThis crashes vitest itself. // 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. // 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.
//
// 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.
// //
@@ -39,6 +52,8 @@
// https://nodejs.org/api/esm.html#import-expressions // https://nodejs.org/api/esm.html#import-expressions
// Reference for `child_process.execFile`: // Reference for `child_process.execFile`:
// 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:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy
import { execFile } from 'node:child_process'; import { execFile } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
@@ -115,10 +130,54 @@ function resolveBuiltSwChunkUrl(): string {
return pathToFileURL(chunkAbsolutePath).href; 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 * Build the inline ESM source that the spawned child will execute. The
* child strips the listed globals on its own isolate and then dynamically * child strips the listed globals on its own isolate, installs a no-op
* imports the SW chunk, reporting back via stdout sentinels. * `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 * Kept as a function (not a template literal at module top) so the
* sentinel/global lists stay synced with the constants above. * sentinel/global lists stay synced with the constants above.
@@ -130,8 +189,10 @@ function buildChildSource(chunkUrl: string): string {
const stripLines = SW_FORBIDDEN_GLOBALS.map( const stripLines = SW_FORBIDDEN_GLOBALS.map(
(key) => `delete globalThis['${key}'];`, (key) => `delete globalThis['${key}'];`,
).join(' '); ).join(' ');
const chromeMockLine = buildChromeMockSource();
return [ return [
stripLines, stripLines,
chromeMockLine,
`try {`, `try {`,
` await import(${JSON.stringify(chunkUrl)});`, ` await import(${JSON.stringify(chunkUrl)});`,
` console.log(${JSON.stringify(CHILD_OK_SENTINEL)});`, ` console.log(${JSON.stringify(CHILD_OK_SENTINEL)});`,