From 6a77967b6cc89227ff445c398bd6fa3209a34f0a Mon Sep 17 00:00:00 2001 From: Mark Date: Mon, 18 May 2026 17:01:06 +0200 Subject: [PATCH] =?UTF-8?q?feat(01-13):=20wave-3B=20=E2=80=94=20A5+A6+A7?= =?UTF-8?q?=20GREEN=20+=20Bug=20B=20canonical=20regression=20rewind?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 permission which matches http/https/file/ftp but NOT about: or data: URLs. The stub HTML satisfies + 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:///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) --- src/test-hooks/offscreen-hooks.ts | 98 ++++++--- tests/uat/extension-page-harness.ts | 303 ++++++++++++++++++++++++++- tests/uat/harness.test.ts | 47 +++-- tests/uat/lib/harness-page-driver.ts | 182 +++++++++++++++- tests/uat/lib/launch.ts | 44 +++- 5 files changed, 609 insertions(+), 65 deletions(-) diff --git a/src/test-hooks/offscreen-hooks.ts b/src/test-hooks/offscreen-hooks.ts index c0c7a90..04ae616 100644 --- a/src/test-hooks/offscreen-hooks.ts +++ b/src/test-hooks/offscreen-hooks.ts @@ -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 | 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 => { - 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; diff --git a/tests/uat/extension-page-harness.ts b/tests/uat/extension-page-harness.ts index 4b7b366..54ed542 100644 --- a/tests/uat/extension-page-harness.ts +++ b/tests/uat/extension-page-harness.ts @@ -64,6 +64,26 @@ // preserves setRecordingMode's setPopup; offscreen // count remains 1 — no second offscreen spawns). // - `assertA6()` — canonical Bug B regression assertion (proven). +// +// Wave 3B surface — extends `window.__mokoshHarness` from 5 → 7 methods: +// - `assertA5()` — SAVE_ARCHIVE dispatches; the page-side check confirms +// the SW handler returns success. The host-side driveA5 +// wrapper handles disk-side verification (zip file +// presence + size > 1KB) since the page cannot read +// `handles.downloadsDir`. Page-side method returns +// `{passed, name, checks: [SW dispatch ack], diagnostics}`; +// host-side merges its own checks on top. +// - `assertA7()` — genuine recording error → ERR badge + recovery +// notification. Sends a synthetic +// `RECORDING_ERROR{error:'codec-unsupported'}` (NOT +// 'user-stopped-sharing' — that's Bug B's branch). +// Asserts: badge='ERR', popup endsWith 'src/popup/index.html', +// notif count delta===1, and ANY active notification id +// has the 'mokosh-recovery-' prefix. The set-membership +// check on the prefix is the plan-resolved choice over a +// "most recent" check — `chrome.notifications.getAll` does +// not guarantee key ordering (set membership is reliable; +// ordering is not). /** * Result shape returned by harness assertions to Puppeteer. @@ -683,6 +703,273 @@ async function assertA4(): Promise { return result; } +/** Timeout for SAVE_ARCHIVE message dispatch — the SW does screenshot + * capture + content-script messaging + JSZip generation before responding. + * The screenshot path can stall briefly under the synthetic-stream pipeline; + * 15s gives several seconds of headroom over the typical ~2-3s run. */ +const A5_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; +/** + * Pre-SAVE_ARCHIVE settle window — wait at least one + * SEGMENT_DURATION_MS (10s, per src/offscreen/recorder.ts) past + * startRecording so the rotation timer fires, pushes the first segment + * into the buffer, and `getVideoBufferFromOffscreen` returns a non-empty + * result. Without this wait, saveArchive throws EmptyVideoBufferError + * (segments=[] → SW emits RECORDING_ERROR{error:'empty-video-buffer'} + * and returns success=false). 11s = 10s rotation + 1s slack for + * MediaRecorder.onstop → onSegmentStopped → segments.push to finish. + */ +const A5_SEGMENT_SETTLE_MS = 11_000; +/** Time in ms to wait for the SW state machine to settle after dispatching + * RECORDING_ERROR. The handler is synchronous (setErrorMode + create + * notification) so 200ms is comfortably above the typical few-ms delay. */ +const A7_SETTLE_MS = 200; +/** Recovery-notification id prefix — must stay in sync with + * `src/background/index.ts:NOTIFICATION_RECOVERY_PREFIX`. The harness + * asserts SET MEMBERSHIP against this prefix on the post-dispatch + * notification map (not "most recent"); `chrome.notifications.getAll` + * returns an Object whose key ordering is NOT guaranteed by the API + * contract — set-membership is the reliable check. */ +const RECOVERY_NOTIF_PREFIX = 'mokosh-recovery-'; + +/** + * Bring the SW state machine to REC + ensure the offscreen recording is + * live. Idempotent shared helper used by A7 (which runs AFTER A6 tears + * the recording down) and any future assertion that needs a fresh REC + * state. + * + * Same direct-offscreen workaround as `assertA2` — bypasses the missing + * `tabs` permission gap by sending START_RECORDING straight to offscreen + * + manually setting badge/popup. Documented at length in `assertA2`'s + * comment block. + * + * @returns ok status + diagnostic error. + */ +async function setupFreshRecording(): Promise<{ ok: boolean; error?: string }> { + try { + const ensureResp = await ensureOffscreen(); + if (!ensureResp.ok) { + return { ok: false, error: `ensureOffscreen failed: ${ensureResp.error ?? '(no error)'}` }; + } + const grantResp = await startRecording(); + if (!grantResp.granted) { + return { ok: false, error: 'startRecording returned granted=false' }; + } + // Wait for badge to become 'REC' before returning — guarantees A2's + // contract for callers that need REC state immediately. + await waitFor( + () => chrome.action.getBadgeText({}), + (v) => v === 'REC', + STATE_WAIT_MS, + "setupFreshRecording: badge should transition to 'REC'", + ); + return { ok: true }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } +} + +/** + * A5 — SAVE_ARCHIVE dispatch. Asserts the SW's `saveArchive` handler + * runs to completion and returns `{success: true}`. The actual zip + * file lands in `handles.downloadsDir` (configured via CDP + * `Browser.setDownloadBehavior` at launch time) — the host-side + * `driveA5` wrapper performs file-system verification (presence + size + * floor) because the page isolate cannot read the per-run downloads + * directory directly. + * + * Pre-condition: recording must be active (A2 + A3 + A4 left it running; + * A5 runs before A6's user-stop simulation in the orchestrator order). + * The SW's `saveArchive` does `chrome.tabs.query({active:true, + * currentWindow:true})` — relies on the launcher's victim about:blank + * tab being brought-to-front; the `tab.url` field is undefined without + * the `tabs` permission but `saveArchive` only checks `tab.id` so the + * permission gap is benign here. + * + * The content-script `GET_RRWEB_EVENTS` round-trip in `saveArchive` + * fails on `about:blank` (no content script injected there) — `saveArchive` + * catches and continues with `rrwebEvents = []`. The resulting zip is + * smaller than a production-page zip but well above the 1KB floor + * because the offscreen video buffer + screenshot dominate. + * + * @returns Structured result with 1 page-side check (SW dispatch ack). + */ +async function assertA5(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A5 — SAVE_ARCHIVE dispatches; SW handler returns success', + checks: [], + diagnostics: [], + }; + + try { + // Step 1 — settle window. A2 started recording shortly before A5; + // the offscreen recorder rotates segments every SEGMENT_DURATION_MS + // (10s). Before the first rotation fires, `segments` is empty and + // `getVideoBufferFromOffscreen` returns `{segments:[]}` which the SW's + // `createArchive` treats as `EmptyVideoBufferError`. Wait 11s so at + // least one segment lands in the buffer before we trigger + // SAVE_ARCHIVE. The settle dominates A5 wall-clock time (~11s of the + // ~13s total) — acceptable given the assertion verifies an + // end-to-end production save flow. + diag(result, `Step 1: settle ${A5_SEGMENT_SETTLE_MS}ms for first segment rotation`); + await new Promise((r) => setTimeout(r, A5_SEGMENT_SETTLE_MS)); + diag(result, 'Step 1 OK — first rotation should have fired'); + + diag(result, 'Step 2: send SAVE_ARCHIVE to SW'); + // SW handler: saveArchive() → captureScreenshot + getVideoBufferFromOffscreen + // + chrome.tabs.sendMessage(GET_RRWEB_EVENTS) → createArchive (JSZip) + // → downloadArchive (chrome.downloads.download with data:application/zip;base64,...). + // SW responds with { success: true } on the happy path; { success: false, error } + // otherwise. EmptyVideoBufferError additionally emits a RECORDING_ERROR + // sendMessage which is filtered out by the orchestrator's per-assertion + // notification snapshotting (A5 does not assert on notifications). + const resp = await sendMessageWithTimeout<{ + success: boolean; + error?: string; + }>( + { type: 'SAVE_ARCHIVE' }, + A5_SAVE_ARCHIVE_TIMEOUT_MS, + 'SAVE_ARCHIVE', + ); + diag(result, `Step 2 result: ${JSON.stringify(resp)}`); + + result.checks.push({ + name: 'A5.1: SAVE_ARCHIVE handler returns success=true', + expected: true, + actual: resp.success, + passed: resp.success === true, + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } + + return result; +} + +/** + * A7 — genuine recording error → ERR + recovery notification. Asserts + * that a non-Bug-B error code routes through `setErrorMode` (badge='ERR' + * + popup pinned) AND creates a `mokosh-recovery-*` notification. + * + * Sends `RECORDING_ERROR{error:'codec-unsupported'}` — a non-Bug-B path + * representative of the genuine capture-failure branch (the SW handler + * at src/background/index.ts:790 routes `'user-stopped-sharing'` through + * setIdleMode + no notification, everything else through setErrorMode + + * recovery notification). The exact error code is arbitrary among the + * non-Bug-B set; `codec-unsupported` is chosen as a clean exemplar. + * + * Pre-condition: a fresh recording must be active. A6 (which runs before + * A7 in the orchestrator order) tears recording down; this assertion + * calls `setupFreshRecording` to restore REC state before dispatching. + * + * Post-conditions verified: + * 1. badge === 'ERR' (setErrorMode contract per badge-state-machine.test.ts) + * 2. popup endsWith 'src/popup/index.html' (setErrorMode preserves the + * SAVE-only popup so the operator can recover the buffer) + * 3. notif count delta === 1 (exactly one new notification — the + * recovery one; no other notifications fire on the error path) + * 4. at least one active notification id has the recovery prefix + * (set-membership; ordering of `chrome.notifications.getAll` is + * not contractually stable — see plan resolved-questions §3) + * + * @returns Structured result with 4 post-dispatch checks (after SETUP). + */ +async function assertA7(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A7 — genuine RECORDING_ERROR → ERR badge + recovery notification', + checks: [], + diagnostics: [], + }; + + try { + diag(result, 'Step 1: setupFreshRecording (A6 may have torn it down)'); + const setupResp = await setupFreshRecording(); + if (!setupResp.ok) { + throw new Error( + `setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`, + ); + } + diag(result, 'Step 1 OK — REC state established'); + + diag(result, 'Step 2: snapshot active notification count + ids'); + const notifBefore = await getActiveNotificationCount(); + diag(result, `Step 2 result: notifBefore=${notifBefore}`); + + diag(result, "Step 3: send RECORDING_ERROR{error:'codec-unsupported'}"); + // No response expected — the SW handler is fire-and-forget + // (returns false from onMessage). We swallow lastError via the + // fire-and-forget pattern: send and continue. sendMessageWithTimeout + // would reject on `chrome.runtime.lastError === 'no response'`; use + // the direct API. + chrome.runtime.sendMessage({ type: 'RECORDING_ERROR', error: 'codec-unsupported' }); + diag(result, 'Step 3 OK — RECORDING_ERROR dispatched (fire-and-forget)'); + + diag(result, `Step 4: settle ${A7_SETTLE_MS}ms`); + await new Promise((r) => setTimeout(r, A7_SETTLE_MS)); + + diag(result, 'Step 5: read post-dispatch state (badge + popup + notifications)'); + const badgeAfter = await chrome.action.getBadgeText({}); + const popupAfter = await chrome.action.getPopup({}); + const notifAfter = await getActiveNotificationCount(); + const notifIds = await new Promise>((resolve, reject) => { + chrome.notifications.getAll((notifications: Object) => { + if (chrome.runtime.lastError !== undefined) { + reject(new Error(String(chrome.runtime.lastError.message))); + return; + } + resolve(Object.keys(notifications ?? {})); + }); + }); + const notifDelta = notifAfter - notifBefore; + const recoveryIdPresent = notifIds.some( + (id) => id.startsWith(RECOVERY_NOTIF_PREFIX), + ); + diag( + result, + `Step 5: badge='${badgeAfter}', popup='${popupAfter}', notifAfter=${notifAfter}, delta=${notifDelta}, ids=${JSON.stringify(notifIds)}`, + ); + + result.checks.push({ + name: "A7.1: badge text is 'ERR' after RECORDING_ERROR (setErrorMode)", + expected: 'ERR', + actual: badgeAfter, + passed: badgeAfter === 'ERR', + }); + // NOTE — chrome.action.getPopup() returns the absolute extension URL, + // not the manifest-relative path; .endsWith() keeps the check + // extension-id independent (see A2.2 NOTE). + result.checks.push({ + name: "A7.2: popup endsWith 'src/popup/index.html' (SAVE-only popup pinned)", + expected: '/>src/popup/index.html', + actual: popupAfter, + passed: popupAfter.endsWith('src/popup/index.html'), + }); + result.checks.push({ + name: 'A7.3: notification count delta === 1 (exactly one new recovery)', + expected: 1, + actual: notifDelta, + passed: notifDelta === 1, + }); + result.checks.push({ + name: `A7.4: at least one notification id startsWith '${RECOVERY_NOTIF_PREFIX}' (set membership)`, + expected: true, + actual: recoveryIdPresent, + passed: recoveryIdPresent === true, + }); + + result.passed = result.checks.every((c) => c.passed); + } catch (err) { + result.error = err instanceof Error ? err.message : String(err); + diag(result, `THREW: ${result.error}`); + } + + return result; +} + // Install the global harness surface. declare global { interface Window { @@ -691,18 +978,28 @@ declare global { assertA2: () => Promise; assertA3: () => Promise; assertA4: () => Promise; + assertA5: () => Promise; assertA6: () => Promise; + assertA7: () => Promise; }; } } -window.__mokoshHarness = { assertA1, assertA2, assertA3, assertA4, assertA6 }; +window.__mokoshHarness = { + assertA1, + assertA2, + assertA3, + assertA4, + assertA5, + assertA6, + assertA7, +}; const statusEl = document.getElementById('status'); if (statusEl !== null) { - statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1, assertA2, assertA3, assertA4, assertA6} available.'; + statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1, assertA2, assertA3, assertA4, assertA5, assertA6, assertA7} available.'; } -console.log('[harness-page] ready — window.__mokoshHarness installed (Wave 3A: A1+A2+A3+A4+A6)'); +console.log('[harness-page] ready — window.__mokoshHarness installed (Wave 3B: A1+A2+A3+A4+A5+A6+A7)'); export {}; diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts index f36d19c..25f135f 100644 --- a/tests/uat/harness.test.ts +++ b/tests/uat/harness.test.ts @@ -8,13 +8,15 @@ // Wave 3A scope — wires A0+A1+A2+A3+A4+A6 (A6 via the proven Wave-2 // driver). A5+A7..A13 throw `NOT YET IMPLEMENTED — Wave 3 wires this` // from `tests/uat/lib/harness-page-driver.ts`; the bail-on-first-failure -// loop stops at the first such throw. Expected Wave-3A diagnostic: -// "UAT harness: 5/14 assertions passed (A0+A1+A2+A3+A4 GREEN; bail at A5)" -// A6 PASSES via the standalone `npx tsx tests/uat/a6.test.ts` entry — -// the orchestrator-level A6 won't reach in Wave 3A because the -// sequential loop bails at A5; A6 lands in the loop output once Wave 3B -// implements driveA5. The orchestrator structure is final from Wave 3A -// onward; future waves only fill in the assertion-driver stubs. +// loop stops at the first such throw. +// +// Wave 3B (this file's current state) wires A5 (SAVE_ARCHIVE → zip on +// disk) + A7 (genuine RECORDING_ERROR → ERR + recovery notification). +// Expected diagnostic: "8/14 GREEN: A0+A1+A2+A3+A4+A5+A6+A7; bail at A8". +// Wave 3C will wire A8+A9+A10; Wave 3D wires A11+A12+A13 for 14/14 GREEN. +// +// The orchestrator structure is final from Wave 3A onward; future waves +// only fill in the assertion-driver stubs. // // Architectural commitments (per 01-11-SUMMARY.md, DO NOT REGRESS): // - Single browser, single recording per run (state machine: idle → @@ -245,11 +247,27 @@ async function main(): Promise { // recording), then dispatch-ended. After A6 the recording is torn // down — A7+ would need to re-start or test post-stop state. // - // Wave 3A only A1..A4 wire to real impls; A5..A13 throw NOT YET - // IMPLEMENTED. Bail-on-first-failure stops the loop at A5 — A6's - // driver wires (via Wave 2's driveA6) but won't reach in this run. + // Wave 3B wires A5 + A7 in addition to A1..A4 + A6 — bail-on-first- + // failure stops at A8 (Wave 3C wires that). Expected diagnostic: + // "8/14 GREEN: A0+A1+A2+A3+A4+A5+A6+A7; A8..A13 NOT YET IMPLEMENTED". // The standalone `npx tsx tests/uat/a6.test.ts` entry remains the - // way to verify A6 in isolation during Wave 3A. + // way to verify A6 in isolation for inner-loop iteration. + process.stdout.write('Launching Chrome + opening harness page...\n'); + const handles = await launchHarnessBrowser(); + process.stdout.write(`Extension id: ${handles.extensionId}\n`); + process.stdout.write(`Downloads dir: ${handles.downloadsDir}\n\n`); + + // Adapter: driveA5 needs `downloadsDir` (host-side fs polling); driveA12 + + // driveA13 return `AssertionWithBytes`. We wrap each in a closure that + // hides those signature differences so the orchestrator's driver list + // is uniform `Page -> Promise`. The byte-returning + // drivers' extra fields are out-of-scope for Wave 3B; Wave 3D will + // extend the orchestrator to surface them when A12/A13 land. The driver + // list is constructed AFTER `launchHarnessBrowser` returns so the + // closure can capture `handles.downloadsDir` without a TDZ trap. + const driveA5Wrapped: (page: import('puppeteer').Page) => Promise = + (page) => driveA5(page, handles.downloadsDir); + const drivers: ReadonlyArray<{ readonly name: string; readonly drive: (page: import('puppeteer').Page) => Promise; @@ -258,7 +276,7 @@ async function main(): Promise { { name: 'A2', drive: driveA2 }, { name: 'A3', drive: driveA3 }, { name: 'A4', drive: driveA4 }, - { name: 'A5', drive: driveA5 as (page: import('puppeteer').Page) => Promise }, + { name: 'A5', drive: driveA5Wrapped }, { name: 'A6', drive: driveA6 }, { name: 'A7', drive: driveA7 }, { name: 'A8', drive: driveA8 }, @@ -269,11 +287,6 @@ async function main(): Promise { { name: 'A13', drive: driveA13 as (page: import('puppeteer').Page) => Promise }, ]; - process.stdout.write('Launching Chrome + opening harness page...\n'); - const handles = await launchHarnessBrowser(); - process.stdout.write(`Extension id: ${handles.extensionId}\n`); - process.stdout.write(`Downloads dir: ${handles.downloadsDir}\n\n`); - const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole }; const results: Array<{ name: string; passed: boolean; error?: string }> = []; let bailReason: string | null = null; diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts index 7db44e9..dead7b6 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -19,11 +19,25 @@ // the first unimplemented one (bail-on-first-failure semantics in // `harness.test.ts` lands in Wave 3A). // +// Wave 3A wires driveA1/A2/A3/A4 (page-side surface in +// `extension-page-harness.ts` from the same wave). +// Wave 3B wires driveA5 (page-side ack + HOST-side fs polling for the +// dropped `session_report_*.zip` in `handles.downloadsDir`) + driveA7 +// (standard page.evaluate wrapper). The driveA5 signature requires a +// second `downloadsDir` argument; the orchestrator at `harness.test.ts` +// threads `handles.downloadsDir` through. +// // References: // - puppeteer Page.evaluate: // https://pptr.dev/api/puppeteer.page.evaluate +// - Node fs.readdirSync / statSync: +// https://nodejs.org/api/fs.html + +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { resolve as resolvePath } from 'node:path'; import type { Page } from 'puppeteer'; + import type { AssertionRecord, CheckRecord } from './assertions'; /** @@ -143,25 +157,171 @@ export async function driveA4(page: Page): Promise { }) as AssertionRecord; } -/* ─── Wave 3B — NOT YET IMPLEMENTED ──────────────────────────────── */ +/* ─── Wave 3B — WIRED ─────────────────────────────────────────────── */ + +/** Maximum wait for the SAVE_ARCHIVE zip to appear in `downloadsDir`. */ +const A5_DOWNLOAD_POLL_TIMEOUT_MS = 15_000; +/** Polling cadence while waiting for the zip. */ +const A5_DOWNLOAD_POLL_INTERVAL_MS = 200; +/** Filename suffix for the dropped archive. Production code in + * `src/background/index.ts:downloadArchive` requests + * `session_report__