feat(04-08): video-file MediaStream + sync-install/lazy-first-frame + explicit WAR — methodology reframe per debug session-2 + iter-2 BLOCKER fixes

Task 1 of Plan 04-08 (methodology reframe of ROADMAP SC #1):

- Bundle 1.9 MB VP9 WebM fixture at tests/uat/fixtures/synthetic-display-source.webm (copy of internal Plan 01-07 fixture; CC0-equivalent project-owned)
- Add globals.d.ts ambient `*.webm?url` module decl (mirrors Plan 01-10 `*.svg?url`)
- Add manifest.json web_accessible_resources entry for `assets/*.webm` (iter-2 BLOCKER 1 — pre-decided to avoid executor improvisation; inert in production where dist/ has zero *.webm)
- Rewrite installFakeDisplayMedia() at src/test-hooks/offscreen-hooks.ts:
  * Replace canvas.captureStream(30) with HTMLVideoElement.captureStream(30) — bypasses Chrome bug 653548 invisible-canvas throttling (debug session-2 root cause)
  * Function signature remains SYNCHRONOUS (`: void`; iter-2 BLOCKER 2 — eager-install contract preserved at lines 528-537)
  * Video element creation + DOM append + monkey-patch assignment execute synchronously
  * canplay wait + .play() deferred INTO fakeGetDisplayMedia closure (lazy first-frame pattern)
  * fakeVideoReadyPromise kicked off at install time so first call observes resolved Promise
  * WARNING 1 (autoplay reject): explicit error class identifier 'autoplay-blocked or codec-unsupported in headless context'
  * displaySurface monkey-patch preserved verbatim
  * A23 lastGetDisplayMediaConstraints capture preserved
  * uninstallFakeDisplayMedia teardown adapted for videoEl (pauses + removes + nulls)
  * All 6 bridge ops UNCHANGED in their sync return-false form
- Add Tier-2 production-bundle filename-leak gate at tests/background/no-test-hooks-in-prod-bundle.test.ts (iter-2 WARNING 5 — synthetic-display-source string must be 0 hits in dist/)

Verification:
- npx tsc --noEmit: exit 0
- npm run build: dist/ produced; 0 *.webm files; 0 synthetic-display-source hits
- npm run build:test: dist-test/assets/synthetic-display-source-mbtR1t3u.webm emitted (1.9 MB; Vite ?url asset)
- Code-only grep (comment-filtered) on offscreen-hooks.ts: 0 canvas refs; 15 video refs
- installFakeDisplayMedia signature unchanged: `: void` 2x; `: Promise` 0x; `await installFakeDisplayMedia` 0x
- Architectural invariant unchanged: `let segments: Blob[] = []` at recorder.ts:91 (1 hit; grep gate enforces)
- Tier-1 FORBIDDEN_HOOK_STRINGS unchanged at 12 entries
- Tier-2 vitest gate PASSES: 14/14 GREEN under SKIP_BUILD=1 (12 Tier-1 + 1 build verify + 1 Tier-2)

Per iter-3 checker advisory 1: the wrong-display-surface throw lives at recorder.ts:313-321 (not line 294 as plan text states; off by ~25 lines but unambiguous).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 10:33:04 +02:00
parent dd8a56453c
commit 81d9935b65
5 changed files with 261 additions and 90 deletions

13
globals.d.ts vendored
View File

@@ -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/<hash>.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;
}

View File

@@ -21,6 +21,10 @@
{
"resources": ["src/welcome/welcome.html"],
"matches": ["<all_urls>"]
},
{
"resources": ["assets/*.webm"],
"matches": ["<all_urls>"]
}
],
"background": {

View File

@@ -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/<hash>.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<typeof setInterval> | 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<void> | 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<void>((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/<hash>.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<MediaStream> => {
@@ -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.

View File

@@ -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/<hash>.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);
});
});

Binary file not shown.