feat(01-13): wave-3B — A5+A6+A7 GREEN + Bug B canonical regression rewind
Wave 3B lands the A5 (SAVE_ARCHIVE → zip on disk) and A7 (genuine
RECORDING_ERROR → ERR + recovery notification) assertions, completing
8/14 of the orchestrator's GREEN floor (A0+A1+A2+A3+A4+A5+A6+A7).
Bails at A8 (Wave 3C scope).
Changes per file:
tests/uat/extension-page-harness.ts
- assertA5: 11s settle (>= SEGMENT_DURATION_MS so first rotation
lands a segment) + send SAVE_ARCHIVE + assert resp.success=true.
Page-side only checks SW handler ack; host-side driver verifies
disk-side outcome (zip presence + size floor).
- assertA7: setupFreshRecording helper (A6 tears down; A7 needs
REC state) → snapshot notif count → send RECORDING_ERROR with
a non-Bug-B error code ('codec-unsupported') → 200ms settle →
assert badge='ERR' + popup endsWith popup.html + notif delta=1
+ set-membership for 'mokosh-recovery-*' prefix.
- setupFreshRecording: shared helper for A7 + future assertions
that need a fresh REC state after a teardown.
tests/uat/lib/harness-page-driver.ts
- driveA5: page.evaluate(assertA5) THEN host-side fs polling for
*.zip in handles.downloadsDir. The CDP Browser.setDownloadBehavior
override renames the file to download.zip (data: URL filename
gap), so we accept any *.zip suffix. Merges page-side check +
host-side checks into a single AssertionRecord. Signature now
takes downloadsDir as a second arg.
- driveA7: standard page.evaluate wrapper (no host-side work).
tests/uat/harness.test.ts
- Wraps driveA5 in a closure that captures handles.downloadsDir.
- Reordered: launchHarnessBrowser MUST run before driver list so
the closure can read handles without a TDZ trap.
tests/uat/lib/launch.ts
- Victim page switched from about:blank to a file:// URL backed by
a tmp HTML file in downloadsDir. About:blank breaks A5 because
chrome.tabs.captureVisibleTab needs <all_urls> permission which
matches http/https/file/ftp but NOT about: or data: URLs. The
stub HTML satisfies <all_urls> + provides a real .url for the
production saveArchive's chrome.tabs.query.
src/test-hooks/offscreen-hooks.ts (test-only — tree-shaken from prod)
- installFakeDisplayMedia: mintStream() helper called per
fakeGetDisplayMedia invocation; each call mints a FRESH
MediaStream from the persistent canvas. Real getDisplayMedia
returns a new stream per call — fake now matches. Required for
A7's setupFreshRecording where the previous recording's stream
tracks were stopped by A6's onUserStoppedSharing teardown.
- Added 33ms setInterval-driven drawFrame() alongside the
existing requestAnimationFrame loop. RAF can throttle in
headless Chrome on offscreen documents (page-visibility
heuristics produce 0 fps), which yields zero-byte
MediaRecorder segments that crash ts-ebml's VINT decode in
webm-remux.extractFramesFromSegment with "Unrepresentable
length: Infinity". The setInterval is redundant when RAF fires
at full rate; it's a safety net for the headless-MV3 corner.
Bug B regression-catch demo (success_criteria #3 — MANDATORY per plan):
Step 1 — apply local regression patch (NOT committed):
src/background/index.ts:792 setIdleMode() → setErrorMode()
Step 2 — npm run build:test && npm run test:uat RED snippet:
A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: FAIL
[PASS] SETUP: badge becomes REC after start
[FAIL] A6.1: badge text is '' (NOT 'ERR') after user-stop
expected: ""
actual: "ERR"
[FAIL] A6.2: popup is '' (NOT manifest default) after user-stop
expected: ""
actual: "chrome-extension://<id>/src/popup/index.html"
[PASS] A6.3: NO recovery notification fired (count delta === 0)
[PASS] A6.4: isRecording=false (via badge proxy)
UAT harness: 6/14 assertions passed (bailed: A6 failed; see above)
Step 3 — revert local patch (git checkout -- src/background/index.ts).
Step 4 — npm run build:test && npm run test:uat GREEN snippet:
A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode: PASS
[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)
UAT harness: 8/14 assertions passed (bailed: A8 failed — NOT YET
IMPLEMENTED — Wave 3C wires driveA8)
The harness CORRECTLY catches the Bug B regression — the canonical
debug 01-09-recovery-flow scenario (operator-initiated stop routed
through setErrorMode locks the operator out of restart because popup
stays pinned to SAVE-only mode). Bug B is now CI-callable end-to-end.
vitest 93/93 GREEN throughout (unit-test layer unaffected). Tier-1
grep gate GREEN (9 forbidden hook strings: 0 occurrences in dist/).
npm run build exit 0; npx tsc --noEmit exit 0.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -97,6 +97,7 @@ export function setSegmentCountGetter(getter: () => number): void {
|
||||
let fakeInstalled = false;
|
||||
let fakeCanvas: HTMLCanvasElement | null = null;
|
||||
let fakeAnimationHandle: number | null = null;
|
||||
let fakeDrawInterval: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
/**
|
||||
* Replace `navigator.mediaDevices.getDisplayMedia` with a synthetic
|
||||
@@ -130,6 +131,16 @@ export function installFakeDisplayMedia(): void {
|
||||
// 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;
|
||||
@@ -159,36 +170,63 @@ export function installFakeDisplayMedia(): void {
|
||||
};
|
||||
drawFrame();
|
||||
|
||||
// captureStream(fps) — 30 fps is the production-typical frame rate.
|
||||
const stream = canvas.captureStream(30);
|
||||
// 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);
|
||||
|
||||
// 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;
|
||||
}
|
||||
/**
|
||||
* 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 returns the synthetic stream. Production code's `await
|
||||
// navigator.mediaDevices.getDisplayMedia(...)` resolves with this
|
||||
// stream immediately — no picker.
|
||||
// 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
|
||||
@@ -197,7 +235,7 @@ export function installFakeDisplayMedia(): void {
|
||||
const fakeGetDisplayMedia = async (
|
||||
_constraints?: DisplayMediaStreamOptions,
|
||||
): Promise<MediaStream> => {
|
||||
return stream;
|
||||
return mintStream();
|
||||
};
|
||||
(navigator.mediaDevices as unknown as {
|
||||
getDisplayMedia: typeof fakeGetDisplayMedia;
|
||||
@@ -218,6 +256,10 @@ export function uninstallFakeDisplayMedia(): void {
|
||||
cancelAnimationFrame(fakeAnimationHandle);
|
||||
fakeAnimationHandle = null;
|
||||
}
|
||||
if (fakeDrawInterval !== null) {
|
||||
clearInterval(fakeDrawInterval);
|
||||
fakeDrawInterval = null;
|
||||
}
|
||||
if (fakeCanvas !== null) {
|
||||
fakeCanvas.remove();
|
||||
fakeCanvas = null;
|
||||
|
||||
Reference in New Issue
Block a user