// src/test-hooks/offscreen-hooks.ts — Plan 01-11 Task 2 (offscreen-side test hook). // // Installs `globalThis.__mokoshTest` in the offscreen document's isolate. // The harness reads this surface via `offPage.evaluate(...)` to: // - read `getCurrentStream().getVideoTracks()[0].getSettings().displaySurface` // (assertion 3 — verifies monitor-only enforcement); // - dispatch `new Event('ended')` on the video track (assertion 6 — // the Bug B simulation per RESEARCH §7 BLOCKER. `track.stop()` does // NOT fire 'ended' per W3C spec, so the production // onUserStoppedSharing handler would never run — that is the trap // this hook exists to expose); // - read `getSegmentCount()` (assertion 11 — verifies 30s ring buffer // per D-13). // // Plan 01-11 PROTOTYPE addition (synthetic MediaStream bypass + offscreen // bridge): the `installFakeDisplayMedia()` shim patches // `navigator.mediaDevices.getDisplayMedia` so the offscreen recorder's // `startRecording` path resolves WITHOUT spawning Chrome's screen-share // picker. The `__mokoshOffscreenQuery` chrome.runtime.onMessage bridge // allows the extension-internal harness page to invoke // installFakeDisplayMedia + dispatch 'ended' on the active track, // because page → offscreen direct evaluate is not available; the only // cross-isolate path is chrome.runtime.sendMessage. // // The offscreen recorder wires the runtime references via the two // setters exported below. These imports are gated by the same // `__MOKOSH_UAT__` token in src/offscreen/recorder.ts as the SW-side // hook; production builds tree-shake the entire module away (Tier-1 // grep gate verifies). // // Cross-isolate note: SW and offscreen are SEPARATE isolates with // SEPARATE `globalThis`. The SW-side sw-hooks.ts installs handler // captures + notification observability on the SW's globalThis; // this file installs stream + segment-count observability on the // offscreen's globalThis. The harness queries the appropriate isolate // per assertion. handlers / notificationCount / notificationIds / // lastNotificationOptions in this offscreen surface are present-but- // inert (initialized to empty values) to keep the type uniform across // isolates — the harness never reads them off the offscreen surface. // // 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 // - Offscreen document isolation in MV3: // https://developer.chrome.com/docs/extensions/reference/api/offscreen import type { MokoshTestSurface } from './types'; // 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; let segmentCountGetter: () => number = () => 0; /** * Wire the current MediaStream into the test surface. Called by * src/offscreen/recorder.ts:startRecording immediately after the * `mediaStream = stream` assignment AND on stream teardown (with * null) so the surface tracks the live recording lifecycle. * * Idempotent — calling with the same value is a no-op equivalent. * * @param stream - The active MediaStream, or null when teardown. */ export function setCurrentStream(stream: MediaStream | null): void { currentStream = stream; } /** * Wire the segment-count getter into the test surface. Called once * by src/offscreen/recorder.ts when the test bundle is active. The * recorder holds a `let segments: Blob[]` module-local; the getter * captures it by closure so the harness reads the live count without * needing to import the recorder's source from the harness side. * * @param getter - Closure returning the current segments.length. */ 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). // // The displaySurface override is the critical detail: production code // in src/offscreen/recorder.ts:294 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 // 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; /** * Plan 01-14 A23 contract — record the last-received constraints object * from every `fakeGetDisplayMedia` invocation. The `installFakeDisplayMedia` * shim already accepts the `constraints` parameter (previously prefixed * `_constraints` as received-but-unused — see below), so the production * call site's `monitorTypeSurfaces: 'include'` sibling lands here. The * `get-last-getDisplayMedia-constraints` bridge op (later in this file) * reads it for harness inspection. * * `null` until any `getDisplayMedia` call has been made — A23 always * runs AFTER A2's `setupFreshRecording` so the cell is populated by * then. Test bundle only; gated identically to the rest of this module * by the top-of-module `__MOKOSH_UAT__` import in src/offscreen/recorder.ts. */ let lastGetDisplayMediaConstraints: DisplayMediaStreamOptions | null = null; /** * Replace `navigator.mediaDevices.getDisplayMedia` with a synthetic * implementation backed by a hidden 30 fps canvas. The returned * MediaStream contains exactly one video track; the track's * `getSettings()` is monkey-patched to report `displaySurface: 'monitor'` * so the production code's post-grant monitor-only validation passes. * * SAFE to call multiple times — second and subsequent calls are no-ops. * * The fake stream behaves like a real getDisplayMedia result: * - track.kind === 'video' * - track.readyState === 'live' until stopped (or until 'ended' dispatched) * - track.addEventListener('ended', cb) works as expected * - track.dispatchEvent(new Event('ended')) fires registered listeners * — this is the Bug B simulation path per RESEARCH §7. * * Called from the harness via the `__mokoshOffscreenQuery` bridge * 'install-fake-display-media' op BEFORE triggering the production * recording-start flow. The patch persists for the lifetime of the * offscreen document. */ export function installFakeDisplayMedia(): void { if (fakeInstalled) { return; } 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. // // 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); /** * 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. * * @param stream - The stream whose first video track is patched in-place. */ const patchDisplaySurface = (stream: MediaStream): void => { const videoTrack = stream.getVideoTracks()[0]; if (videoTrack !== undefined) { const originalGetSettings = videoTrack.getSettings.bind(videoTrack); videoTrack.getSettings = ((): MediaTrackSettings => { const real = originalGetSettings(); return { ...real, displaySurface: 'monitor', }; }) as typeof videoTrack.getSettings; } }; /** * Mint a FRESH MediaStream from the persistent canvas. 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. * * @returns Fresh MediaStream with displaySurface monkey-patch applied * to its video track. */ const mintStream = (): MediaStream => { const stream = canvas.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. // // 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. const fakeGetDisplayMedia = async ( constraints?: DisplayMediaStreamOptions, ): Promise => { // Plan 01-14 A23: capture the production call's constraints object // so the harness can verify `monitorTypeSurfaces: 'include'` reached // 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; return mintStream(); }; (navigator.mediaDevices as unknown as { getDisplayMedia: typeof fakeGetDisplayMedia; }).getDisplayMedia = fakeGetDisplayMedia; } /** * Uninstall the fake getDisplayMedia. Used for cleanup between test * runs if multiple recordings need to start fresh. Not called by the * A6 prototype (single recording lifecycle). */ export function uninstallFakeDisplayMedia(): void { if (!fakeInstalled) { return; } fakeInstalled = false; if (fakeAnimationHandle !== null) { cancelAnimationFrame(fakeAnimationHandle); fakeAnimationHandle = null; } if (fakeDrawInterval !== null) { clearInterval(fakeDrawInterval); fakeDrawInterval = null; } if (fakeCanvas !== null) { fakeCanvas.remove(); fakeCanvas = null; } // We deliberately do NOT restore the original getDisplayMedia — the // offscreen document is throwaway and gets a fresh navigator on the // next createDocument() anyway. } /** * Dispatch a synthetic 'ended' event on the active stream's video * track. This is the Bug B simulation path per RESEARCH §7 BLOCKER — * `track.stop()` does NOT fire 'ended' per W3C spec; only * dispatchEvent does. * * Used by A6: the harness calls this after the recording is live; * the production `onUserStoppedSharing` handler fires; the SW state * machine routes through setIdleMode. * * @returns Result with ok status; ok=false when no current stream. */ export function dispatchEndedOnTrack(): { ok: boolean; error?: string } { if (currentStream === null) { return { ok: false, error: 'no current MediaStream — recording must be active', }; } const track = currentStream.getVideoTracks()[0]; if (track === undefined) { return { ok: false, error: 'no video track in stream' }; } track.dispatchEvent(new Event('ended')); return { ok: true }; } // ─── Install the global surface ─────────────────────────────────────── // Note: the offscreen isolate's globalThis is FRESH per offscreen // document creation (each createDocument restart resets it). The // gated dynamic import in recorder.ts top-of-module runs once per // offscreen lifetime, so each new offscreen document gets a fresh // surface install — there is no cross-lifetime contamination. // // Augment the surface with the installFakeDisplayMedia entrypoint so // the harness can invoke it via offPage.evaluate. The MokoshTestSurface // type widens to include this method via a cross-cast at install time // — keeping the type clean while still exposing the prototype hook. globalThis.__mokoshTest = { handlers: { onClicked: null, onStartup: null, notificationOnClicked: null, }, notificationCount: 0, lastNotificationOptions: null, get notificationIds() { return []; }, getCurrentStream: () => currentStream, getSegmentCount: () => segmentCountGetter(), installFakeDisplayMedia, uninstallFakeDisplayMedia, dispatchEndedOnTrack, } as MokoshTestSurface & { installFakeDisplayMedia: typeof installFakeDisplayMedia; uninstallFakeDisplayMedia: typeof uninstallFakeDisplayMedia; dispatchEndedOnTrack: typeof dispatchEndedOnTrack; }; // ─── Offscreen bridge: __mokoshOffscreenQuery ──────────────────────── // The extension-internal harness page cannot evaluate directly in the // offscreen isolate (separate globalThis; chrome.runtime.sendMessage // is the only cross-isolate path). So we register a dedicated // onMessage handler that responds to __mokoshOffscreenQuery messages // with the requested operation result. // // Protocol — page → offscreen message: // { type: '__mokoshOffscreenQuery', op: } // Response shapes (sync via sendResponse, return false): // op='install-fake-display-media' → { ok: true } OR { ok: false, error } // op='dispatch-ended' → { ok: true } OR { ok: false, error: 'no stream' } // op='has-stream' → { hasStream: boolean } // op='get-display-surface' → { displaySurface: string|null } OR { ok: false, error } // — Plan 01-13 Wave 3A A3 contract. Returns the active track's // `getSettings().displaySurface` value (monkey-patched to 'monitor' // by `installFakeDisplayMedia`); returns null when no stream is live. // op='get-segment-count' → { count: number } OR { count: -1, error } // — Plan 01-13 Wave 3D A11 contract. Returns the offscreen recorder's // live `segments.length` via the `segmentCountGetter` closure wired // at startRecording (see src/offscreen/recorder.ts:284). Before any // startRecording, the getter is the default `() => 0` from line 54 // above — A11 always calls this AFTER setupFreshRecording so a non- // zero count is meaningful. The 10s rotation cadence (D-13; // SEGMENT_DURATION_MS) means a recording that has been live for // ~35s should report count ≥ 3 (3 × 10s = 30s = MAX_SEGMENTS). // op='get-last-getDisplayMedia-constraints' → { constraints: object|null } // — Plan 01-14 A23 contract. Returns the most-recent constraints // object captured by `fakeGetDisplayMedia`. Used by the harness to // verify the production call site passes `monitorTypeSurfaces: 'include'` // (W3C Screen Capture spec §6.1; Chrome ≥ 119 picker-narrowing // semantics). `null` only when no getDisplayMedia call has happened // yet — A23 always runs AFTER A2's setupFreshRecording so a non-null // value is the expected case. // Unknown ops respond { ok: false, error: 'unknown-op' }. // // The bridge handler MUST run BEFORE the production offscreen bridge // installed at recorder.ts:838 — but offscreen-hooks runs at top-of- // module via the gated dynamic import (before bootstrap()), so this // ordering is satisfied by construction. // // IMPORTANT: chrome.runtime.onMessage dispatches to ALL registered // listeners; our handler returns false for non-matching message types // so the production handler still sees them. The production handler // also returns false for unknown types, so there is no two-way // contention. chrome.runtime.onMessage.addListener((rawMessage, _sender, sendResponse) => { if (rawMessage === null || typeof rawMessage !== 'object') { return false; } const message = rawMessage as { type?: unknown; op?: unknown }; if (message.type !== '__mokoshOffscreenQuery') { return false; } const op = String(message.op ?? ''); if (op === 'install-fake-display-media') { try { installFakeDisplayMedia(); sendResponse({ ok: true }); } catch (err) { sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err), }); } return false; } if (op === 'dispatch-ended') { try { const r = dispatchEndedOnTrack(); sendResponse(r); } catch (err) { sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err), }); } return false; } if (op === 'has-stream') { sendResponse({ hasStream: currentStream !== null }); return false; } if (op === 'get-display-surface') { // Plan 01-13 Wave 3A A3 contract — return the active track's // displaySurface so the harness can verify the offscreen-hooks // monkey-patched getSettings() correctly reports 'monitor'. // Production code in src/offscreen/recorder.ts enforces this same // invariant (tears down + throws 'wrong-display-surface' otherwise), // so if recording is live the value is guaranteed monitor — the // harness asserts === 'monitor' for explicit empirical verification. try { if (currentStream === null) { sendResponse({ displaySurface: null }); return false; } const track = currentStream.getVideoTracks()[0]; if (track === undefined) { sendResponse({ displaySurface: null }); return false; } const settings = track.getSettings(); const displaySurface = settings.displaySurface ?? null; sendResponse({ displaySurface }); } catch (err) { sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err), }); } return false; } if (op === 'get-last-getDisplayMedia-constraints') { // Plan 01-14 A23 contract — return the most-recent constraints object // captured by the `fakeGetDisplayMedia` shim. Production code at // src/offscreen/recorder.ts:270 calls `navigator.mediaDevices.getDisplayMedia({ // video: {...}, monitorTypeSurfaces: 'include', audio: false })`; this op // exposes that object so the harness can assert // `constraints.monitorTypeSurfaces === 'include'`. // // `null` is returned in two cases: // (a) No `getDisplayMedia` call has been made yet — A23 always runs // AFTER A2's setupFreshRecording so this is unreachable in the // orchestrated sequence; // (b) The call was made with `undefined` args — also unreachable for // the production call site which always supplies the constraints // object. // try/catch is defensive — bridge handlers must never propagate // exceptions to chrome.runtime.sendMessage (the dispatcher contract // shared by all ops above). try { sendResponse({ constraints: lastGetDisplayMediaConstraints }); } catch (err) { sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err), }); } return false; } if (op === 'get-segment-count') { // Plan 01-13 Wave 3D A11 contract — return the offscreen recorder's // live segment count via the `segmentCountGetter` closure wired at // startRecording (src/offscreen/recorder.ts:284). The closure // captures the recorder's module-local `segments: Blob[]` array, // which the rotation lifecycle (D-13; SEGMENT_DURATION_MS = 10s, // MAX_SEGMENTS = 3) populates with self-contained WebM segments. // After ~35s of continuous recording, A11 asserts count >= 3. // // The default getter (`() => 0`) at module load returns 0 — A11 // therefore MUST call this AFTER setupFreshRecording so the // recorder has wired the live getter. A pre-recording call would // legitimately return 0; the harness orders the assertion so this // failure mode is unreachable. // // -1 sentinel on error preserves the dispatcher contract (every // op returns a numeric `count` field on the happy path or -1 + // `error` on failure). A `try/catch` is defensive against a future // getter that throws (the closure-bound module-level array is a // pure read, so no throw is expected, but bridge handlers should // never propagate exceptions to chrome.runtime.sendMessage). try { sendResponse({ count: segmentCountGetter() }); } catch (err) { sendResponse({ count: -1, error: err instanceof Error ? err.message : String(err), }); } return false; } sendResponse({ ok: false, error: 'unknown-op' }); return false; }); // ─── Auto-install fake getDisplayMedia at module load ──────────────── // PROTOTYPE: install the fake getDisplayMedia eagerly so production // recorder.startRecording will use the synthetic stream on its first // call — no chicken-and-egg with the bridge install op. Wrap in a // try so any DOM-not-ready edge case does not block module init. try { installFakeDisplayMedia(); } catch (e) { console.warn("[offscreen-hooks] eager installFakeDisplayMedia failed:", e); } export {};