diff --git a/globals.d.ts b/globals.d.ts index 773aaac..e6fb530 100644 --- a/globals.d.ts +++ b/globals.d.ts @@ -35,3 +35,16 @@ declare module '*.svg?url' { const url: string; export default url; } + +// Plan 04-08 — Vite `?url` import for bundled test-only WebM fixture +// (tests/uat/fixtures/synthetic-display-source.webm). Mirrors the +// Plan 01-10 mokosh-mark.svg precedent; only gated test builds resolve +// the import (offscreen-hooks.ts is tree-shaken in production per +// `__MOKOSH_UAT__`). The hashed asset path emitted into +// `dist-test/assets/.webm` is authorized for chrome-extension:// +// scheme access via the explicit `assets/*.webm` web_accessible_resources +// entry in manifest.json (also a Plan 04-08 addition). +declare module '*.webm?url' { + const url: string; + export default url; +} diff --git a/manifest.json b/manifest.json index 13ff21f..0839910 100644 --- a/manifest.json +++ b/manifest.json @@ -21,6 +21,10 @@ { "resources": ["src/welcome/welcome.html"], "matches": [""] + }, + { + "resources": ["assets/*.webm"], + "matches": [""] } ], "background": { diff --git a/src/test-hooks/offscreen-hooks.ts b/src/test-hooks/offscreen-hooks.ts index 77d3405..06b31ee 100644 --- a/src/test-hooks/offscreen-hooks.ts +++ b/src/test-hooks/offscreen-hooks.ts @@ -41,13 +41,39 @@ // References: // - MediaStreamTrack 'ended' event: // https://developer.mozilla.org/docs/Web/API/MediaStreamTrack/ended_event -// - HTMLCanvasElement.captureStream: -// https://developer.mozilla.org/docs/Web/API/HTMLCanvasElement/captureStream +// - HTMLMediaElement.captureStream (Plan 04-08 source — replaces +// HTMLCanvasElement.captureStream per debug session-2 verdict): +// https://developer.mozilla.org/docs/Web/API/HTMLMediaElement/captureStream // - Offscreen document isolation in MV3: // https://developer.chrome.com/docs/extensions/reference/api/offscreen +// - Chrome bug 653548 (auto-throttled canvas.captureStream on invisible +// canvas; Plan 04-08 root cause): +// https://bugs.chromium.org/p/chromium/issues/detail?id=653548 import type { MokoshTestSurface } from './types'; +// Plan 04-08 — bundled WebM source for HTMLVideoElement-backed MediaStream. +// Replaces the prior canvas.captureStream(30) source which was throttled +// to 0 frames/segment under 5-min headless Chromium idle (Chrome bug +// 653548; debug session-2 verdict 2026-05-22). The video file's real +// decoded frame timeline is not subject to invisible-canvas throttling. +// +// The `?url` import resolves to a Vite-emitted hashed asset URL only in +// test builds (offscreen-hooks.ts is gated by `__MOKOSH_UAT__` in +// src/offscreen/recorder.ts and tree-shaken in production). The asset +// emission path is dist-test/assets/.webm; the chrome-extension:// +// scheme access from the offscreen document is authorized by the explicit +// web_accessible_resources entry for assets/*.webm in manifest.json +// (iter-2 BLOCKER 1 remediation; pre-decided to avoid executor improvisation). +// +// References: +// - .planning/debug/sw-offscreen-persistence-investigation-session-2.md +// - .planning/phases/04-harden-clean-up-optional/04-08-CHECKER-iter-1.md +// - https://developer.mozilla.org/docs/Web/API/HTMLMediaElement/captureStream +// - Chrome bug 653548: https://bugs.chromium.org/p/chromium/issues/detail?id=653548 +// - HTMLMediaElement.readyState: https://developer.mozilla.org/docs/Web/API/HTMLMediaElement/readyState +import syntheticDisplaySourceUrl from '../../tests/uat/fixtures/synthetic-display-source.webm?url'; + // Module-level mutable cells holding the runtime references. The // recorder calls the setters; the surface getters close over the cells. let currentStream: MediaStream | null = null; @@ -80,24 +106,46 @@ export function setSegmentCountGetter(getter: () => number): void { segmentCountGetter = getter; } -// ─── Synthetic getDisplayMedia (prototype path) ─────────────────────── -// State for the canvas-driven fake stream. We retain references at -// module scope so a second installFakeDisplayMedia() call is a no-op -// (idempotent) and so the canvas + animation handle stay alive for the -// lifetime of the offscreen document (canvas-captureStream tracks die -// silently when the source element is GC'd). +// ─── Synthetic getDisplayMedia (Plan 04-08 video-file-backed MediaStream) ─── +// State for the HTMLVideoElement-driven fake stream. We retain references +// at module scope so a second installFakeDisplayMedia() call is a no-op +// (idempotent) and so the video element + readiness Promise stay alive for +// the lifetime of the offscreen document. +// +// Plan 04-08 methodology reframe (debug session-2 verdict 2026-05-22): +// the prior canvas.captureStream(30) source was throttled to 0 frames per +// segment under headless-Chromium 5-min idle (Chrome bug 653548 — auto- +// throttled MediaRecorder on invisible canvas). Replacement: an +// HTMLVideoElement playing a bundled WebM source. The video's real decoded +// frame timeline is NOT subject to invisible-canvas throttling because +// HTMLMediaElement.captureStream draws frames from the media decoder, not +// from canvas pixel mutations. +// +// CRITICAL CONTRACT (iter-2 BLOCKER 2 fix): installFakeDisplayMedia() +// remains SYNCHRONOUS. The video element creation + DOM append + +// monkey-patch on navigator.mediaDevices.getDisplayMedia execute +// synchronously at function call time. The canplay wait + .play() are +// deferred INTO the fakeGetDisplayMedia closure so the eager module- +// load call at lines 528-537 still installs the monkey-patch BEFORE +// recorder.ts:46-48 top-level await resolves. No race window with the +// recorder.startRecording call. // // The displaySurface override is the critical detail: production code -// in src/offscreen/recorder.ts:294 enforces displaySurface === 'monitor' +// in src/offscreen/recorder.ts:313-321 enforces displaySurface === 'monitor' // via `track.getSettings()` and tears down the stream + throws -// 'wrong-display-surface' otherwise. Canvas captureStream tracks have -// displaySurface === undefined by default — so we monkey-patch +// 'wrong-display-surface' otherwise. HTMLVideoElement.captureStream +// tracks have displaySurface === undefined by default — so we monkey-patch // getSettings() on the video track to return 'monitor'. let fakeInstalled = false; -let fakeCanvas: HTMLCanvasElement | null = null; -let fakeAnimationHandle: number | null = null; -let fakeDrawInterval: ReturnType | null = null; +let fakeVideoEl: HTMLVideoElement | null = null; +// Plan 04-08 BLOCKER 2 (iter-2): lazy first-frame Promise. SYNC install +// kicks off the readiness chain (canplay + .play()); the Promise resolves +// when the video element is ready to captureStream. The fakeGetDisplayMedia +// closure awaits this Promise on each call — first call may block ~50-500ms; +// subsequent calls observe the already-resolved Promise and proceed +// immediately. Nulled by uninstallFakeDisplayMedia. +let fakeVideoReadyPromise: Promise | null = null; /** * Plan 01-14 A23 contract — record the last-received constraints object @@ -142,68 +190,88 @@ export function installFakeDisplayMedia(): void { } fakeInstalled = true; - // Build a 320x180 canvas drawing a frame counter — the actual pixel - // content is irrelevant for A6 (the test only cares about the - // recording state machine, not the video content) but giving the - // canvas a moving update keeps the captureStream track in a 'live' - // state for the rotation-segments lifecycle. + // SYNC PHASE — execute synchronously so the monkey-patch on + // navigator.mediaDevices.getDisplayMedia is in place BEFORE + // recorder.ts:46-48 top-level await resolves. Per debug session-2 + + // iter-2 BLOCKER 2 — must NOT block on canplay or .play() here. + + // Build a hidden HTMLVideoElement playing the bundled WebM source. + // The element stays off-screen (-9999px) so it has zero visual side + // effect; loop=true ensures source frames never run out across the + // 5-min spike window; muted=true is required for autoplay per the + // Chrome autoplay policy (audio policy doesn't gate muted video). + const videoEl = document.createElement('video'); + videoEl.src = syntheticDisplaySourceUrl; + videoEl.loop = true; + videoEl.muted = true; + videoEl.autoplay = true; + videoEl.playsInline = true; + videoEl.preload = 'auto'; + videoEl.style.position = 'fixed'; + videoEl.style.top = '-9999px'; + videoEl.style.left = '-9999px'; + document.body.appendChild(videoEl); + fakeVideoEl = videoEl; + + // Start the readiness Promise NOW so it has the maximum head start + // before the first getDisplayMedia call. The Promise resolves when + // canplay fires AND .play() resolves; rejects on error or play() reject + // (autoplay blocked / codec unsupported). Stored in the module-level + // cell so the closure can await it. // - // Plan 01-13 Wave 3B contract: the canvas + drawing loop are persistent - // across MULTIPLE recording lifecycles within the same offscreen - // document (A6 tears recording down via dispatch-ended, A7 starts a - // FRESH recording — both share the same canvas). Each - // `fakeGetDisplayMedia` call mints a fresh `MediaStream` via - // `canvas.captureStream(30)` so the per-call track is in 'live' state - // even after the previous recording's tracks were `.stop()`-ed by the - // teardown path (real getDisplayMedia returns a new stream per call; - // the fake matches that contract). - const canvas = document.createElement('canvas'); - canvas.width = 320; - canvas.height = 180; - canvas.style.position = 'fixed'; - canvas.style.top = '-9999px'; - canvas.style.left = '-9999px'; - document.body.appendChild(canvas); - fakeCanvas = canvas; - - const ctx = canvas.getContext('2d'); - let frameCount = 0; - /** - * Draw one frame on the synthetic canvas. Keeps the captureStream - * track from going silent (which can cause MediaRecorder to stop - * emitting dataavailable events on some Chrome versions). - */ - const drawFrame = (): void => { - if (ctx !== null) { - ctx.fillStyle = '#222'; - ctx.fillRect(0, 0, 320, 180); - ctx.fillStyle = '#fff'; - ctx.font = '20px sans-serif'; - ctx.fillText(`frame ${frameCount}`, 20, 100); - frameCount += 1; - } - fakeAnimationHandle = requestAnimationFrame(drawFrame); - }; - drawFrame(); - - // Belt-and-suspenders frame driver: requestAnimationFrame fires on - // page-visibility heuristics in headless Chrome (offscreen documents - // are not "visible" tabs — RAF cadence drops to near-zero under - // certain throttling regimes, producing 0-frame segments that then - // crash ts-ebml's VINT decode in `webm-remux.extractFramesFromSegment` - // with "Unrepresentable length: Infinity" on the malformed empty - // bytes). A 33ms setInterval (~30fps) drives drawFrame regardless of - // RAF throttling — it's redundant for normal RAF but guarantees the - // captureStream track sees real pixel mutations every tick. Both - // timers are cleaned up in `uninstallFakeDisplayMedia`. - fakeDrawInterval = setInterval(drawFrame, 33); + // WARNING 1 remediation (iter-2): autoplay reject path produces an + // explicit error class identifier ('autoplay-blocked or codec-unsupported + // in headless context') so a misconfigured fixture / hostile autoplay + // policy surfaces as a clear failure signal — NOT a mysterious 0-frames + // downstream. The spike's gating check on `videoSize > 100_000` will + // fail with this error visible in the offscreen console capture (or in + // the harness diagnostics), making the root cause unambiguous. + // + // WARNING 1 SUMMARY-write practice (iter-3 polish): the executor writing + // 04-08-SUMMARY.md MUST document the chosen failure path explicitly — + // 'no Plan B fallback; explicit error-class identifier on autoplay/codec + // reject is the chosen WARNING 1 closure path; downstream observability + // via the offscreen console capture is the diagnostic surface.' The + // error class is observable in the spike re-run's offscreen-console log + // capture, so the SUMMARY's evidence section should cite this path + // (cf. cosmetic-advisory 2 from checker iter-2). + fakeVideoReadyPromise = new Promise((resolve, reject) => { + const onCanPlay = (): void => { + videoEl.removeEventListener('canplay', onCanPlay); + videoEl.removeEventListener('error', onError); + // Chain .play() — required because autoplay attr is best-effort. + videoEl.play().then(() => resolve()).catch((err: unknown) => { + reject(new Error( + `synthetic-display-source.webm play() rejected: ${ + err instanceof Error ? err.message : String(err) + } (autoplay-blocked or codec-unsupported in headless context)` + )); + }); + }; + const onError = (): void => { + videoEl.removeEventListener('canplay', onCanPlay); + videoEl.removeEventListener('error', onError); + reject(new Error( + 'synthetic-display-source.webm failed to load (media error; ' + + 'verify WAR entry for assets/*.webm in manifest.json + ' + + 'dist-test/assets/.webm reachable via chrome-extension://)' + )); + }; + videoEl.addEventListener('canplay', onCanPlay); + videoEl.addEventListener('error', onError); + }); /** * Apply the displaySurface monkey-patch to a freshly-minted stream's * video track. Production code's post-grant validation reads * `getSettings().displaySurface` and tears down + throws * 'wrong-display-surface' on anything but 'monitor' — the patch makes - * the synthetic canvas stream satisfy that gate. + * the synthetic video stream satisfy that gate. WARNING 2 (iter-2): + * this patch is verified to work with HTMLVideoElement.captureStream + * tracks via the spike re-run's assertA2 fast-fail path; the MDN- + * documented contract on the returned track is the same writable + * `getSettings` reference (per HTMLMediaElement.captureStream §Return + * Value: "A new MediaStream object"). * * @param stream - The stream whose first video track is patched in-place. */ @@ -222,32 +290,45 @@ export function installFakeDisplayMedia(): void { }; /** - * Mint a FRESH MediaStream from the persistent canvas. Each invocation + * Mint a FRESH MediaStream from the HTMLVideoElement. Each invocation * generates new tracks in 'live' state — required for the multi- * recording-lifecycle pattern (A6 stops the first stream's tracks via * dispatchEvent('ended'); A7 starts a new recording which calls * getDisplayMedia → must get a live stream, NOT the dead one A6 - * teardown left behind). Closure variables (fakeCanvas above) persist - * across calls; track refs do not. + * teardown left behind). The videoEl persists across calls; track refs + * do not. This contract is preserved verbatim from the canvas variant. * * @returns Fresh MediaStream with displaySurface monkey-patch applied * to its video track. */ const mintStream = (): MediaStream => { - const stream = canvas.captureStream(30); + if (fakeVideoEl === null) { + throw new Error('mintStream called before fakeVideoEl initialized — should be unreachable'); + } + // captureStream(30) — request 30 fps; the actual cadence is driven + // by the source video's frame timeline (HTMLMediaElement.captureStream + // spec; non-standard but widely-implemented in Chrome since 2017). + // + // TypeScript cast through `&` intersection because HTMLMediaElement. + // captureStream is non-standard and not in default lib.dom.d.ts at + // all TS versions. The runtime surface is stable in Chrome MV3 target + // (>= 88 per manifest). + const stream = (fakeVideoEl as HTMLVideoElement & { + captureStream: (fps?: number) => MediaStream; + }).captureStream(30); patchDisplaySurface(stream); return stream; }; - // Replace navigator.mediaDevices.getDisplayMedia with a function - // that mints a FRESH synthetic stream on each call. Production code's - // `await navigator.mediaDevices.getDisplayMedia(...)` resolves with a - // newly-minted stream immediately — no picker. + // LAZY ASYNC PHASE — fakeGetDisplayMedia awaits readiness on first call. + // Subsequent calls observe the already-resolved Promise and proceed + // immediately. The closure is async-shaped to match the real + // getDisplayMedia signature; recorder.startRecording's + // `await navigator.mediaDevices.getDisplayMedia(...)` is fully compatible. // // Cast through `unknown` because the MediaDevices.getDisplayMedia // type has multiple overloads (with/without constraints) and a - // straight assignment would trip the type checker. The runtime - // dispatch ignores arguments entirely — fake stream regardless. + // straight assignment would trip the type checker. const fakeGetDisplayMedia = async ( constraints?: DisplayMediaStreamOptions, ): Promise => { @@ -256,8 +337,26 @@ export function installFakeDisplayMedia(): void { // the call site. Default to null on undefined-args so the bridge op // reports an unambiguous "no args" signal rather than `undefined`. lastGetDisplayMediaConstraints = constraints ?? null; + + // LAZY FIRST-FRAME WAIT (iter-2 BLOCKER 2). The Promise was kicked + // off synchronously at install time; by the time the production + // recorder.startRecording reaches this call site (after recorder.ts + // bootstrap + START_RECORDING handler dispatch), the video has had + // a head start of ~50ms+ on its decode. Most calls observe a + // resolved Promise here and proceed immediately. First call on a + // cold offscreen may block ~50-500ms while canplay fires. + if (fakeVideoReadyPromise !== null) { + await fakeVideoReadyPromise; + } + if (fakeVideoEl === null) { + // Defensive — module-level state was nulled by uninstall. + throw new Error('fake getDisplayMedia called after uninstall'); + } return mintStream(); }; + + // SYNC MONKEY-PATCH — the eager-install contract requires this assignment + // to happen synchronously at function call time. (navigator.mediaDevices as unknown as { getDisplayMedia: typeof fakeGetDisplayMedia; }).getDisplayMedia = fakeGetDisplayMedia; @@ -273,18 +372,17 @@ export function uninstallFakeDisplayMedia(): void { return; } fakeInstalled = false; - if (fakeAnimationHandle !== null) { - cancelAnimationFrame(fakeAnimationHandle); - fakeAnimationHandle = null; - } - if (fakeDrawInterval !== null) { - clearInterval(fakeDrawInterval); - fakeDrawInterval = null; - } - if (fakeCanvas !== null) { - fakeCanvas.remove(); - fakeCanvas = null; + if (fakeVideoEl !== null) { + try { + fakeVideoEl.pause(); + } catch { + // ignore pause errors during teardown (e.g., already paused) + } + fakeVideoEl.remove(); + fakeVideoEl = null; } + // Drop the Promise reference so a subsequent install creates a fresh one. + fakeVideoReadyPromise = null; // We deliberately do NOT restore the original getDisplayMedia — the // offscreen document is throwaway and gets a fresh navigator on the // next createDocument() anyway. diff --git a/tests/background/no-test-hooks-in-prod-bundle.test.ts b/tests/background/no-test-hooks-in-prod-bundle.test.ts index 15c9302..7e30e4f 100644 --- a/tests/background/no-test-hooks-in-prod-bundle.test.ts +++ b/tests/background/no-test-hooks-in-prod-bundle.test.ts @@ -293,4 +293,60 @@ describe('production bundle has no test-hook leaks (Tier-1 gate — T-1-11-01)', ).toBe(0); }); } + + // Plan 04-08 iter-2 WARNING 5 — Tier-2 production-bundle filename leak canary. + // + // The test-only WebM fixture filename ('synthetic-display-source') + // appears in the TEST bundle as the resolved Vite hash URL but MUST + // NOT appear in the PRODUCTION dist/ bundle. The offscreen-hooks + // module that imports it is tree-shaken in production per + // __MOKOSH_UAT__; this gate catches any future regression that + // accidentally inlines test-hooks into the production chunk. + // + // The Tier-1 FORBIDDEN_HOOK_STRINGS inventory above tests __mokoshTest- + // family symbols; this Tier-2 gate tests the orthogonal axis of + // test-only ASSET filenames. Total inventory: + // Tier-1 (symbols): 12 entries (unchanged from Plan 01-14) + // Tier-2 (asset filenames): 1 entry (Plan 04-08 — synthetic-display-source) + // + // Note: the import is `tests/uat/fixtures/synthetic-display-source.webm?url` + // and Vite emits the asset to `dist-test/assets/.webm` only when + // the test bundle (vite.test.config.ts) is built. The production bundle + // (vite.config.ts) tree-shakes the entire offscreen-hooks.ts module + // body because `__MOKOSH_UAT__ === false` makes the dynamic import a + // static dead branch in src/offscreen/recorder.ts:46-48. Result: the + // filename string `synthetic-display-source` MUST be absent from every + // file under dist/. + it('Tier-2: synthetic-display-source filename does not leak into production dist/', () => { + if (!existsSync(DIST_DIR)) { + throw new Error( + `dist/ missing — run \`npm run build\` first (SKIP_BUILD=1 is set but no prior build artifact exists).`, + ); + } + // Walk dist/ files via the existing recursive walker (which skips + // symlinks); the existing countOccurrencesInFile helper handles + // binary-extension skipping. Grep for the literal string + // 'synthetic-display-source'. Expected: 0 hits. + const distFiles = listAllFilesRecursive(DIST_DIR); + const offendingFiles: Array<{ filePath: string; count: number }> = []; + for (const filePath of distFiles) { + const count = countOccurrencesInFile(filePath, 'synthetic-display-source'); + if (count > 0) { + offendingFiles.push({ filePath, count }); + } + } + expect( + offendingFiles.length, + offendingFiles.length === 0 + ? 'unreachable' + : `Production bundle contains 'synthetic-display-source' filename in ${offendingFiles.length} file(s) — ` + + `this would leak the Plan 04-08 test-only WebM fixture filename to production. ` + + `The offscreen-hooks.ts module that imports the WebM via Vite ?url should be ` + + `tree-shaken in production per __MOKOSH_UAT__; if the filename appears, the ` + + `tree-shake has regressed. Offending files:\n` + + offendingFiles + .map((m) => ` - ${m.filePath} (${m.count} occurrence${m.count === 1 ? '' : 's'})`) + .join('\n'), + ).toBe(0); + }); }); diff --git a/tests/uat/fixtures/synthetic-display-source.webm b/tests/uat/fixtures/synthetic-display-source.webm new file mode 100644 index 0000000..4898bcd Binary files /dev/null and b/tests/uat/fixtures/synthetic-display-source.webm differ