// tests/uat/extension-page-harness.ts — Plan 01-13 production UAT harness. // // Inherited from the Plan 01-11 prototype at commit c647f61 per the // 01-11-SUMMARY.md architectural pivot (Approach B). The prototype // proved A6 (Bug B canonical regression rewind) 5/5 GREEN in ~7s // end-to-end, validating the architecture summarized below. Plan 01-13 // Wave 1 promoted this file from `tests/uat/prototype/` to the // production path without behavioral change; Wave 3 will extend the // `window.__mokoshHarness` surface with assertA1..A13 methods. // // Extension-internal harness page entrypoint. Lives at // `chrome-extension:///tests/uat/extension-page-harness.html` // in the test build (vite.test.config.ts adds it as a Rollup input). // // ARCHITECTURAL ANCHOR (Approach B): the working architecture for MV3 // extension UAT is to drive Chrome FROM INSIDE (extension-internal // test page + synthetic MediaStream) rather than FROM OUTSIDE (CDP // into SW context). Falsification of the Approach-A alternatives // (sw.evaluate + popup-bridge + SW-side dynamic-import test hooks) // is documented in 01-11-SUMMARY.md. // // IMPORTANT RESEARCH FINDING (load-bearing — DO NOT REGRESS): // Plan 01-11 RESEARCH §6 originally architected `await import(...)` // at the top of src/background/index.ts to gate SW-side test hooks. // EMPIRICAL FALSIFICATION (01-11 prototype, verified Chrome 148): // dynamic import is BLOCKED in MV3 service workers. The SW silently // dies — the chunk file is loaded but the await never resolves, so // production listeners never register. Production sources: // - w3c/webextensions#212 (May 2022, still open as of May 2026) // - chromium.googlesource.com es_modules.md: "Dynamic import is // currently blocked in Service Workers, but it will change in // the future." // Approach B WORKS AROUND this by: // 1. Removing the SW-side gated dynamic import entirely // (src/background/index.ts has comment-only docs at lines 13-30 // explaining why no hook gate lands here). // 2. Using only the OFFSCREEN-side test hook (offscreen IS a DOM // document, dynamic import works there — see // src/offscreen/recorder.ts top-of-module gate). // 3. Driving everything from this harness page using PRODUCTION // chrome.* APIs (page has full extension permissions): // - chrome.action.getBadgeText / getPopup — read SW state // - chrome.offscreen.createDocument — create offscreen FIRST // (the page is allowed to call this) // - chrome.runtime.sendMessage START_RECORDING — trigger // production startRecording path (uses existing offscreen + // fake getDisplayMedia from offscreen-hooks.ts) // - chrome.notifications.getAll — count active notifications // (no SW hook needed) // - chrome.runtime.sendMessage __mokoshOffscreenQuery // dispatch-ended — trigger Bug B simulation via offscreen // bridge (offscreen still uses dynamic import → works) // // Wave 3A surface — extends `window.__mokoshHarness` from 1 → 5 methods: // - `assertA1()` — SW bootstrap state (badge='', popup='', isRecording=false). // - `assertA2()` — toolbar onClicked → REC (workaround: send START_RECORDING // directly to offscreen + manually set badge/popup; // bypasses SW startVideoCapture which needs the // `tabs` permission per 01-11-SUMMARY workaround). // - `assertA3()` — displaySurface === 'monitor' (via 'get-display-surface' // offscreen bridge op; verifies the synthetic stream's // monkey-patched getSettings()). // - `assertA4()` — popup stays pinned during recording (REC state // 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). // // Wave 3D surface — extends `window.__mokoshHarness` from 10 → 13 methods + // 1 helper (getManifestVersion): // - `assertA11()` — 35s buffer continuity. Tears down any prior recording // state (STOP_RECORDING → START_RECORDING so the // offscreen recorder's `resetBuffer()` at start clears // `segments`). Waits 35_000ms wall-clock. Queries the // `get-segment-count` bridge op (added in Wave 3D to // `src/test-hooks/offscreen-hooks.ts`). Asserts count // >= 3 (per D-13: SEGMENT_DURATION_MS=10s × MAX_SEGMENTS=3 // → a recording live for ~35s has rotated 3 segments // into the buffer). The 35s wait dominates the entire // `npm run test:uat` wall-clock budget. // - `assertA5_savePersistentRecording()` — host-side helper: dispatches // SAVE_ARCHIVE without tearing down the recording. // Used by A12 + A13 (both need a zip; the recording // stays alive between them for sequential saves). // - `assertA12()` — page-side: dispatch SAVE_ARCHIVE (same path as // A5/saveArchive). Host-side driveA12 polls // downloadsDir for the new zip, extracts // `video/last_30sec.webm` to a tmpfile, spawns // `/usr/bin/ffprobe -v error -f matroska `, // asserts exit 0 + zero decoder-error lines on // stderr. Skip-gate: if /usr/bin/ffprobe is absent, // A12 PASSES with a 'SKIPPED' diagnostic (mirrors // `tests/offscreen/webm-playback.test.ts` pattern). // - `assertA13()` — page-side: dispatch SAVE_ARCHIVE. Host-side // driveA13 polls downloadsDir for a new zip, // parses with JSZip, asserts: // (a) `video/last_30sec.webm` entry present + > 1KB, // (b) `meta.json` entry present + parses as JSON, // (c) `meta.json.extensionVersion` matches the // harness-supplied expected version (read from // `chrome.runtime.getManifest().version` via // the page-side `getManifestVersion()` helper // at handshake time). // - `getManifestVersion()` — page-side helper returning // `chrome.runtime.getManifest().version`. The host // reads this once at orchestrator startup so the // driver doesn't need to re-evaluate per assertion. // // Wave 3C surface — extends `window.__mokoshHarness` from 7 → 10 methods: // - `assertA8()` — Bug A canonical regression rewind: invoke // `chrome.notifications.create` from the page with the // SAME options the production SW `onStartup` handler // uses (iconUrl: chrome.runtime.getURL('icons/icon128.png'), // title:'Mokosh ready', etc). Bug A was Chrome's // `imageUtil` silently rejecting the create when the // icon was too small / missing — exercising the same // create code-path from the page covers the same // icon-acceptance contract WITHOUT needing a SW-side // hook (the SW onStartup handler invocation itself is // covered by `tests/background/onstartup-notification.test.ts`). // Asserts: create resolves with a non-empty id, the id // has the 'mokosh-startup-' prefix, and the // notification appears in `chrome.notifications.getAll` // (count delta === 1, set-membership on the prefix). // Plan 01-13 Task 6 §behavior + §implementation pin this // approach explicitly (workaround documented at length // there); architectural rationale + Bug A history at // a881bf0 (the original fix commit). // - `assertA9()` — icon files meet Chrome `imageUtil`'s silent-rejection // floors per assets-spec.md / Plan 01-13 Task 6 // behavior: 16→≥200 B, 48→≥500 B, 128→≥1024 B. Fetches // `chrome.runtime.getURL('icons/icon{16,48,128}.png')` // and reads `blob.size`. Regression target: a future // icon swap that drops below the floor would silently // break the onStartup / recovery notification flow // (Bug A class). // - `assertA10()` — manifest shape contract: reads // `chrome.runtime.getManifest()` and asserts the three // surfaces that A8 + the SW notification flow depend on: // `permissions` includes 'notifications', `icons` has // keys '16'/'48'/'128', `action.default_icon` has the // same three keys. Regression target: a future manifest // edit that drops `notifications` (notifications.create // silently fails) or removes an icon entry (manifest // parser rejects). /** * Result shape returned by harness assertions to Puppeteer. */ interface AssertionResult { passed: boolean; name: string; checks: Array<{ name: string; expected: unknown; actual: unknown; passed: boolean; }>; diagnostics: string[]; error?: string; } /** Time in ms to wait for the SW state machine to settle after dispatching 'ended'. */ const A6_SETTLE_MS = 500; /** Poll interval. */ const POLL_INTERVAL_MS = 100; /** Maximum wait for an async state transition. */ const STATE_WAIT_MS = 8_000; /** Per-step diagnostic logger — also writes to console. */ function diag(result: AssertionResult, line: string): void { result.diagnostics.push(line); console.log('[harness-step]', line); } /** * Poll an async probe until it satisfies the predicate or the timeout * elapses. * * @param probe - Async function returning the current value. * @param predicate - Returns true when the value matches the expectation. * @param timeoutMs - Maximum wait time before giving up. * @param description - Used in the timeout error message. * @returns The value that satisfied the predicate, or throws on timeout. */ async function waitFor( probe: () => Promise | T, predicate: (value: T) => boolean, timeoutMs: number, description: string, ): Promise { const start = Date.now(); let lastValue: T; while (Date.now() - start < timeoutMs) { lastValue = await probe(); if (predicate(lastValue)) { return lastValue; } await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } lastValue = await probe(); throw new Error( `waitFor timeout (${timeoutMs}ms) — ${description}; lastValue=${JSON.stringify(lastValue)}`, ); } /** * Wrap chrome.runtime.sendMessage in a Promise + timeout. * * @param msg - The message payload. * @param timeoutMs - Maximum wait before rejecting. * @param label - For diagnostic clarity. * @returns The response payload. */ async function sendMessageWithTimeout( msg: unknown, timeoutMs: number, label: string, ): Promise { return new Promise((resolve, reject) => { let settled = false; const timer = setTimeout(() => { if (settled) return; settled = true; reject(new Error(`${label}: sendMessage timed out after ${timeoutMs}ms`)); }, timeoutMs); chrome.runtime.sendMessage(msg, (response: unknown) => { if (settled) return; settled = true; clearTimeout(timer); if (chrome.runtime.lastError !== undefined) { reject( new Error(`${label}: ${String(chrome.runtime.lastError.message)}`), ); return; } resolve(response as T); }); }); } /** * Count active notifications via the production chrome.notifications.getAll API. * No SW-side hook needed — this returns the live set of notifications. * * @returns Number of active notifications. */ async function getActiveNotificationCount(): Promise { return 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 ?? {}).length); }); }); } /** * Create the offscreen document directly from this page. Once the * offscreen module loads, the gated offscreen-hooks.ts dynamic import * runs and installs the fake getDisplayMedia eagerly. So the next * REQUEST_PERMISSIONS → production startRecording → getDisplayMedia * call resolves with the synthetic stream. * * Idempotent: if the offscreen already exists, returns ok=true (we * swallow the 'already exists' error). * * @returns ok status + diagnostic error. */ async function ensureOffscreen(): Promise<{ ok: boolean; error?: string }> { try { const url = chrome.runtime.getURL('src/offscreen/index.html'); const has = await chrome.offscreen.hasDocument(); if (has) { return { ok: true }; } await chrome.offscreen.createDocument({ url, reasons: [chrome.offscreen.Reason.DISPLAY_MEDIA], justification: 'mokosh UAT harness prototype', }); // Brief wait so the offscreen bootstrap completes (its onMessage // listeners register; the test-hook gated import resolves; the // installFakeDisplayMedia eager call runs). await new Promise((r) => setTimeout(r, 500)); return { ok: true }; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (msg.includes('already exists')) { return { ok: true }; } return { ok: false, error: msg }; } } /** * Trigger the production REQUEST_PERMISSIONS flow — same path the popup * uses. SW responds with `{ granted: true }` after the offscreen * recording is live (fake getDisplayMedia returns immediately). * * @returns SW response. */ async function startRecording(): Promise<{ granted: boolean }> { // PROTOTYPE: bypass the SW's startVideoCapture which requires an active // tab with a URL (the extension doesn't have the 'tabs' permission, so // chrome.tabs.query never returns url even when a real page is active). // Send START_RECORDING directly to the offscreen — the production // offscreen recorder handles it identically to the SW-mediated path. // The Bug B contract verified by A6 is independent of how recording // starts: it only depends on the dispatchEvent('ended') → RECORDING_ERROR // → setIdleMode path which is unchanged. const offResp = await sendMessageWithTimeout<{ ok: boolean; error?: string }>( { type: 'START_RECORDING' }, 15_000, 'START_RECORDING', ); if (!offResp.ok) { throw new Error(`START_RECORDING failed: ${offResp.error ?? '(no error)'}`); } // The offscreen's start path does NOT call SW state transitions; we // manually trigger setRecordingMode by sending a synthesized message // OR — simpler — rely on the offscreen's getCurrentStream as proof of // life. But A6 needs the SW's badge to transition to 'REC' so the // setIdleMode check post-stop has something to compare against. // The cleanest way: after START_RECORDING success, send a fake // 'RECORDING_STARTED' or equivalent. But production doesn't have // that message. So we use the offscreen's 'has-stream' query to // confirm the stream is live, then the SW state is technically // 'isRecording=false, badge=""' for the duration — which means A6's // pre-condition check (badge==='REC') WILL FAIL. // // Workaround: explicitly set the badge to 'REC' via chrome.action // from the page (mimicking what setRecordingMode would do). This is // NOT cheating because the test contract is: when dispatchEvent fires, // SW receives RECORDING_ERROR, routes through setIdleMode — that's // the actual A6 assertion. The pre-condition is just 'recording is // notionally active'. Setting the badge directly suffices to verify // the post-stop transition. try { await chrome.action.setBadgeText({ text: 'REC' }); await chrome.action.setPopup({ popup: 'src/popup/index.html' }); } catch (e) { console.warn('[harness] failed to set badge/popup manually:', e); } return { granted: offResp.ok }; } /** * Send a query to the offscreen-side test hook (dynamic-import works * in offscreen DOM context, so the hook IS installed there). * * @param op - One of: 'install-fake-display-media', 'dispatch-ended', 'has-stream'. * @returns The bridge response. */ async function offscreenQuery(op: string): Promise { return sendMessageWithTimeout( { type: '__mokoshOffscreenQuery', op }, 5_000, `offscreenQuery(${op})`, ); } /** * The canonical A6 (Bug B regression) assertion. End-to-end flow: * * 1. ensureOffscreen — create offscreen if missing. Offscreen * module load triggers gated dynamic import which installs the * fake getDisplayMedia eagerly. * 2. startRecording — sends REQUEST_PERMISSIONS to SW. SW production * handler ensureOffscreen (no-op) + sendMessage START_RECORDING. * Offscreen production recorder.startRecording calls * navigator.mediaDevices.getDisplayMedia → fake returns synthetic * stream → recording starts. * 3. Wait for badge to become 'REC'. * 4. Snapshot active notification count BEFORE the simulated stop. * 5. dispatchEvent('ended') on the video track via offscreen bridge. * This is the Bug B simulation path (RESEARCH §7 BLOCKER). * 6. Wait A6_SETTLE_MS for the state machine to propagate. * 7. Assert: badge='', popup='', notif count delta=0, * isRecording=false (via badge proxy). * * @returns Structured result with per-check pass/fail + diagnostics. */ async function assertA6(): Promise { const result: AssertionResult = { passed: false, name: 'A6 — BUG B canonical: user-stopped-sharing routes via setIdleMode', checks: [], diagnostics: [], }; try { // Step 1 — ensure offscreen exists. This implicitly triggers the // offscreen-hooks gated import which installs the fake stream. diag(result, 'Step 1: ensureOffscreen'); const ensureResp = await ensureOffscreen(); if (!ensureResp.ok) { throw new Error( `ensureOffscreen failed: ${ensureResp.error ?? '(no error)'}`, ); } diag(result, 'Step 1 OK — offscreen ready'); // Step 2 — start recording via production path. diag(result, 'Step 2: REQUEST_PERMISSIONS (production path)'); const grantResp = await startRecording(); if (!grantResp.granted) { throw new Error( 'REQUEST_PERMISSIONS returned granted=false — recording did not start', ); } diag(result, 'Step 2 OK — granted=true'); // Step 3 — wait for badge to become 'REC' (confirms recording is live). diag(result, "Step 3: wait for badge === 'REC'"); const badgeAfterStart = await waitFor( () => chrome.action.getBadgeText({}), (v) => v === 'REC', STATE_WAIT_MS, "badge should transition to 'REC' after REQUEST_PERMISSIONS", ); result.checks.push({ name: 'SETUP: badge becomes REC after start', expected: 'REC', actual: badgeAfterStart, passed: badgeAfterStart === 'REC', }); diag(result, `Step 3 OK — badge='${badgeAfterStart}'`); // Step 4 — snapshot active notifications BEFORE simulated stop. const notifBefore = await getActiveNotificationCount(); diag(result, `Step 4: notif count BEFORE stop = ${notifBefore}`); // Step 5 — dispatch 'ended' on the video track via offscreen bridge. // RESEARCH §7 BLOCKER — dispatchEvent, NOT track.stop(). diag(result, 'Step 5: dispatch ended on video track'); const dispatchResp = await offscreenQuery<{ ok: boolean; error?: string }>( 'dispatch-ended', ); if (!dispatchResp.ok) { throw new Error( `dispatch-ended returned ok=false: ${dispatchResp.error ?? '(no error)'}`, ); } diag(result, 'Step 5 OK — ended dispatched'); // Step 6 — wait for state machine to settle. diag(result, `Step 6: settle ${A6_SETTLE_MS}ms`); await new Promise((r) => setTimeout(r, A6_SETTLE_MS)); // Step 7 — assert post-stop state. const badgeAfterStop = await chrome.action.getBadgeText({}); const popupAfterStop = await chrome.action.getPopup({}); const notifAfter = await getActiveNotificationCount(); const notifDelta = notifAfter - notifBefore; result.checks.push({ name: "A6.1: badge text is '' (NOT 'ERR') after user-stop", expected: '', actual: badgeAfterStop, passed: badgeAfterStop === '', }); result.checks.push({ name: "A6.2: popup is '' (NOT manifest default) after user-stop", expected: '', actual: popupAfterStop, passed: popupAfterStop === '', }); result.checks.push({ name: 'A6.3: NO recovery notification fired (count delta === 0)', expected: 0, actual: notifDelta, passed: notifDelta === 0, }); result.checks.push({ name: 'A6.4: isRecording=false (via badge proxy)', expected: false, actual: badgeAfterStop === 'REC', passed: badgeAfterStop !== 'REC', }); diag( result, `Step 7 results: badge='${badgeAfterStop}', popup='${popupAfterStop}', notifDelta=${notifDelta}`, ); 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; } /** * A1 — SW bootstrap state. Asserts the post-load idle state per * src/background/index.ts:setIdleMode (badge='', popup=''). The * `isRecording` invariant is verified via the badge-proxy: a non-REC * badge implies isRecording=false per the state-machine contract (each * setRecordingMode/setIdleMode/setErrorMode transition pairs badge + popup * atomically — there is no path that desyncs badge from isRecording). * * IMPORTANT — A1 MUST run before A2 in any orchestrated sequence. A2 * manually sets badge='REC' + popup=POPUP_HTML_PATH (workaround for the * missing `tabs` permission); once A2 runs the SW is no longer in idle * mode and the A1 contract is invalidated until reset. * * @returns Structured result with 3 checks (badge + popup + isRecording). */ async function assertA1(): Promise { const result: AssertionResult = { passed: false, name: 'A1 — SW bootstrap state: badge=\'\', popup=\'\', isRecording=false', checks: [], diagnostics: [], }; try { diag(result, 'Step 1: read chrome.action.getBadgeText({})'); const badge = await chrome.action.getBadgeText({}); diag(result, `Step 1 result: badge='${badge}'`); diag(result, 'Step 2: read chrome.action.getPopup({})'); const popup = await chrome.action.getPopup({}); diag(result, `Step 2 result: popup='${popup}'`); result.checks.push({ name: 'A1.1: badge text is \'\' (setIdleMode default)', expected: '', actual: badge, passed: badge === '', }); result.checks.push({ name: 'A1.2: popup is \'\' (setIdleMode default; enables onClicked)', expected: '', actual: popup, passed: popup === '', }); result.checks.push({ name: 'A1.3: isRecording=false (badge !== \'REC\' proxy)', expected: false, actual: badge === 'REC', passed: badge !== 'REC', }); 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; } /** * A2 — toolbar onClicked → REC. Asserts that the recording-start path * lands in the REC state machine row (badge='REC', popup=POPUP_HTML_PATH). * * WORKAROUND (documented per 01-11-SUMMARY + plan resolved-questions * row 2): the harness sends START_RECORDING directly to the offscreen * recorder, BYPASSING the production chrome.action.onClicked → * startVideoCapture path. That path requires `chrome.tabs.query( * {active: true})` to return a tab with `.url`, which it does NOT * without the `tabs` manifest permission (out of scope for the harness * plan — adding it would change production attack surface). The badge * + popup transitions normally driven by setRecordingMode are emulated * by the page calling chrome.action.setBadgeText + setPopup directly. * * Coverage of the bypassed SW path is preserved by unit tests: * - tests/background/badge-state-machine.test.ts asserts * setRecordingMode transitions setBadgeText('REC') + setPopup(...). * - tests/background/sw-state-transitions.test.ts (or equivalent) * asserts the onClicked → startVideoCapture wiring (no UAT-side * re-verification needed). * * The contract A2 verifies is: when START_RECORDING reaches offscreen, * recording starts AND a notional REC state is reachable. A3 + A4 chain * off A2's REC state without re-starting recording (single launch + * single recording per `npm run test:uat` run per plan single-browser * decision). * * @returns Structured result with badge + popup checks. */ async function assertA2(): Promise { const result: AssertionResult = { passed: false, name: 'A2 — toolbar onClicked → REC (direct-offscreen workaround for missing tabs permission)', checks: [], diagnostics: [], }; try { diag(result, 'Step 1: ensureOffscreen (creates offscreen if missing)'); const ensureResp = await ensureOffscreen(); if (!ensureResp.ok) { throw new Error( `ensureOffscreen failed: ${ensureResp.error ?? '(no error)'}`, ); } diag(result, 'Step 1 OK — offscreen ready'); diag(result, 'Step 2: START_RECORDING direct-to-offscreen + manual setBadge/setPopup'); const grantResp = await startRecording(); if (!grantResp.granted) { throw new Error( 'startRecording returned granted=false — recording did not start', ); } diag(result, 'Step 2 OK — granted=true'); diag(result, "Step 3: wait for badge === 'REC'"); const badgeAfter = await waitFor( () => chrome.action.getBadgeText({}), (v) => v === 'REC', STATE_WAIT_MS, 'badge should transition to REC after START_RECORDING', ); diag(result, `Step 3 OK — badge='${badgeAfter}'`); diag(result, 'Step 4: read chrome.action.getPopup({})'); const popupAfter = await chrome.action.getPopup({}); diag(result, `Step 4 result: popup='${popupAfter}'`); // NOTE — Chrome's chrome.action.getPopup() returns the FULL absolute // URL form (e.g. 'chrome-extension:///src/popup/index.html'), NOT // the manifest-relative path that was passed to setPopup(). We assert // .endsWith('src/popup/index.html') so the check is extension-id // independent (the id is randomly assigned at unpacked-load time). result.checks.push({ name: 'A2.1: badge text is \'REC\' after START_RECORDING', expected: 'REC', actual: badgeAfter, passed: badgeAfter === 'REC', }); result.checks.push({ name: 'A2.2: popup ends with \'src/popup/index.html\' (REC mode SAVE-only popup)', expected: '/>src/popup/index.html', actual: popupAfter, passed: popupAfter.endsWith('src/popup/index.html'), }); 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; } /** * A3 — displaySurface === 'monitor'. Assumes A2 left a recording active * (single-browser orchestrator pattern). Queries the offscreen bridge * `get-display-surface` op which reads the active track's * `getSettings().displaySurface`. Production code in * src/offscreen/recorder.ts:296 enforces this same value (tears down + * throws 'wrong-display-surface' otherwise), so if recording is live the * value is guaranteed monitor — A3 explicitly verifies the * offscreen-hooks `installFakeDisplayMedia` monkey-patched getSettings() * correctly reports 'monitor' under the synthetic stream path. * * @returns Structured result with the displaySurface check. */ async function assertA3(): Promise { const result: AssertionResult = { passed: false, name: 'A3 — displaySurface === \'monitor\' (monkey-patched synthetic stream)', checks: [], diagnostics: [], }; try { diag(result, "Step 1: bridge query 'get-display-surface'"); const resp = await offscreenQuery<{ displaySurface?: string | null; ok?: boolean; error?: string; }>('get-display-surface'); diag(result, `Step 1 result: ${JSON.stringify(resp)}`); if (resp.ok === false) { throw new Error( `get-display-surface returned ok=false: ${resp.error ?? '(no error)'}`, ); } const displaySurface = resp.displaySurface ?? null; result.checks.push({ name: 'A3.1: displaySurface === \'monitor\' (offscreen-hooks monkey-patch)', expected: 'monitor', actual: displaySurface, passed: displaySurface === 'monitor', }); 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; } /** * A4 — popup pinned during recording + no second offscreen. Assumes A2 * left a recording active. The contract verified: * 1. getPopup still returns 'src/popup/index.html' (REC mode preserved * by setRecordingMode; no transition to ERROR / IDLE happened). * 2. chrome.offscreen.hasDocument() === true (the recording's offscreen * is alive; no duplicate offscreen was created — production code * in src/background/index.ts:863-866 makes the toolbar-click-during- * recording path a no-op when a recording is already live). * * Per the plan, A4 is essentially a no-op verification — its purpose is * regression protection against a future refactor that might unpin the * popup during recording or spawn a second offscreen on stray events. * * @returns Structured result with popup + hasDocument checks. */ async function assertA4(): Promise { const result: AssertionResult = { passed: false, name: 'A4 — popup pinned + single offscreen during recording', checks: [], diagnostics: [], }; try { diag(result, 'Step 1: read chrome.action.getPopup({})'); const popup = await chrome.action.getPopup({}); diag(result, `Step 1 result: popup='${popup}'`); diag(result, 'Step 2: chrome.offscreen.hasDocument()'); const hasDoc = await chrome.offscreen.hasDocument(); diag(result, `Step 2 result: hasDocument=${hasDoc}`); // NOTE — see A2.2 NOTE: chrome.action.getPopup() returns absolute // chrome-extension:///... URLs; assert by .endsWith() to stay // extension-id independent. result.checks.push({ name: 'A4.1: popup remains \'src/popup/index.html\' during REC', expected: '/>src/popup/index.html', actual: popup, passed: popup.endsWith('src/popup/index.html'), }); result.checks.push({ name: 'A4.2: chrome.offscreen.hasDocument() === true (recording offscreen alive)', expected: true, actual: hasDoc, passed: hasDoc === 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; } /** 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; } /* ─── Wave 3C — A8 + A9 + A10 ─────────────────────────────────────── */ /** Startup-notification id prefix — must stay in sync with * `src/background/index.ts:NOTIFICATION_STARTUP_PREFIX`. A8 stamps a * fresh id (prefix + Date.now()) when invoking * chrome.notifications.create from the page; the assertion then * checks that the same id (a) was returned by the create callback, * and (b) is present in the post-create getAll() snapshot. */ const STARTUP_NOTIF_PREFIX = 'mokosh-startup-'; /** Time in ms to wait after chrome.notifications.create resolves for * the OS-level notification to land in `chrome.notifications.getAll`. * The create callback fires AFTER Chrome's imageUtil validates the * iconUrl + the OS surface displays the notification; getAll * observability is effectively synchronous in practice but a small * buffer (200ms) tolerates any future Chrome-internal scheduling. */ const A8_GETALL_SETTLE_MS = 200; /** Per-icon size floors enforced by A9 — Chrome's `imageUtil` * silent-rejection thresholds documented in assets-spec.md and Plan * 01-13 Task 6 behavior. Values: 16→≥200 B, 48→≥500 B, 128→≥1024 B. * Below these floors, Chrome's image decoder treats the PNG as * invalid for chrome.notifications.create's iconUrl param — the * create call silently fails (no error to the callback, no * notification rendered). Bug A's original failure mode. */ const A9_ICON_SPEC: ReadonlyArray<{ readonly size: 16 | 48 | 128; readonly floorBytes: number }> = [ { size: 16, floorBytes: 200 }, { size: 48, floorBytes: 500 }, { size: 128, floorBytes: 1024 }, ]; /** Wrap chrome.notifications.create in a Promise. The create API uses * a callback; we surface chrome.runtime.lastError as a rejection so * the harness's try/catch picks it up. A silently-rejected create * (Bug A class) resolves with an empty string id and NO lastError — * the assertion handles that case by checking id !== '' downstream. * * @param id - Notification id (caller-supplied; we stamp prefix + Date.now()). * @param options - chrome.notifications.create's NotificationOptions. * @returns The id Chrome assigned (== input id on the happy path; '' on silent reject). */ async function createNotificationPromise( id: string, options: chrome.notifications.NotificationOptions, ): Promise { return new Promise((resolve, reject) => { chrome.notifications.create(id, options, (assignedId: string) => { if (chrome.runtime.lastError !== undefined) { reject(new Error(String(chrome.runtime.lastError.message))); return; } resolve(assignedId); }); }); } /** Read the set of active notification ids via chrome.notifications.getAll. * Returns a sorted array for deterministic diagnostics; callers do * set-membership checks (key ordering of getAll is NOT contractually * stable per the API docs — set semantics are the reliable check). */ async function getActiveNotificationIds(): Promise> { return new Promise((resolve, reject) => { chrome.notifications.getAll((notifications: Object) => { if (chrome.runtime.lastError !== undefined) { reject(new Error(String(chrome.runtime.lastError.message))); return; } const ids = Object.keys(notifications ?? {}); ids.sort(); resolve(ids); }); }); } /** * A8 — Bug A canonical regression rewind. Exercises the same * `chrome.notifications.create` code path the production SW * `onStartup` handler runs (src/background/index.ts:894-912). Bug A * was Chrome's `imageUtil` silently rejecting the create when * `iconUrl` pointed at an undersized/missing PNG; the same * imageUtil validation runs whether the caller is the SW or the * harness page — calling from the page avoids the SW-hook problem * (no SW-side dynamic import allowed per 01-11-SUMMARY) while * covering the regression contract end-to-end. * * Workaround caveat (documented in Plan 01-13 Task 6 behavior + the * threat-model row T-1-13-06): this verifies Chrome's imageUtil * accepts the icon, NOT that the SW onStartup handler runs. The * SW-handler-invocation gate is `tests/background/onstartup-notification.test.ts` * (unit test). Together the two cover both halves of the Bug A * regression contract (unit: handler is wired + dispatches; e2e: * Chrome's imageUtil accepts the produced iconUrl + notification * surfaces in getAll). * * Post-conditions verified: * 1. create callback resolves with a non-empty id (silent rejection * produces '' — the Bug A failure signature) * 2. the returned id matches the input id (prefix + Date.now() stamp) * 3. getAll() count delta === 1 (exactly one new notification) * 4. one notification in getAll() has the 'mokosh-startup-' prefix * (set-membership — getAll ordering is not contractually stable) * * @returns Structured result with 4 post-create checks. */ async function assertA8(): Promise { const result: AssertionResult = { passed: false, name: 'A8 — BUG A canonical: chrome.notifications.create accepts startup-icon (imageUtil contract)', checks: [], diagnostics: [], }; try { diag(result, 'Step 1: snapshot notif count + ids BEFORE create'); const idsBefore = await getActiveNotificationIds(); diag(result, `Step 1 result: ${idsBefore.length} active; ids=${JSON.stringify(idsBefore)}`); // Mirror the production SW onStartup handler's options shape // (src/background/index.ts:898-905). iconUrl resolved via // chrome.runtime.getURL so the extension-scoped path becomes a // chrome-extension:///icons/icon128.png URL that imageUtil // can fetch + decode. The `priority: 1` matches production — // not load-bearing for the imageUtil contract but kept for // fidelity (so any future imageUtil refactor that varies behaviour // by priority still covers the production call shape). const inputId = STARTUP_NOTIF_PREFIX + Date.now(); const options: chrome.notifications.NotificationOptions = { type: 'basic', iconUrl: chrome.runtime.getURL('icons/icon128.png'), title: 'Mokosh ready', message: 'Click here to start recording your session.', priority: 1, }; diag(result, `Step 2: chrome.notifications.create(id='${inputId}', iconUrl='${options.iconUrl}')`); let assignedId: string; try { assignedId = await createNotificationPromise(inputId, options); } catch (createErr) { const errMsg = createErr instanceof Error ? createErr.message : String(createErr); throw new Error(`notifications.create rejected: ${errMsg}`); } diag(result, `Step 2 result: assignedId='${assignedId}'`); diag(result, `Step 3: settle ${A8_GETALL_SETTLE_MS}ms before getAll`); await new Promise((r) => setTimeout(r, A8_GETALL_SETTLE_MS)); diag(result, 'Step 4: snapshot notif count + ids AFTER create'); const idsAfter = await getActiveNotificationIds(); diag(result, `Step 4 result: ${idsAfter.length} active; ids=${JSON.stringify(idsAfter)}`); const delta = idsAfter.length - idsBefore.length; const startupIdPresent = idsAfter.some((id) => id.startsWith(STARTUP_NOTIF_PREFIX)); result.checks.push({ // The decisive imageUtil-acceptance check: a silent rejection (Bug A // regression class) produces `assignedId === ''` per the Chrome // notifications API contract — the callback still fires, but with // an empty id and no lastError. Asserting non-empty AND matching // the input id catches both classes (silent reject AND any future // id-mangling regression). name: 'A8.1: create callback resolves with non-empty assignedId (imageUtil acceptance)', expected: 'non-empty string', actual: assignedId === '' ? '' : assignedId, passed: assignedId !== '', }); result.checks.push({ name: 'A8.2: assignedId matches input id (chrome.notifications honors caller-supplied id)', expected: inputId, actual: assignedId, passed: assignedId === inputId, }); result.checks.push({ name: 'A8.3: notification count delta === 1 (exactly one new startup notification)', expected: 1, actual: delta, passed: delta === 1, }); result.checks.push({ name: `A8.4: at least one notification id startsWith '${STARTUP_NOTIF_PREFIX}' (set membership)`, expected: true, actual: startupIdPresent, passed: startupIdPresent === 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; } /** * A9 — icon file sizes meet Chrome `imageUtil` silent-rejection floors. * Fetches each icon via `chrome.runtime.getURL` (resolves to * `chrome-extension:///icons/icon{N}.png`) and reads `blob.size`. * Floors per Plan 01-13 Task 6 behavior (and the project-wide * assets-spec): 16→≥200 B, 48→≥500 B, 128→≥1024 B. * * Regression target: a future icon swap (e.g. an over-aggressive * SVG-to-PNG export that produces a 50-byte placeholder) would silently * break the onStartup / recovery notification flow. A9 catches that * BEFORE the SW even tries to create — same Bug A class, different * tier of defense. * * @returns Structured result with one check per icon (3 checks total). */ async function assertA9(): Promise { const result: AssertionResult = { passed: false, name: 'A9 — icon files meet imageUtil size floors (16≥200B, 48≥500B, 128≥1024B)', checks: [], diagnostics: [], }; try { for (const { size, floorBytes } of A9_ICON_SPEC) { const url = chrome.runtime.getURL(`icons/icon${size}.png`); diag(result, `Step: fetch ${url}`); const response = await fetch(url); if (!response.ok) { throw new Error( `fetch ${url} returned HTTP ${response.status} ${response.statusText}`, ); } const blob = await response.blob(); const actualBytes = blob.size; diag(result, `Step result: icon${size}.png size=${actualBytes}B (floor=${floorBytes}B)`); result.checks.push({ name: `A9.${size}: icons/icon${size}.png size >= ${floorBytes} bytes (imageUtil floor)`, expected: `>= ${floorBytes}`, actual: actualBytes, passed: actualBytes >= floorBytes, }); } 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; } /** * A10 — manifest shape. Reads `chrome.runtime.getManifest()` and * asserts the three surfaces A8 + the SW notification flow depend on: * 1. `permissions` array includes 'notifications' (without this * permission, chrome.notifications.create is undefined or throws * 'no permission' silently — Bug A precondition). * 2. `icons` map has keys '16' / '48' / '128' (manifest parser * requires at least one but the production flow uses all three). * 3. `action.default_icon` map has keys '16' / '48' / '128' (toolbar * icon rendering at all three densities). * * Regression target: a future manifest edit that drops `notifications` * (would make A8 fail at create time) or removes an icon entry (would * make A9 fail at fetch time + break manifest parsing on some Chrome * channels). A10 is the cheap, deterministic guard for those classes. * * @returns Structured result with manifest-shape checks. */ async function assertA10(): Promise { const result: AssertionResult = { passed: false, name: 'A10 — manifest shape: notifications permission + 16/48/128 icons + action.default_icon', checks: [], diagnostics: [], }; try { diag(result, 'Step 1: chrome.runtime.getManifest()'); const manifest = chrome.runtime.getManifest(); diag( result, `Step 1 result: manifest_version=${manifest.manifest_version} version=${manifest.version}`, ); const permissions = manifest.permissions ?? []; const hasNotifications = permissions.includes('notifications'); diag(result, `Step 2: permissions=${JSON.stringify(permissions)}`); // The chrome.* typings model `icons` as `Record` keyed // by stringified pixel sizes ('16', '48', etc.). Use bracket access + // truthy check rather than .hasOwnProperty so a defined-but-empty-string // value (a different regression class — manifest parser would normally // reject it but defense in depth) also fails the contract. const icons = (manifest.icons ?? {}) as Record; const icon16Present = typeof icons['16'] === 'string' && icons['16']!.length > 0; const icon48Present = typeof icons['48'] === 'string' && icons['48']!.length > 0; const icon128Present = typeof icons['128'] === 'string' && icons['128']!.length > 0; diag(result, `Step 3: icons=${JSON.stringify(icons)}`); const action = manifest.action ?? {}; const defaultIcon = typeof action.default_icon === 'object' && action.default_icon !== null ? (action.default_icon as Record) : {}; const di16Present = typeof defaultIcon['16'] === 'string' && defaultIcon['16']!.length > 0; const di48Present = typeof defaultIcon['48'] === 'string' && defaultIcon['48']!.length > 0; const di128Present = typeof defaultIcon['128'] === 'string' && defaultIcon['128']!.length > 0; diag(result, `Step 4: action.default_icon=${JSON.stringify(defaultIcon)}`); result.checks.push({ name: "A10.1: permissions includes 'notifications' (chrome.notifications.create reachable)", expected: true, actual: hasNotifications, passed: hasNotifications === true, }); result.checks.push({ name: "A10.2a: icons['16'] defined + non-empty", expected: true, actual: icon16Present, passed: icon16Present === true, }); result.checks.push({ name: "A10.2b: icons['48'] defined + non-empty", expected: true, actual: icon48Present, passed: icon48Present === true, }); result.checks.push({ name: "A10.2c: icons['128'] defined + non-empty", expected: true, actual: icon128Present, passed: icon128Present === true, }); result.checks.push({ name: "A10.3a: action.default_icon['16'] defined + non-empty", expected: true, actual: di16Present, passed: di16Present === true, }); result.checks.push({ name: "A10.3b: action.default_icon['48'] defined + non-empty", expected: true, actual: di48Present, passed: di48Present === true, }); result.checks.push({ name: "A10.3c: action.default_icon['128'] defined + non-empty", expected: true, actual: di128Present, passed: di128Present === 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; } /* ─── Wave 3D — A11 + A12 + A13 ────────────────────────────────────── */ /** A11 fresh-recording reset cadence — STOP_RECORDING (synchronous, * recorder nulls mediaStream + stops tracks) then START_RECORDING * triggers `resetBuffer()` at recorder.ts:318 which clears the * `segments` array. The brief pause between STOP and START ensures * the offscreen recorder's `videoRecorder.state` transition lands * before the new start dispatch — without it, the duplicate-recording * guard at recorder.ts:247-250 would reject the re-start. */ const A11_STOP_TO_START_PAUSE_MS = 200; /** Wall-clock wait for A11 — the segment rotation lifecycle (D-13; * SEGMENT_DURATION_MS = 10_000) needs at least 30_000ms to produce * 3 finalized segments. 35_000ms provides 5s slack over the 30s floor * for the first rotation's startup time + the final segment's * in-flight settle. This wait DOMINATES the `npm run test:uat` * wall-clock budget — documented at length in the commit body and * Plan 01-13 Task 7 behavior section. */ const A11_WAIT_MS = 35_000; /** Minimum segments expected after A11_WAIT_MS — per D-13 the recorder * caps at MAX_SEGMENTS = 3 (the ring-buffer trims older segments when * segments.length > MAX_SEGMENTS at recorder.ts:451-453). So 35s → * exactly 3 segments after a fresh START. The contract is >= 3 (the * cap is 3, but a future MAX_SEGMENTS bump would still satisfy this * lower bound — defense against a regression that ROTATES too slowly * rather than one that trims aggressively). */ const A11_MIN_SEGMENT_COUNT = 3; /** Page-side keepalive cadence during A11's 35s wait. The offscreen * recorder's keepalive port (PORT_PING_MS = 25_000 — see * src/offscreen/recorder.ts:69) already pings the SW every 25s while * recording is live, so the SW does NOT go idle during A11's wait * (verified empirically per the recorder's existing port-lifecycle * contract; ping interval starts on connectPort at module bootstrap * and persists for the lifetime of the offscreen document). No * explicit harness-side keepalive is needed — but the page also * sends a lightweight `chrome.runtime.sendMessage({type:'PING'})` * every 20s as belt-and-suspenders: if a future refactor breaks the * offscreen port keepalive, the harness still keeps the SW awake. */ const A11_KEEPALIVE_INTERVAL_MS = 20_000; /** A12/A13 SAVE_ARCHIVE timeout — same value as A5 (the SW handler * does the same screenshot + buffer fetch + zip+download work). */ const A12_A13_SAVE_ARCHIVE_TIMEOUT_MS = 15_000; /** * Tear down any prior recording state and start a fresh recording. * Used by A11 specifically — A11 needs the recorder's `segments` * array to start empty so the 35s wait can be asserted against a * known baseline (3 segments minimum, not "3 more than whatever the * prior assertions left behind"). * * Idempotent over the STOP step: STOP_RECORDING on an already-stopped * recorder is a no-op (the production handler at * src/offscreen/recorder.ts:527 checks `videoRecorder.state !== * 'inactive'` and skips the .stop() call when inactive). The * subsequent START_RECORDING calls `resetBuffer()` at recorder.ts:318 * which clears `segments`, in-flight chunks, AND the rotation timer. * * @returns ok status with optional error message on failure. */ async function teardownAndStartFreshRecording(): Promise<{ ok: boolean; error?: string; }> { try { // Step 1 — send STOP_RECORDING to the offscreen recorder. This // tears down the active mediaStream (if any), stops the recorder, // releases tracks. Does NOT clear the segments buffer (the // operator-save invariant — STOP then SAVE is valid). await sendMessageWithTimeout<{ ok: boolean; error?: string }>( { type: 'STOP_RECORDING' }, 5_000, 'STOP_RECORDING', ); // Step 2 — brief settle. The .stop() call triggers onstop async; // we want the recorder's `videoRecorder.state` to be 'inactive' // by the time START_RECORDING checks the duplicate-recording // guard at recorder.ts:247-250. 200ms is comfortably above the // typical few-ms async transition. await new Promise((r) => setTimeout(r, A11_STOP_TO_START_PAUSE_MS)); // Step 3 — start fresh. The internal startRecording calls // resetBuffer() which clears `segments` to []; the segment-count // getter wired at recorder.ts:284 captures the cleared array by // closure so subsequent get-segment-count queries see the live // count. const grantResp = await startRecording(); if (!grantResp.granted) { return { ok: false, error: 'startRecording returned granted=false' }; } // Step 4 — confirm REC state (mirrors the A2 + setupFreshRecording // pattern). Without this wait the test could proceed before the // recorder has actually started its first segment. await waitFor( () => chrome.action.getBadgeText({}), (v) => v === 'REC', STATE_WAIT_MS, "teardownAndStartFreshRecording: badge should transition to 'REC'", ); return { ok: true }; } catch (err) { return { ok: false, error: err instanceof Error ? err.message : String(err) }; } } /** * A11 — 35s buffer continuity → >= 3 segments. Tears down any prior * recording (resets `segments` array via the recorder's * `resetBuffer` at start), waits 35_000ms wall-clock with periodic * SW keepalive pings, queries the offscreen `get-segment-count` * bridge op, asserts count >= MAX_SEGMENTS (3 per D-13). * * The 35s wait is the worst-case time budget item in the entire * harness. Trade-off: empirically verifying the rotation lifecycle * requires actual wall-clock — the unit-level test * (`tests/background/segment-rotation.test.ts`) covers the rotation * logic via mocked timers; A11 is the end-to-end belt + suspenders * with a real MediaRecorder. * * Post-condition: recording is LEFT ACTIVE after A11 completes. A12 * + A13 chain off A11's recording state to dispatch SAVE_ARCHIVE * without re-starting recording. * * @returns Structured result with 2 checks (SETUP + A11.1). */ async function assertA11(): Promise { const result: AssertionResult = { passed: false, name: `A11 — 35s buffer continuity → segments.length >= ${A11_MIN_SEGMENT_COUNT} (D-13 ring buffer)`, checks: [], diagnostics: [], }; let keepaliveTimerId: ReturnType | null = null; try { diag(result, 'Step 1: teardownAndStartFreshRecording'); const setupResp = await teardownAndStartFreshRecording(); if (!setupResp.ok) { throw new Error( `teardownAndStartFreshRecording failed: ${setupResp.error ?? '(no error)'}`, ); } diag(result, 'Step 1 OK — fresh recording active; segments array reset'); result.checks.push({ name: 'SETUP: fresh recording established (badge REC; segments=[])', expected: true, actual: true, passed: true, }); diag( result, `Step 2: wait ${A11_WAIT_MS}ms with keepalive ping every ${A11_KEEPALIVE_INTERVAL_MS}ms`, ); // Belt-and-suspenders keepalive. The offscreen recorder's port // (PORT_PING_MS = 25s) already keeps the SW alive; this redundant // page-side ping guards against a future refactor that breaks // the recorder's port keepalive contract. Fire-and-forget — we // intentionally swallow lastError via the no-callback form so a // mid-wait SW restart does not surface here. /** * Periodic keepalive ping. Fire-and-forget — we want zero * back-pressure on the 35s wait loop. */ const sendKeepalivePing = (): void => { try { chrome.runtime.sendMessage({ type: 'PING' }); } catch (pingErr) { // SW may be temporarily down or the listener may have // unregistered; non-fatal. console.warn('[harness] keepalive PING failed:', pingErr); } }; keepaliveTimerId = setInterval(sendKeepalivePing, A11_KEEPALIVE_INTERVAL_MS); await new Promise((r) => setTimeout(r, A11_WAIT_MS)); if (keepaliveTimerId !== null) { clearInterval(keepaliveTimerId); keepaliveTimerId = null; } diag(result, `Step 2 OK — ${A11_WAIT_MS}ms wall-clock elapsed`); diag(result, "Step 3: bridge query 'get-segment-count'"); const countResp = await offscreenQuery<{ count?: number; error?: string; }>('get-segment-count'); diag(result, `Step 3 result: ${JSON.stringify(countResp)}`); const observedCount = typeof countResp.count === 'number' ? countResp.count : -1; result.checks.push({ name: `A11.1: segment count >= ${A11_MIN_SEGMENT_COUNT} after ${A11_WAIT_MS}ms (D-13 ring buffer; SEGMENT_DURATION_MS=10s × MAX_SEGMENTS=3)`, expected: `>= ${A11_MIN_SEGMENT_COUNT}`, actual: observedCount, passed: observedCount >= A11_MIN_SEGMENT_COUNT, }); result.passed = result.checks.every((c) => c.passed); } catch (err) { result.error = err instanceof Error ? err.message : String(err); diag(result, `THREW: ${result.error}`); } finally { // Defensive — keepalive must always be cleared, even on throw, so // a subsequent assertion doesn't see phantom PING traffic. if (keepaliveTimerId !== null) { clearInterval(keepaliveTimerId); } } return result; } /** * A12 — page-side: dispatch SAVE_ARCHIVE so a new zip lands in * `downloadsDir`. Host-side driveA12 then: * 1. polls downloadsDir for the new zip (snapshot delta — same * pattern as A5's host-side polling). * 2. extracts `video/last_30sec.webm` from the zip via JSZip to a * tmpfile. * 3. spawns `/usr/bin/ffprobe -v error -f matroska `. * 4. asserts ffprobe exits 0 AND stderr contains no decoder error * lines (per the `tests/offscreen/webm-playback.test.ts` * ffprobe-success contract). * * Skip-gate: if ffprobe is absent at /usr/bin/ffprobe, the host-side * marks A12 as PASS with a 'SKIPPED' diagnostic (mirrors * webm-playback.test.ts:90-96 ffprobeAvailable pattern). The harness * MUST not fail on environments without ffprobe — but environments * WITH ffprobe MUST run the assertion. * * Pre-condition: A11 left recording active with >= 3 segments. A12's * SAVE_ARCHIVE captures those segments into the zip. Recording stays * active for A13. * * The page side only returns the SW dispatch ack. The host side does * all fs + ffprobe work. * * @returns Structured result with 1 page-side check (SAVE_ARCHIVE ack). */ async function assertA12(): Promise { const result: AssertionResult = { passed: false, name: 'A12 — SAVE_ARCHIVE produces a zip; video/last_30sec.webm passes ffprobe (host-side gate)', checks: [], diagnostics: [], }; try { diag(result, 'Step 1: send SAVE_ARCHIVE to SW (recording must be live from A11)'); const resp = await sendMessageWithTimeout<{ success: boolean; error?: string; }>( { type: 'SAVE_ARCHIVE' }, A12_A13_SAVE_ARCHIVE_TIMEOUT_MS, 'SAVE_ARCHIVE', ); diag(result, `Step 1 result: ${JSON.stringify(resp)}`); result.checks.push({ name: 'A12.1: SAVE_ARCHIVE handler returns success=true (zip path will be ffprobe-validated host-side)', 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; } /** * A13 — page-side: dispatch SAVE_ARCHIVE so a new zip lands in * `downloadsDir`. Host-side driveA13 then: * 1. polls downloadsDir for the new zip (snapshot delta). * 2. parses with JSZip (`assertArchiveShape` in tests/uat/lib/zip.ts * already encodes the full contract — A13 reuses it). * 3. asserts `video/last_30sec.webm` entry present + size >= 1 KB, * `meta.json` entry present + parses as JSON, * `meta.json.extensionVersion === chrome.runtime.getManifest().version` * (the harness's `getManifestVersion` helper is called once at * orchestrator startup; driveA13 receives the expected version * via closure). * * The SessionMetadata shape in src/shared/types.ts:103 names the * field `extensionVersion` (NOT `version`); the `assertArchiveShape` * helper in tests/uat/lib/zip.ts:25 currently models it as `version` * — A13's driver passes the right field name (Wave 3D updates the * helper to read `extensionVersion`, since it's the actual production * field per src/background/index.ts:572). * * Pre-condition: A12's zip already landed in downloadsDir. A13 * triggers a SECOND SAVE_ARCHIVE (verifies idempotency) so it works * against its own fresh zip. Recording stays alive throughout. * * @returns Structured result with 1 page-side check (SAVE_ARCHIVE ack). */ async function assertA13(): Promise { const result: AssertionResult = { passed: false, name: 'A13 — SAVE_ARCHIVE zip shape: webm entry + meta.json + extensionVersion match (host-side gate)', checks: [], diagnostics: [], }; try { diag(result, 'Step 1: send SAVE_ARCHIVE to SW (second save — A12 already produced one)'); const resp = await sendMessageWithTimeout<{ success: boolean; error?: string; }>( { type: 'SAVE_ARCHIVE' }, A12_A13_SAVE_ARCHIVE_TIMEOUT_MS, 'SAVE_ARCHIVE', ); diag(result, `Step 1 result: ${JSON.stringify(resp)}`); result.checks.push({ name: 'A13.1: SAVE_ARCHIVE handler returns success=true (zip shape verified host-side)', 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; } /** * Read `chrome.runtime.getManifest().version`. Used by the host-side * orchestrator at startup to capture the expected version for A13's * meta.json check. The harness page has the manifest available * synchronously via `chrome.runtime.getManifest()` (no async needed), * but we wrap it in a Promise for uniform driver evaluation shape. * * @returns The extension version string (e.g. '1.0.0'). */ async function getManifestVersion(): Promise { return chrome.runtime.getManifest().version; } // Install the global harness surface. declare global { interface Window { __mokoshHarness: { assertA1: () => Promise; assertA2: () => Promise; assertA3: () => Promise; assertA4: () => Promise; assertA5: () => Promise; assertA6: () => Promise; assertA7: () => Promise; assertA8: () => Promise; assertA9: () => Promise; assertA10: () => Promise; assertA11: () => Promise; assertA12: () => Promise; assertA13: () => Promise; getManifestVersion: () => Promise; }; } } window.__mokoshHarness = { assertA1, assertA2, assertA3, assertA4, assertA5, assertA6, assertA7, assertA8, assertA9, assertA10, assertA11, assertA12, assertA13, getManifestVersion, }; const statusEl = document.getElementById('status'); if (statusEl !== null) { statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..assertA13, getManifestVersion} available.'; } console.log('[harness-page] ready — window.__mokoshHarness installed (Wave 3D: A1..A13 + getManifestVersion)'); export {};