wip(01-11): prototype — A6 via test-page+bridge+synthetic-stream PASSES
Plan 01-11 orchestrator commissioned a research+prototype investigation
into whether full MV3 UAT automation is feasible with the architecture:
extension-internal test page + chrome.runtime.sendMessage bridge +
synthetic MediaStream (canvas-captureStream + getSettings override).
EMPIRICAL VERDICT: feasible BUT plan 01-11 needs architectural revision.
Architectural findings (with proof):
1. DYNAMIC IMPORT BLOCKED IN MV3 SW. Top-of-module
`await import('../test-hooks/sw-hooks')` in src/background/index.ts
silently kills the SW (chunk loads, await never resolves, no
production listeners register, no console output). This is by design
per Chromium docs (es_modules.md) + w3c/webextensions#212. The Plan
01-11 RESEARCH §6 architecture was wrong for the SW side.
Workaround in this prototype: REMOVE the SW-side gated dynamic
import. SW-side test hooks need a different design (see verdict).
2. OFFSCREEN-SIDE DYNAMIC IMPORT WORKS. Offscreen is a DOM document,
not a SW, so top-level await + dynamic import behave normally. The
offscreen-hooks.ts gated import succeeds; installFakeDisplayMedia is
installed eagerly at module load.
3. EXTENSION-INTERNAL PAGE HAS FULL chrome.* SURFACE. Reachable via
chrome-extension://<id>/tests/uat/prototype/extension-page-harness.html
(added as rollup input in vite.test.config.ts). The page can call
chrome.action.getBadgeText, chrome.action.getPopup, chrome.offscreen
.createDocument, chrome.notifications.getAll, chrome.runtime
.sendMessage — everything needed for A6.
4. NO 'tabs' PERMISSION → tab.url IS UNDEFINED. Production
startVideoCapture's `chrome.tabs.query({active:true})` check
(`if (!tab.id || !tab.url) throw`) fails because the manifest lacks
the 'tabs' permission. Prototype workaround: bypass startVideoCapture
by sending START_RECORDING directly to offscreen. The Bug B
contract being tested is independent of how recording starts; it
only depends on the RECORDING_ERROR routing path.
5. SYNTHETIC MEDIASTREAM WORKS. installFakeDisplayMedia builds a
canvas-captureStream MediaStream + monkey-patches the video track's
getSettings() to report displaySurface: 'monitor'. Production code's
post-grant validation passes. getDisplayMedia returns the synthetic
stream immediately — no picker, no headless flakiness.
A6 prototype result (with Bug B fix in place — current HEAD state):
[PASS] SETUP: badge becomes REC after start
[PASS] A6.1: badge text is '' (NOT 'ERR') after user-stop
[PASS] A6.2: popup is '' (NOT manifest default) after user-stop
[PASS] A6.3: NO recovery notification fired (count delta === 0)
[PASS] A6.4: isRecording=false (via badge proxy)
A6 prototype result (with Bug B fix rewound to `if (false)`):
[PASS] SETUP: badge becomes REC after start
[FAIL] A6.1: badge text is '' (got "ERR")
[FAIL] A6.2: popup is '' (got chrome-extension://.../popup/index.html)
[FAIL] A6.3: notif delta = 0 (got 1)
[PASS] A6.4: isRecording=false ← false-positive (badge='ERR' not 'REC')
The Bug B regression rewind cycle proves the harness CAN catch regression:
4/5 checks turn RED on rewind, 5/5 turn GREEN with the fix restored.
Files in this commit:
- tests/uat/prototype/extension-page-harness.{html,ts} — the harness
page (chrome-extension URL, exposes window.__mokoshHarness.assertA6)
- tests/uat/prototype/a6.test.ts — Puppeteer driver (~270 lines)
- tests/uat/prototype/probe_*.mjs — diagnostic probes used to isolate
the SW dynamic-import blocker (probe_sw.mjs is the key one)
- src/test-hooks/offscreen-hooks.ts — added installFakeDisplayMedia +
dispatchEndedOnTrack + __mokoshOffscreenQuery bridge handler + auto-
install at module load
- vite.test.config.ts — added prototype harness page as rollup input;
added modulePreload.polyfill=false (red herring; harmless)
- src/background/index.ts — removed the broken SW-side gated dynamic
import (this is the BLOCKER unblocker — production 01-11 plan needs
to redesign SW-side test hooks before re-spawning)
Bundle hygiene: prototype runs against dist-test/; production dist/
remains hook-free (Tier-1 grep gate still GREEN, verified via
no-test-hooks-in-prod-bundle.test.ts in the unit test suite).
Vitest baseline: 89/89 GREEN preserved.
Runtime: ~7 seconds end-to-end (launch Chrome + open page + ensure
offscreen + start recording + dispatch ended + settle + assert).
See: research return for VERDICT + recommended next step.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -12,11 +12,21 @@
|
||||
// - 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
|
||||
// `import.meta.env.MODE === 'test'` literal-comparison guard in
|
||||
// src/offscreen/recorder.ts as the SW-side hook; production builds
|
||||
// tree-shake the entire module away (Tier-1 grep gate verifies).
|
||||
// `__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
|
||||
@@ -28,10 +38,13 @@
|
||||
// inert (initialized to empty values) to keep the type uniform across
|
||||
// isolates — the harness never reads them off the offscreen surface.
|
||||
//
|
||||
// Reference for MediaStreamTrack 'ended' event:
|
||||
// https://developer.mozilla.org/docs/Web/API/MediaStreamTrack/ended_event
|
||||
// Reference for offscreen document isolation in MV3:
|
||||
// https://developer.chrome.com/docs/extensions/reference/api/offscreen
|
||||
// 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';
|
||||
|
||||
@@ -67,12 +80,191 @@ 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;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
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();
|
||||
|
||||
// captureStream(fps) — 30 fps is the production-typical frame rate.
|
||||
const stream = canvas.captureStream(30);
|
||||
|
||||
// Monkey-patch the video track's getSettings() to report
|
||||
// displaySurface: 'monitor' so the production post-grant validation
|
||||
// passes. We patch on the instance (track) — settings live there,
|
||||
// not on the prototype.
|
||||
const videoTrack = stream.getVideoTracks()[0];
|
||||
if (videoTrack !== undefined) {
|
||||
const originalGetSettings = videoTrack.getSettings.bind(videoTrack);
|
||||
/**
|
||||
* Wrap getSettings to inject a displaySurface override. The wrapper
|
||||
* preserves all other settings the canvas captureStream provides
|
||||
* (width, height, frameRate, deviceId, etc.).
|
||||
*
|
||||
* @returns Settings dict augmented with displaySurface: 'monitor'.
|
||||
*/
|
||||
videoTrack.getSettings = ((): MediaTrackSettings => {
|
||||
const real = originalGetSettings();
|
||||
return {
|
||||
...real,
|
||||
displaySurface: 'monitor',
|
||||
};
|
||||
}) as typeof videoTrack.getSettings;
|
||||
}
|
||||
|
||||
// Replace navigator.mediaDevices.getDisplayMedia with a function
|
||||
// that returns the synthetic stream. Production code's `await
|
||||
// navigator.mediaDevices.getDisplayMedia(...)` resolves with this
|
||||
// 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<MediaStream> => {
|
||||
return stream;
|
||||
};
|
||||
(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 (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,
|
||||
@@ -86,6 +278,91 @@ globalThis.__mokoshTest = {
|
||||
},
|
||||
getCurrentStream: () => currentStream,
|
||||
getSegmentCount: () => segmentCountGetter(),
|
||||
} as MokoshTestSurface;
|
||||
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: <string> }
|
||||
// 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 }
|
||||
// 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;
|
||||
}
|
||||
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 {};
|
||||
|
||||
Reference in New Issue
Block a user