diff --git a/tests/uat/extension-page-harness.ts b/tests/uat/extension-page-harness.ts index 5ea311c..88a15b9 100644 --- a/tests/uat/extension-page-harness.ts +++ b/tests/uat/extension-page-harness.ts @@ -1700,11 +1700,19 @@ async function assertA12(): Promise { * 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. + * Plan 01-13 Task 9 (debug session 01-09-save-stops-recording) amendment: + * after the SAVE-auto-stops-recording fix in src/background/index.ts, + * A12's SAVE_ARCHIVE now stops the recording (per SPEC one-shot intent). + * A13's own SAVE_ARCHIVE therefore needs a FRESH recording — without it, + * A13 would dispatch against an empty buffer and saveArchive would + * return success=false (the EmptyVideoBufferError path). A13 thus + * does its own setupFreshRecording + segment-settle before dispatching. + * Trade-off: adds ~11s wall-clock to the harness (segment-settle for + * the first rotation) — acceptable; the SPEC SAVE=stop contract is the + * load-bearing requirement, A13 was designed under the old "save keeps + * recording" assumption. * - * @returns Structured result with 1 page-side check (SAVE_ARCHIVE ack). + * @returns Structured result with checks (SETUP + A13.1). */ async function assertA13(): Promise { const result: AssertionResult = { @@ -1715,7 +1723,38 @@ async function assertA13(): Promise { }; try { - diag(result, 'Step 1: send SAVE_ARCHIVE to SW (second save — A12 already produced one)'); + // Plan 01-13 Task 9 amendment: A12's SAVE_ARCHIVE now auto-stops + // recording (per the SAVE=stop SPEC contract). A13 needs to + // re-establish a fresh recording + wait for the first segment + // rotation before dispatching its own SAVE — otherwise the + // SW's saveArchive throws EmptyVideoBufferError on an empty + // segments buffer and returns success=false. + diag(result, 'Step 1: setupFreshRecording (A12 SAVE stopped recording per 01-09 fix)'); + const setupResp = await setupFreshRecording(); + if (!setupResp.ok) { + throw new Error( + `setupFreshRecording failed: ${setupResp.error ?? '(no error)'}`, + ); + } + diag(result, 'Step 1 OK — fresh recording active'); + result.checks.push({ + name: 'SETUP: fresh recording established (post-A12 SAVE auto-stop)', + expected: true, + actual: true, + passed: true, + }); + + // Step 2 — segment-settle. Same rationale as A5 (line ~890): the + // offscreen recorder rotates segments every SEGMENT_DURATION_MS + // (10s). Before the first rotation, `segments` is empty and + // `getVideoBufferFromOffscreen` returns `{segments:[]}` which + // createArchive treats as EmptyVideoBufferError. Wait 11s so at + // least one segment lands in the buffer. + diag(result, `Step 2: settle ${A5_SEGMENT_SETTLE_MS}ms for first segment rotation`); + await new Promise((r) => setTimeout(r, A5_SEGMENT_SETTLE_MS)); + diag(result, 'Step 2 OK — first rotation should have fired'); + + diag(result, 'Step 3: send SAVE_ARCHIVE to SW (own fresh-recording save)'); const resp = await sendMessageWithTimeout<{ success: boolean; error?: string; @@ -1724,7 +1763,7 @@ async function assertA13(): Promise { A12_A13_SAVE_ARCHIVE_TIMEOUT_MS, 'SAVE_ARCHIVE', ); - diag(result, `Step 1 result: ${JSON.stringify(resp)}`); + diag(result, `Step 3 result: ${JSON.stringify(resp)}`); result.checks.push({ name: 'A13.1: SAVE_ARCHIVE handler returns success=true (zip shape verified host-side)', @@ -1742,6 +1781,129 @@ async function assertA13(): Promise { return result; } +/** Settle window for the SW state machine after SAVE_ARCHIVE completes + * — the post-save STOP_RECORDING + setIdleMode block runs synchronously + * in saveArchive's finally, but chrome.action.getBadgeText reads are + * cross-event-loop. 500ms provides comfortable headroom; the + * observed lag is typically a few ms. */ +const A14_POST_SAVE_SETTLE_MS = 500; + +/** + * A14 — post-SAVE auto-stop state check. Plan 01-13 Task 9 closure for + * the operator empirical UAT bug `.planning/debug/01-09-save-stops-recording.md`. + * + * Verifies that A13's SAVE_ARCHIVE (and by extension every SAVE) leaves + * the SW state machine in IDLE: + * - badge text === '' (setBadgeState('OFF') side effect of setIdleMode) + * - popup === '' (chrome.action.setPopup('') side effect of setIdleMode; + * observed as the Chrome-resolved absolute URL ending in a path that + * is NOT 'src/popup/index.html' — under the IDLE contract Chrome + * returns an empty string when getPopup is called against a popup + * that was set to '') + * - no NEW notification with the 'mokosh-recovery-' prefix surfaced + * since A14 entered (delta-based — A7 left a recovery notification + * in the active set; the SAVE auto-stop must NOT add another one, + * per the "deliberate stop ≠ error" contract that mirrors Bug B) + * + * Pre-condition: A13 just completed (its SAVE_ARCHIVE auto-stopped the + * recording under the new fix; the state machine should already be in + * IDLE by the time A14 runs). + * + * Post-condition: no state change — A14 is read-only. + * + * Per the spec instruction (alternative simpler design from the + * orchestrator prompt): we settle for badge='' + popup='' + no-NEW-recovery-notif + * as the A14 contract. Direct isRecording check is transitively verified + * via the absence of the REC badge — the production SW state machine + * (src/background/index.ts:setRecordingMode/setIdleMode/setErrorMode) + * pairs isRecording transitions with badge transitions atomically, so + * the badge serves as a reliable proxy. + * + * @returns Structured result with 3 checks (badge + popup + no-new-recovery-notif). + */ +async function assertA14(): Promise { + const result: AssertionResult = { + passed: false, + name: 'A14 — post-SAVE auto-stop state: badge=\'\' + popup=\'\' + no new mokosh-recovery-* notif', + checks: [], + diagnostics: [], + }; + + try { + // Snapshot the recovery-notification ids BEFORE the A14 settle. + // A7 left at least one recovery-* in the active set; the delta we + // care about is "did any NEW recovery surface since A13's SAVE + // completed". A13's SAVE finished a few ms ago (the orchestrator + // ran assertA13 → returned → host moved to A14); the post-save + // finally block in saveArchive already executed setIdleMode + + // STOP_RECORDING. But we cannot observe what A13 did atomically + // — the cleanest contract is to snapshot now, settle, and check + // for a DELTA only. The empty-buffer-error branch comment in + // src/background/index.ts notes that the recovery notification + // would briefly appear; in this assertion we verify that under + // the HAPPY (success-path) SAVE, no recovery notification + // surfaces. A13's setup dispatched a successful SAVE (against a + // fresh recording with >= 1 segment from the 11s settle), so the + // happy-path branch ran — no RECORDING_ERROR → no recovery notif. + diag(result, 'Step 1: snapshot mokosh-recovery-* notification ids (delta baseline)'); + const idsBefore = await getActiveNotificationIds(); + const recoveryIdsBefore = idsBefore.filter( + (id) => id.startsWith(RECOVERY_NOTIF_PREFIX), + ); + diag( + result, + `Step 1 result: ${recoveryIdsBefore.length} active recovery-prefix ids: ${JSON.stringify(recoveryIdsBefore)}`, + ); + + diag(result, `Step 2: settle ${A14_POST_SAVE_SETTLE_MS}ms for post-A13 state machine to land`); + await new Promise((r) => setTimeout(r, A14_POST_SAVE_SETTLE_MS)); + + diag(result, 'Step 3: read post-SAVE state (badge + popup + recovery ids delta)'); + const badge = await chrome.action.getBadgeText({}); + const popup = await chrome.action.getPopup({}); + const idsAfter = await getActiveNotificationIds(); + const recoveryIdsAfter = idsAfter.filter( + (id) => id.startsWith(RECOVERY_NOTIF_PREFIX), + ); + const recoveryDelta = recoveryIdsAfter.length - recoveryIdsBefore.length; + diag( + result, + `Step 3 result: badge='${badge}', popup='${popup}', recoveryDelta=${recoveryDelta} (before=${recoveryIdsBefore.length}, after=${recoveryIdsAfter.length})`, + ); + + result.checks.push({ + name: 'A14.1: badge text is \'\' after SAVE_ARCHIVE auto-stop (setIdleMode)', + expected: '', + actual: badge, + passed: badge === '', + }); + // Chrome's chrome.action.getPopup() returns an absolute URL when the + // popup was set via a non-empty path. When setPopup was called with + // an empty string (the setIdleMode case), getPopup returns an empty + // string per the Chrome runtime API contract. Assert empty-string + // equality. + result.checks.push({ + name: 'A14.2: popup is \'\' after SAVE_ARCHIVE auto-stop (setIdleMode re-enables onClicked)', + expected: '', + actual: popup, + passed: popup === '', + }); + result.checks.push({ + name: 'A14.3: NO new mokosh-recovery-* notification (deliberate stop != error)', + expected: 0, + actual: recoveryDelta, + passed: recoveryDelta === 0, + }); + + 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 @@ -1772,6 +1934,7 @@ declare global { assertA11: () => Promise; assertA12: () => Promise; assertA13: () => Promise; + assertA14: () => Promise; getManifestVersion: () => Promise; }; } @@ -1791,14 +1954,15 @@ window.__mokoshHarness = { assertA11, assertA12, assertA13, + assertA14, getManifestVersion, }; const statusEl = document.getElementById('status'); if (statusEl !== null) { - statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..assertA13, getManifestVersion} available.'; + statusEl.textContent = 'Harness ready. window.__mokoshHarness.{assertA1..assertA14, getManifestVersion} available.'; } -console.log('[harness-page] ready — window.__mokoshHarness installed (Wave 3D: A1..A13 + getManifestVersion)'); +console.log('[harness-page] ready — window.__mokoshHarness installed (Plan 01-13 Task 9: A1..A14 + getManifestVersion)'); export {}; diff --git a/tests/uat/harness.test.ts b/tests/uat/harness.test.ts index cebd500..22e50c9 100644 --- a/tests/uat/harness.test.ts +++ b/tests/uat/harness.test.ts @@ -1,23 +1,26 @@ -// tests/uat/harness.test.ts — Plan 01-13 Wave 3A orchestrator. +// tests/uat/harness.test.ts — Plan 01-13 orchestrator (Wave 3A → Task 9). // -// Top-level entry for the production UAT harness. Drives all 14 +// Top-level entry for the production UAT harness. Drives all 15 // assertions sequentially against a SINGLE launched Chrome instance with // a SINGLE harness page; bails on the first failure with a structured -// diagnostic dump. Exits 0 only when 14/14 GREEN. +// diagnostic dump. Exits 0 only when 15/15 GREEN. // // 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` +// driver). A5+A7..A13 threw `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. +// loop stopped at the first such throw. // // Wave 3B wires A5 (SAVE_ARCHIVE → zip on disk) + A7 (genuine -// RECORDING_ERROR → ERR + recovery notification). Wave 3C (this file's -// current state) wires A8 (Bug A canonical onStartup-notification -// regression rewind) + A9 (icon file sizes meet imageUtil floors) + -// A10 (manifest shape contract). Expected diagnostic: -// "11/14 GREEN: A0+A1+A2+A3+A4+A5+A6+A7+A8+A9+A10; bail at A11". +// RECORDING_ERROR → ERR + recovery notification). Wave 3C wires A8 +// (Bug A canonical onStartup-notification regression rewind) + A9 (icon +// file sizes meet imageUtil floors) + A10 (manifest shape contract). // Wave 3D wires A11+A12+A13 for 14/14 GREEN. // +// Plan 01-13 Task 9 closure (debug 01-09-save-stops-recording) adds A14: +// post-SAVE auto-stop state check (badge='', popup='', no new +// mokosh-recovery-*). Chains off A13's SAVE_ARCHIVE — read-only +// observation, no new dispatch. Final target: 15/15 GREEN. +// // The orchestrator structure is final from Wave 3A onward; future waves // only fill in the assertion-driver stubs. // @@ -63,6 +66,7 @@ import { driveA11, driveA12, driveA13, + driveA14, getManifestVersion, } from './lib/harness-page-driver'; import { @@ -219,7 +223,11 @@ async function assertA0_GrepGate(): Promise<{ * iterate driver list → bail on first failure → close browser → return * exit code. * - * @returns Process exit code: 0 on 14/14 GREEN, 1 on any failure. + * Plan 01-13 Task 9 closure (debug 01-09-save-stops-recording) added A14 + * after A13. The orchestrator now drives 14 page-side assertions + * (A1..A14) plus the host-side A0 grep gate = 15 total. + * + * @returns Process exit code: 0 on 15/15 GREEN, 1 on any failure. */ async function main(): Promise { process.stdout.write('\nMokosh Plan 01-13 — UAT harness orchestrator\n'); @@ -295,6 +303,12 @@ async function main(): Promise { { name: 'A11', drive: driveA11 }, { name: 'A12', drive: driveA12Wrapped }, { name: 'A13', drive: driveA13Wrapped }, + // Plan 01-13 Task 9 closure (debug 01-09-save-stops-recording): A14 + // verifies that A13's SAVE_ARCHIVE auto-stopped the recording per + // SPEC one-shot intent. Read-only assertion on chrome.action + + // notification ids state; no new SAVE dispatch — A13's already + // exercised the SAVE path. Recording stays stopped after A14. + { name: 'A14', drive: driveA14 }, ]; const buffers = { swConsole: handles.swConsole, offConsole: handles.offConsole }; @@ -337,7 +351,7 @@ async function main(): Promise { } const passedCount = results.filter((r) => r.passed).length; - // Total = 1 (A0) + drivers.length (A1..A13) = 14. + // Total = 1 (A0) + drivers.length (A1..A14) = 15. const total = drivers.length + 1; const finalPassed = passedCount + 1; // +1 for A0 (we already passed it to reach here) diff --git a/tests/uat/lib/harness-page-driver.ts b/tests/uat/lib/harness-page-driver.ts index a4a9c4b..ac7795c 100644 --- a/tests/uat/lib/harness-page-driver.ts +++ b/tests/uat/lib/harness-page-driver.ts @@ -968,6 +968,28 @@ export async function getManifestVersion(page: Page): Promise { }) as string; } +/* ─── Plan 01-13 Task 9 — driveA14 ─────────────────────────────────── */ + +/** + * Drive A14 (post-SAVE auto-stop state check). Plan 01-13 Task 9 closure + * for debug session 01-09-save-stops-recording. Standard page.evaluate + * wrapper — A14 is a read-only assertion of the SW state machine left + * by A13's SAVE_ARCHIVE: badge='', popup='', no new mokosh-recovery-* + * notification. All work happens page-side; host side just triggers + + * reads the result. + * + * @param page - The harness page from `launchHarnessBrowser`. + * @returns Structured AssertionRecord with 3 checks (badge + popup + no-new-recovery). + */ +export async function driveA14(page: Page): Promise { + return await page.evaluate(async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- evaluate runs in browser context where Window types are loose. + const harness = (window as any).__mokoshHarness; + const r: AssertionRecord = await harness.assertA14(); + return r; + }) as AssertionRecord; +} + // Note (Wave 3D): the AssertionWithBytes interface is retained at the // top of this file as a public export — but Wave 3D's drivers no // longer use it (the host side now does all bytes-handling internally