From 74400ae6ac186588a3a943a9f9485f702b92d0a3 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 17 May 2026 11:16:05 +0200 Subject: [PATCH] =?UTF-8?q?test(debug-01-08):=20complete=20SW-bundle-impor?= =?UTF-8?q?t=20gate=20=E2=80=94=20mock=20chrome.*=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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..(...) 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) --- tests/background/sw-bundle-import.test.ts | 65 ++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/tests/background/sw-bundle-import.test.ts b/tests/background/sw-bundle-import.test.ts index 0c707f5..e338f99 100644 --- a/tests/background/sw-bundle-import.test.ts +++ b/tests/background/sw-bundle-import.test.ts @@ -26,6 +26,19 @@ // 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..(...)` 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 // 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 // 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 import { execFile } from 'node:child_process'; import { existsSync, readFileSync } from 'node:fs'; @@ -115,10 +130,54 @@ function resolveBuiltSwChunkUrl(): string { 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..(...)` 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 and then dynamically - * imports the SW chunk, reporting back via stdout sentinels. + * 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. @@ -130,8 +189,10 @@ function buildChildSource(chunkUrl: string): string { const stripLines = SW_FORBIDDEN_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)});`,