From c75854cbefbe841e29fba3afb7c84225dc18a0c8 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 17 May 2026 09:52:45 +0200 Subject: [PATCH] test(debug-01-08): RED Tier-1 SW-bundle-loadability gate + corrected hypothesis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tests/background/sw-bundle-import.test.ts that loads the built SW chunk under SW-simulated globals (Buffer/process/window/document stripped) via a spawned Node child process. Pins the orchestrator-side gap that caused Plan 01-08's SW init crash: the prior deps test only checked SOURCE packages under default Node globals, never the bundled output, so Vite/Rollup's CJS-interop bug (tree-shaking the `ebml` package while leaving a dangling `{tools:f}=Pc` destructure against an empty Pc) went undetected until operator empirical smoke. RED against HEAD aabbd0c — failure surfaces the exact production error ("Cannot read properties of undefined (reading 'readVint')"), proving the test is a true regression gate, not a tautology. Also rewrites .planning/debug/01-08-sw-incompatibility.md to reflect the actual root cause (Vite/Rollup CJS interop) rather than the orchestrator's initial falsified hypothesis (new Function + Buffer globals — disproven by Node simulation showing the throw fires at module-init line 12:33809 before any CSP-eval or Buffer-ref code path executes). Full vitest: 60 passing + 3 RED (this gate + the 2 pre-existing Task 5 fixture-dependent duration tests). No regressions. 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. Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/debug/01-08-sw-incompatibility.md | 456 ++++++++++++++++++++ tests/background/sw-bundle-import.test.ts | 208 +++++++++ 2 files changed, 664 insertions(+) create mode 100644 .planning/debug/01-08-sw-incompatibility.md create mode 100644 tests/background/sw-bundle-import.test.ts diff --git a/.planning/debug/01-08-sw-incompatibility.md b/.planning/debug/01-08-sw-incompatibility.md new file mode 100644 index 0000000..1e57e51 --- /dev/null +++ b/.planning/debug/01-08-sw-incompatibility.md @@ -0,0 +1,456 @@ +--- +slug: 01-08-sw-incompatibility +status: investigating +trigger: | + Plan 01-08 Tasks 1-4 landed cleanly (5 commits 5035314..aabbd0c, merged + fast-forward into gsd/phase-01-stabilize-video-pipeline at aabbd0c). + All gates green: tsc clean, type-safety grep clean, npm run build exit 0, + 60/62 vitest GREEN (only the 2 fixture-dependent webm-playback duration + tests remain RED — those are Task 5's empirical responsibility). + + Operator ran smoke.sh against the post-remux build and reported: "it + errored, and i can't even see the SW console" — + chrome://serviceworker-internals shows the SW at Running Status: + STARTING (stuck forever), Fetch handler existence: DOES_NOT_EXIST, Log + empty. The SW dies at top-level module evaluation BEFORE any handler + registers and before any console.log can fire. + + Initial orchestrator hypothesis ("ts-ebml uses `new Function` + Buffer + globals → CSP-blocks SW") was speculation from bundle grep and proved + WRONG when tested. A proper Node-simulation that strips SW-relevant + globals (`delete globalThis.Buffer; delete globalThis.process; await + import('./dist/assets/index.ts-8ny38Qcj.js')`) reveals the actual error + fires at top-level module init: + + TypeError: Cannot read properties of undefined (reading 'readVint') + at file:///.../dist/assets/index.ts-8ny38Qcj.js:12:33809 + at hn (file:///.../dist/assets/index.ts-8ny38Qcj.js:12:41461) + at file:///.../dist/assets/index.ts-8ny38Qcj.js:12:42172 + at ModuleJob.run (node:internal/modules/esm/module_job:430:25) + + Bundle context at the failure site: + + i.readVint=i.writeVint=i.readBlock=...=void 0; + const s=mo, h=a(go()), {tools:f}=Pc, d=Gc; + i.readVint = f.readVint; // ← throws: f is undefined + + This is the bundled form of ts-ebml/lib/tools.js. The destructure + `{tools:f}=Pc` fails because `Pc` is an empty placeholder namespace + object (`var Pc={}` — declared once, never populated). `Pc` is the + Vite/Rollup-mangled identifier for the `ebml` package (transitive dep + of ts-ebml; ts-ebml's tools.js does `const { tools: _tools } = + require("ebml")`). + + Root cause is a Vite/Rollup CJS-interop bug, NOT a SW-API mismatch. + ts-ebml itself is structurally SW-compatible; it just cannot find its + transitive `ebml` dependency at runtime because Rollup tree-shook the + entire ebml module body while leaving a placeholder reference behind. + The CSP-eval and Buffer-global concerns from the original hypothesis + are real (they would have fired AFTER this error) but are downstream + of the actual init-time crash. + + Plan 01-08's Task 1 deps-compatibility test (tests/background/ + webm-remux-deps.test.ts) ran in vitest's Node env where Buffer IS + defined and inspected source files for DOM globals — it never loaded + the bundled output in a SW-simulated env, so the runtime tree-shake + hole and the SW-global stripping were both missed. + + This blocks Plan 01-08 entirely until the bundle either successfully + imports `ebml` or replaces ts-ebml with something Vite-friendly. +created: 2026-05-17T07:34:32Z +updated: 2026-05-17T08:15:00Z +phase: 01-stabilize-video-pipeline +related_plan: .planning/phases/01-stabilize-video-pipeline/01-08-PLAN.md +related_summary: .planning/phases/01-stabilize-video-pipeline/01-08-SUMMARY.md +related_uat: .planning/phases/01-stabilize-video-pipeline/01-UAT.md +prior_resolved_sessions: + - .planning/debug/resolved/d12-blob-port-transfer-fails.md + - .planning/debug/resolved/webm-playback-freeze.md + - .planning/debug/resolved/empty-archive-port-race.md + - .planning/debug/d13-multi-ebml-concat-unplayable.md (the prior bug that Plan 01-08 was supposed to fix; still open until 01-08 actually works) +--- + +# Debug: Plan 01-08 SW init crash — Vite/Rollup CJS interop strips `ebml` from bundle + +## Symptoms + +**Expected:** SW initializes cleanly; chrome://extensions shows the +"service worker" link active; SW console accessible; offscreen +handshake completes; recording starts. + +**Actual:** SW dies at top-level module evaluation; +chrome://serviceworker-internals: Status=STARTING (stuck), +Fetch handler=DOES_NOT_EXIST, Log=empty. Operator cannot reach the SW +console because no handler ever registers. + +**Reproduction (bundle-level, no Chrome needed):** +1. `git checkout gsd/phase-01-stabilize-video-pipeline` (HEAD: aabbd0c) +2. `npm install && npm run build` +3. Run a SW-simulated Node import: + + ```bash + node --input-type=module -e " + delete globalThis.Buffer; + delete globalThis.process; + await import('./dist/assets/index.ts-8ny38Qcj.js'); + " + ``` + +4. Observe identical crash to operator: `TypeError: Cannot read + properties of undefined (reading 'readVint')` + +**Reproduction (full smoke):** +1. Steps 1-2 above +2. `KEEP_PROFILE=0 ./smoke.sh` +3. In Chrome: Load Unpacked → dist/ — SW dies as described + +**Diagnostic evidence (bundle inspection):** + +```bash +$ grep -boE "\bPc\b" dist/assets/index.ts-8ny38Qcj.js +73034:Pc # ← declaration +211437:Pc # ← only use site (the failing destructure) +``` + +Bytes 72950-73050: `...var Pc={},Zi={exports:{}},Xi={exports:{}}...` +— `Pc` is declared as an empty object literal and **never assigned** +anywhere else in the 374 KB bundle. + +Bytes 211350-211450 (failure site, transpiled `ts-ebml/lib/tools.js`): +```js +const s=mo, h=a(go()), {tools:f}=Pc, d=Gc; +i.readVint = f.readVint; // ← throws here at module init +``` + +Identifier mapping (verified against `node_modules/ts-ebml/lib/tools.js`): +- `mo` → `int64-buffer` (correctly bundled, source visible) +- `go()` → `EBMLEncoder` factory (correctly bundled) +- `Pc` → `ebml` package (empty placeholder; tree-shaken) +- `Gc` → `ebml-block` (correctly bundled, source visible) + +Bundle source-identifier audit for `ebml` package: +```bash +$ grep -c "EbmlEncoder" dist/assets/index.ts-8ny38Qcj.js +0 +$ grep -c "EbmlDecoder" dist/assets/index.ts-8ny38Qcj.js +0 +$ grep -c "Tools as tools" dist/assets/index.ts-8ny38Qcj.js +0 +``` + +None of the `ebml` package's source identifiers appear in the bundle — +Rollup tree-shook the entire module body while leaving the destructure +reference dangling. + +**Why the CJS interop fails:** + +`node_modules/ebml/package.json` declares all three of `main`, +`module`, and `browser`. Vite (browser/SW target) prefers `module` +(`lib/ebml.esm.js`), which exports as **named ESM**: + + export { Tools as tools, schema, EbmlDecoder as Decoder, EbmlEncoder as Encoder }; + +But `node_modules/ts-ebml/lib/tools.js` (compiled CJS) does: + + const { tools: _tools } = require("ebml"); + +`@rollup/plugin-commonjs` is supposed to bridge a CJS `require()` of an +ESM module by wrapping it. Here it allocated the namespace placeholder +`var Pc = {}` for the would-be `module.exports`, but the wrapper that +should rewrite it via `Pc.tools = Tools; Pc.schema = schema; ...` was +never emitted. Body of `ebml.esm.js` was tree-shaken because Rollup +could not statically prove `Pc.readVint`/`Pc.writeVint` reach the +public surface (they're funneled through ts-ebml's `_tools` local). + +This is a known class of @rollup/plugin-commonjs failure mode for +packages that mix `module`/`main`/`browser` fields with consumers that +require them via CJS; usually fixed by forcing esbuild's CJS-interop +via `optimizeDeps.include` or by tightening `commonjsOptions`. + +**Timeline:** +- Bug introduced: commit 41e94d5 ("feat(01-08): implement remuxSegments") + pulled in `ts-ebml@3.0.2` as a runtime dependency. +- Deps test (Task 1, commit 5035314) wrongly certified SW-compat: + it only checked source-level `document`/`window` references, not + bundle-level import-load behavior in a SW-simulated env. +- Discovered: 2026-05-17 by operator empirical smoke. +- Initial orchestrator hypothesis (new Function + Buffer) FALSIFIED + 2026-05-17 via Node-simulation; real cause identified the same day. + +## Current Focus + +hypothesis: | + Vite/Rollup's default CJS-interop pipeline tree-shakes the `ebml` + package out of the SW bundle while leaving a dangling destructure + reference in the bundled `ts-ebml/lib/tools.js`. At SW init time the + destructure `{tools:f}=Pc` evaluates to `{tools: undefined}` because + `Pc` is an empty placeholder namespace object that the CJS wrapper + never populates. Then `_tools.readVint` throws TypeError at + module-level execution, killing the SW before any handler registers. + + This is NOT a ts-ebml-vs-SW-API mismatch, NOT a CSP eval issue, NOT + a Buffer-global issue. Those concerns were the orchestrator's + initial speculative hypothesis and are FALSIFIED by the Node + simulation — the crash fires before any of those code paths would + execute. (They may surface as secondary issues once the primary is + fixed; the strengthened RED gate must catch those too.) + + The fix space is bundler-configuration vs library-swap vs + architectural relocation. See "Candidate fix strategies" below. + +test: | + Two-tier RED gate, both required: + + Tier 1 (cheap, deterministic, runs in vitest): load the built SW + bundle via `await import(distPath)` after stripping SW-incompatible + globals (`delete globalThis.Buffer; delete globalThis.process; + delete globalThis.document; delete globalThis.window`). Assert no + throw. Lives at `tests/background/sw-bundle-import.test.ts`. This is + the gate that should have caught this bug pre-checkpoint. + + Tier 2 (optional, expensive): playwright + a real Chrome MV3 + unpacked-load that checks the SW reaches OFFSCREEN_READY. Deferred + unless Tier 1 proves insufficient. + + Tier 1 will go RED IMMEDIATELY against the current dist bundle. It + will go GREEN only after the chosen fix lands. + +expecting: | + After fix lands: + 1. Tier 1 SW-bundle-import test passes. + 2. SW initializes cleanly in Chrome; chrome://serviceworker-internals + shows Running Status: ACTIVATED, Fetch handler: EXISTS. + 3. Offscreen handshake completes. + 4. smoke.sh produces a zip with playable ~30s WebM. + 5. The 2 currently-RED webm-playback duration tests (Task 5's gate) + either go GREEN or surface a separate, post-fix issue worth + debugging on its own merits. + +next_action: | + CHECKPOINT to orchestrator with the 4 candidate fix strategies + + the debugger's recommendation. Orchestrator routes to user via + AskUserQuestion. Per feedback-no-unilateral-scope-reduction, the + debugger does NOT pick. + +reasoning_checkpoint: "" +tdd_checkpoint: "Tier 1 RED gate landed at tests/background/sw-bundle-import.test.ts — verified RED against HEAD aabbd0c" + +## Constraints + +- TDD mode is ON. Tier 1 RED test landed BEFORE any GREEN fix. +- Auto-loaded memories: `feedback-gsd-ceremony-for-fixes.md` (no + hot-edits) and `feedback-no-unilateral-scope-reduction.md` (no + scope narrowing; surface choices via AskUserQuestion). +- `feedback-pre-checkpoint-bundle-gates.md`: the Tier 1 gate + explicitly closes the orchestrator-side gap that caused this bug + — any future plan executor MUST run Tier 1 before surfacing an + operator-empirical checkpoint. +- Plan 01-08 Tasks 1-4 are committed (5 commits). The fix can amend + on top of those commits (preserve history) OR revert ts-ebml and + replan. Both are reasonable; the choice depends on which fix + strategy the user picks. +- The pre-existing deps test + (tests/background/webm-remux-deps.test.ts) is INSUFFICIENT; the + new Tier 1 gate supersedes it. Whether to delete or rename the old + one is a follow-up — keep it for now. +- The two RED webm-playback duration tests REMAIN red; this debug + session must drive them to GREEN. + +## Candidate fix strategies (surface to user; debugger does NOT pick) + +### Strategy A — Vite `optimizeDeps.include: ['ts-ebml', 'ebml']` + +**Mechanism:** Force esbuild to pre-bundle `ts-ebml` + `ebml` during +Vite's dep-optimization phase. esbuild's CJS↔ESM interop is more +permissive than @rollup/plugin-commonjs and reliably handles the +`require("ebml")` → ESM-named-exports bridge. + +**Blast radius:** Tiny — adds 2 lines to vite.config.ts. No src/ +changes. No dep changes. Build output may grow slightly because +esbuild bundles less aggressively than Rollup but this is the SW +bundle, which is small. + +**Risk:** `optimizeDeps` primarily targets dev-mode (`vite dev`); its +effect on production `vite build` is less guaranteed. May need to +pair with `build.commonjsOptions` (Strategy B). Worth testing in +isolation first. + +**Effort:** 30 min including verification. + +### Strategy B — Vite `build.commonjsOptions: { transformMixedEsModules: true, requireReturnsDefault: 'auto' }` + +**Mechanism:** Tighten @rollup/plugin-commonjs configuration. +`transformMixedEsModules: true` enables the plugin to handle modules +that mix CJS and ESM (which is what `ebml`'s mismatched main/module +fields produce when seen through ts-ebml's CJS require). `auto` +requireReturnsDefault picks the right shape per-module. + +**Blast radius:** Same as A — 2 lines in vite.config.ts. May +combine with A. + +**Risk:** Lower than A in production (operates on Rollup which IS +production bundler). But changes apply globally and may subtly affect +how OTHER CJS deps in the project (zip.js, etc.) bundle. Needs a full +vitest re-run. + +**Effort:** 30 min including verification. + +### Strategy C — Replace `ts-ebml` with a pure-ESM EBML parser + +**Mechanism:** Swap the dep entirely. Candidates: +- `jswebm` — pure-ESM WebM parser; smaller surface; needs API verification +- `ebml-stream` — modern fork of node-ebml; may have similar CJS issues +- `webm-cluster-parser` — narrow-scope parser; might fit our needs +- Hand-rolled minimal EBML reader for just the 3 element types we need + (Segment, Cluster, SimpleBlock) — maybe ~200 LOC + +**Blast radius:** Large — rewrite of `src/background/webm-remux.ts` ++ all unit tests that mock ts-ebml. Removes 2 deps (ts-ebml, ebml) +and their transitive trees, adds 1 (or 0 if hand-rolled). + +**Risk:** Behavioral regression on the actual remux output — current +unit tests assume ts-ebml's element layout. Migration requires +careful cross-validation against the existing test fixtures. Net +positive long-term: removes the entire ts-ebml-CJS-interop class of +bugs. + +**Effort:** 1-2 days if hand-rolled; less if a drop-in pure-ESM +replacement exists and works. + +### Strategy D — Move EBML parsing to OFFSCREEN document + +**Mechanism:** OFFSCREEN has full DOM, lenient CSP, and standard +ESM/CJS interop because Vite emits a separate offscreen bundle that +goes through a different (more permissive) loader path. Move +`remuxSegments` from `src/background/webm-remux.ts` to a new +`src/offscreen/remux.ts`; the SW posts segments to offscreen via +chrome.runtime.sendMessage and gets the remuxed Blob back. + +**Blast radius:** Architectural — invalidates Plan 01-08's +files_modified list. Requires Plan 01-08 amendment. May touch Plan +01-09's `src/offscreen/recorder.ts` for handler co-location. Adds a +new SW↔offscreen message type. + +**Risk:** Pushes more logic into the offscreen tier (which already +handles MediaRecorder + Blob transfer); offscreen lifetime is +chrome-managed and may be killed between segments, requiring careful +re-init. Also: latency of the extra round-trip (acceptable here — +remux happens at archive-time, not at record-time). + +**Effort:** ~1 day including re-coordination with Plan 01-09. + +### Debugger recommendation + +**Try A first (30 min), fall back to B (30 min), fall back to C +(1-2 days), fall back to D (1 day).** Rationale: A and B are pure +config changes with tiny blast radii and high probability of fixing +a vendor-CJS-interop class of bug. They preserve Plan 01-08's +existing implementation and unit tests verbatim. C and D are +heavier-weight backstops only justified if A and B both fail. + +The debugger STRONGLY recommends A+B together over either alone +because they're complementary (A targets dev pre-bundling, B targets +prod Rollup pass) and the cost is identical. + +## Files of Interest + +- `src/background/webm-remux.ts` — current ts-ebml import + remuxSegments +- `tests/background/webm-remux-deps.test.ts` — wrongly-passing deps test (keep but supersede) +- `tests/background/sw-bundle-import.test.ts` — NEW Tier 1 RED gate (this session) +- `dist/assets/index.ts-8ny38Qcj.js` — broken SW bundle (diagnostic only) +- `node_modules/ts-ebml/lib/tools.js` line 9 — `const { tools: _tools } = require("ebml");` (the call that bundles wrong) +- `node_modules/ebml/package.json` — module/main/browser triplet (cause of Rollup confusion) +- `node_modules/ebml/lib/ebml.esm.js` — what Vite picked (named exports) +- `node_modules/ebml/lib/ebml.js` — what ts-ebml's CJS require expects (default export) +- `vite.config.ts` — where strategies A and B would apply +- `src/background/index.ts` — createArchive call site (importer) + +## Evidence + +- timestamp: 2026-05-17T08:10:00Z + source: Node SW-simulation + finding: | + `node --input-type=module -e "delete globalThis.Buffer; delete + globalThis.process; await import('./dist/assets/index.ts-8ny38Qcj.js')"` + throws `TypeError: Cannot read properties of undefined (reading + 'readVint')` at line 12:33809. Reproduces operator's chrome failure + deterministically in 100 ms outside Chrome. + +- timestamp: 2026-05-17T08:11:00Z + source: bundle grep + finding: | + `grep -boE "\bPc\b" dist/assets/index.ts-8ny38Qcj.js` returns + exactly 2 hits: declaration at byte 73034 (`var Pc={}`) and use + at byte 211437 (`{tools:f}=Pc`). Zero assignments between. `Pc` + is the bundled identifier for the unresolved `ebml` import. + +- timestamp: 2026-05-17T08:12:00Z + source: bundle source-identifier audit + finding: | + `grep -c "EbmlEncoder|EbmlDecoder|Tools as tools" + dist/assets/index.ts-8ny38Qcj.js` returns 0/0/0. None of the + `ebml` package's source identifiers are in the bundle — Rollup + tree-shook the entire module body while leaving the import + reference. By contrast `int64-buffer`, `ebml-block`, and ts-ebml + itself ARE in the bundle (verified by their identifiers). + +- timestamp: 2026-05-17T08:13:00Z + source: ts-ebml/lib/tools.js inspection + finding: | + Line 9: `const { tools: _tools } = require("ebml");`. Line 11: + `exports.readVint = _tools.readVint;`. This is the exact pattern + that Vite/Rollup bundles into `{tools:f}=Pc; i.readVint=f.readVint`. + +- timestamp: 2026-05-17T08:14:00Z + source: node_modules/ebml/package.json + finding: | + Declares `main: lib/ebml.js` (CJS, default-exports-style), + `module: lib/ebml.esm.js` (ESM named exports), and `browser: + lib/ebml.iife.js` (IIFE). Vite picks `module` for the browser/SW + target. The shape mismatch between ESM named exports and CJS + require-default is what trips @rollup/plugin-commonjs. + +- timestamp: 2026-05-17T08:15:00Z + source: hypothesis-disconfirmation + finding: | + Initial orchestrator hypothesis (`new Function` CSP-block + Buffer + ReferenceError) cannot be the cause because the Node-simulation + stack trace shows the throw fires at line 12:33809 (the + destructure site) BEFORE any `new Function` or `Buffer.from` call + executes. Those concerns are downstream of init and would only + surface IF the bundle reached the per-segment remux code, which + it never does. The original hypothesis is FALSIFIED. + +## Eliminated + +- "ts-ebml uses `new Function`, blocked by SW CSP" — FALSIFIED. + The `new Function("")` site is reachable only after module init + completes, which never happens. CSP block is downstream. + +- "ts-ebml uses `Buffer.from`, undefined in SW" — FALSIFIED for the + init crash. Buffer references are reachable only inside the per-call + remux functions, never invoked because module init dies first. May + surface as secondary issues after primary fix; Tier 1 gate will + catch. + +- "ts-ebml itself is SW-incompatible" — FALSIFIED. The library's + code is structurally fine; the breakage is in HOW Vite bundles its + transitive `ebml` dep. + +- "Plan 01-08 implementation bug in src/background/webm-remux.ts" — + FALSIFIED. The crash is in bundled node_modules code, not in + application src/. The Plan 01-08 implementation is fine. + +## Resolution + +root_cause: | + Vite/Rollup default CJS-interop pipeline tree-shook the `ebml` + package out of the SW bundle while leaving a dangling destructure + reference in bundled ts-ebml/lib/tools.js. The destructure + `{tools:f}=Pc` against an empty placeholder `Pc` throws TypeError + at SW top-level module init, killing the SW before any handler can + register. Caused by `ebml`'s mismatched main/module/browser package + fields colliding with ts-ebml's CJS-style `require("ebml")` import. +fix: "" +verification: "" +files_changed: [] diff --git a/tests/background/sw-bundle-import.test.ts b/tests/background/sw-bundle-import.test.ts new file mode 100644 index 0000000..0c707f5 --- /dev/null +++ b/tests/background/sw-bundle-import.test.ts @@ -0,0 +1,208 @@ +// 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. +// +// This test exercises the actual built artifact under SW-simulated globals +// (Buffer/process/document/window stripped). Any throw at top-level module +// evaluation surfaces as a clean test failure with the exact stack the +// operator would see in Chrome. +// +// 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. +// +// 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 + +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); + +// Globals stripped to simulate the MV3 Service Worker runtime. 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. +const SW_FORBIDDEN_GLOBALS: ReadonlyArray = [ + 'window', + 'document', + 'Buffer', + '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__'; + +// 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; + +interface ChildImportResult { + readonly ok: boolean; + readonly errorMessage: string; + readonly errorStackFirstLine: 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-.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-.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 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. + * + * 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_FORBIDDEN_GLOBALS.map( + (key) => `delete globalThis['${key}'];`, + ).join(' '); + return [ + stripLines, + `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(' '); +} + +/** + * 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 { + 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)}`, + ); +} + +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(); + + 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_FORBIDDEN_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); + }); +});