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:
2026-05-18 12:06:08 +02:00
parent f44ca3afba
commit c647f61553
10 changed files with 1239 additions and 23 deletions

View File

@@ -26,9 +26,6 @@ import JSZip from 'jszip';
// .test.ts` enforces that `__mokoshTest` is absent from every file
// under `dist/` post-build (T-1-11-01 — Elevation of Privilege via
// leaked hook surface).
if (__MOKOSH_UAT__) {
await import('../test-hooks/sw-hooks');
}
// Default MIME applied when a wire chunk somehow lacks a type
// field (defense-in-depth: in normal operation the offscreen recorder
@@ -937,4 +934,4 @@ chrome.runtime.onInstalled.addListener((details) => {
});
// Запуск при старте Service Worker
initialize();
initialize();

View File

@@ -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 {};