diff --git a/tests/background/save-archive-stops-recording.test.ts b/tests/background/save-archive-does-not-stop-recording.test.ts similarity index 70% rename from tests/background/save-archive-stops-recording.test.ts rename to tests/background/save-archive-does-not-stop-recording.test.ts index a7eb212..ba9bbb8 100644 --- a/tests/background/save-archive-stops-recording.test.ts +++ b/tests/background/save-archive-does-not-stop-recording.test.ts @@ -1,37 +1,39 @@ -// tests/background/save-archive-stops-recording.test.ts +// tests/background/save-archive-does-not-stop-recording.test.ts // -// Plan 01-13 Task 9 operator UAT closure — debug session -// `.planning/debug/01-09-save-stops-recording.md`. +// Plan 01-09 Amendment 3 (2026-05-19) — charter reversal of the +// prior `save-archive-stops-recording.test.ts` (renamed via `git mv` +// from this same file; history preserved). Debug session record: +// `.planning/debug/resolved/01-09-save-does-not-stop-recording.md`. // -// Contract: SAVE_ARCHIVE is one-shot per SPEC intent (`Тз расширение -// фаза1.md`: "one click MUST produce a self-contained archive"). After -// the SW's saveArchive() completes (zip created + chrome.downloads.download -// dispatched), the SW MUST: -// 1. send STOP_RECORDING to offscreen (releases the MediaStream tracks + -// stops the MediaRecorder + closes Chrome's sharing banner) -// 2. transition the state machine to IDLE (setBadgeText('') + setPopup('')) -// so the operator sees the recording cleared AND the toolbar click -// re-fires onClicked for a fresh session start -// 3. NOT fire a recovery notification — the stop is deliberate (mirrors -// the Bug B user-stopped-sharing branch — see -// `.planning/debug/resolved/01-09-recovery-flow.md`) +// Contract (REVERSED 2026-05-19): SAVE_ARCHIVE creates a new zip but +// does NOT stop the recorder. The recording is continuous — the only +// termination paths are: +// 1. Operator clicks Chrome's "Stop sharing" banner → offscreen's +// onUserStoppedSharing emits RECORDING_ERROR{error:'user-stopped-sharing'} +// → SW's RECORDING_ERROR handler routes to setIdleMode (Bug B +// branch — preserved). Tests in `tests/background/badge-state-machine.test.ts` +// pin this path; A14 harness assertion verifies it end-to-end. +// 2. Browser closes (SW terminates; offscreen tears down with it). +// 3. Extension uninstalled. // -// Pre-fix behaviour (operator empirical UAT 2026-05-19): SAVE_ARCHIVE only -// produced the zip; recording stayed live; toolbar press re-opened the -// SAVE-only popup with no state delta. Operator dispatched 4 toolbar -// presses across ~94s; 2 zips downloaded; 1 misclick. +// SPEC alignment: `Тз расширение фаза1.md` framing is "continuous +// capture / always-on safety net". Operator workflow: hit a bug → SAVE +// (zip lands) → keep working → next bug also captured. The prior +// Amendment 2 fix at commits cd83eb0+4f4c3e2+2b6c24b+89f3337 implemented +// SAVE=stop semantics; 2026-05-19 operator UX iteration REVERSED that. // -// Tests A..D below pin the post-SAVE state machine transition contract. -// All RED today; all GREEN after src/background/index.ts saveArchive() -// is patched to dispatch STOP_RECORDING + setIdleMode. +// Tests A..D below INVERT the prior contract: after SAVE_ARCHIVE, the +// SW state machine MUST remain in REC (badge stays 'REC', popup stays +// pinned to popup.html, no STOP_RECORDING dispatched to offscreen, no +// recovery notification fired). // -// Test architecture mirrors `tests/background/request-id-protocol.test.ts`: -// synthetic BUFFER response delivered via port.onMessage listeners to drive -// saveArchive's request-id'd buffer fetch to completion. The empty-segments -// BUFFER causes createArchive to throw EmptyVideoBufferError (intentional -// — the post-save state transition MUST happen on BOTH the success path -// AND the empty-buffer-error path, per the fix design's operator-UI -// contract: SAVE click = stop, success or empty-buffer alike). +// Test architecture (unchanged from the prior file): synthetic BUFFER +// response delivered via port.onMessage listeners to drive saveArchive's +// request-id'd buffer fetch to completion. The empty-segments BUFFER +// causes createArchive to throw EmptyVideoBufferError (intentional — +// the post-save state invariance MUST hold on BOTH the success path +// AND the empty-buffer-error path: SAVE click does NOT stop recording, +// success or empty-buffer alike). import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -274,8 +276,9 @@ async function setupSwInRecState(stub: BgChromeStub): Promise { /** * Dispatch SAVE_ARCHIVE and drain the resulting async chain to completion. * Resolves the in-flight REQUEST_BUFFER with an empty BUFFER (which causes - * createArchive → EmptyVideoBufferError → saveArchive catch branch — the - * branch under test for the post-save STOP_RECORDING + setIdleMode dispatch). + * createArchive → EmptyVideoBufferError → saveArchive catch branch — under + * the REVERSED contract, neither the success path nor the catch path may + * stop the recording). * * @param stub - The chrome stub. * @param port - The wired offscreen port. @@ -302,11 +305,10 @@ async function dispatchSaveArchiveAndDrain( await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 32; i++) await Promise.resolve(); - // Find the REQUEST_BUFFER post and respond with an empty-segments BUFFER - // (mirrors the empty-buffer fallback path; saveArchive's createArchive - // throws EmptyVideoBufferError when segments.length === 0, the catch - // branch handles it, and the FIX dispatches STOP_RECORDING + setIdleMode - // regardless of which branch ran). + // Find the REQUEST_BUFFER post and respond with an empty-segments BUFFER. + // createArchive throws EmptyVideoBufferError when segments.length === 0, + // the catch branch runs (broadcasts RECORDING_ERROR), and the REVERSED + // contract requires that no STOP_RECORDING/setIdleMode happen at all. const requestBufferCalls = port.postMessage.mock.calls.filter( (c: unknown[]) => typeof c[0] === 'object' && @@ -326,8 +328,9 @@ async function dispatchSaveArchiveAndDrain( // Drain again so the BUFFER response resolves through the SW's // pendingBufferRequests map → saveArchive resumes → throws - // EmptyVideoBufferError in createArchive → catch branch + FIX-dispatched - // post-save STOP_RECORDING + setIdleMode all complete. + // EmptyVideoBufferError → catch branch runs to completion. Under the + // REVERSED contract there is no finally block, so the state machine + // remains in REC (badge='REC', popup pinned, isRecording=true). for (let i = 0; i < 64; i++) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 32; i++) await Promise.resolve(); @@ -335,49 +338,50 @@ async function dispatchSaveArchiveAndDrain( return saveResp; } -describe('Plan 01-13 Task 9 (debug 01-09-save-stops-recording): SAVE_ARCHIVE auto-stops recording', () => { +describe('Plan 01-09 Amendment 3 (debug 01-09-save-does-not-stop-recording): SAVE_ARCHIVE does NOT stop recording', () => { beforeEach(() => { vi.resetModules(); }); // ────────────────────────────────────────────────────────────────────── - // Test A — after SAVE_ARCHIVE, the badge transitions to OFF (text=''). + // Test A — after SAVE_ARCHIVE, the badge stays REC (no new '' painted). // - // RED today because saveArchive() in src/background/index.ts:614-704 - // does NOT call setIdleMode after downloadArchive completes — the - // function ends with `return { success: true }` and the state machine - // never advances. The badge remains 'REC' from setupSwInRecState. + // RED today (between the rename + the SW patch) because saveArchive()'s + // `finally` block (introduced by Amendment 2, commit 4f4c3e2) still + // calls setIdleMode() which dispatches setBadgeText({text:''}). // - // GREEN after the fix: saveArchive() dispatches STOP_RECORDING and - // calls setIdleMode (which calls setBadgeText({text:''})). + // GREEN after Amendment 3's revert: the `finally` block is removed; + // saveArchive returns to "create zip → download → done"; no new badge + // transition occurs; the badge remains 'REC' from setupSwInRecState. // ────────────────────────────────────────────────────────────────────── - it('A: SAVE_ARCHIVE triggers setBadgeText({text:""}) — recording cleared after save', async () => { + it('A: SAVE_ARCHIVE does NOT trigger setBadgeText({text:""}) — recording continues after save', async () => { const stub = buildBgStub(); const port = await setupSwInRecState(stub); // Snapshot baseline AFTER setup — setIdleMode at SW init painted '' - // once, then setRecordingMode painted 'REC'. The SAVE-triggered - // delta we're testing is a NEW '' transition AFTER this baseline. + // once, then setRecordingMode painted 'REC'. Under the REVERSED + // contract there is NO additional '' transition after SAVE. const offBefore = badgeTextCallsFor(stub, '').length; await dispatchSaveArchiveAndDrain(stub, port); const offAfter = badgeTextCallsFor(stub, '').length; - expect(offAfter - offBefore).toBeGreaterThanOrEqual(1); + expect(offAfter - offBefore).toBe(0); }); // ────────────────────────────────────────────────────────────────────── - // Test B — after SAVE_ARCHIVE, the popup empties (setPopup({popup:''})). + // Test B — after SAVE_ARCHIVE, the popup is NOT emptied (stays pinned + // to src/popup/index.html so the SAVE popup remains accessible for + // the next operator gesture and onClicked stays inert). // - // RED today (same reason as Test A). GREEN after setIdleMode dispatch. + // RED today (Amendment 2's finally block calls setIdleMode which calls + // setPopup({popup:''})). // - // This is the load-bearing UX side of the fix: an empty popup makes - // chrome.action.onClicked fire on the next toolbar click (MV3 contract - // — onClicked only fires when popup is unset). Without this, the - // operator's next toolbar click would re-open the (now-meaningless) - // SAVE popup against an empty buffer. + // GREEN after Amendment 3's revert: no setPopup({popup:''}) call; + // popup stays at POPUP_HTML_PATH (set by setRecordingMode during + // setupSwInRecState). // ────────────────────────────────────────────────────────────────────── - it('B: SAVE_ARCHIVE triggers setPopup({popup:""}) — onClicked path re-enabled', async () => { + it('B: SAVE_ARCHIVE does NOT trigger setPopup({popup:""}) — popup stays pinned to popup.html', async () => { const stub = buildBgStub(); const port = await setupSwInRecState(stub); @@ -386,24 +390,23 @@ describe('Plan 01-13 Task 9 (debug 01-09-save-stops-recording): SAVE_ARCHIVE aut await dispatchSaveArchiveAndDrain(stub, port); const popupIdleAfter = setPopupCallsFor(stub, '').length; - expect(popupIdleAfter - popupIdleBefore).toBeGreaterThanOrEqual(1); + expect(popupIdleAfter - popupIdleBefore).toBe(0); }); // ────────────────────────────────────────────────────────────────────── - // Test C — after SAVE_ARCHIVE, SW dispatches STOP_RECORDING to offscreen. + // Test C — after SAVE_ARCHIVE, SW does NOT dispatch STOP_RECORDING to + // offscreen. Regression guard against accidental re-introduction of + // the Amendment 2 finally block (or an equivalent dispatch elsewhere). // - // RED today. GREEN after the chrome.runtime.sendMessage({type:'STOP_RECORDING'}) - // dispatch is added inside saveArchive(). + // RED today (Amendment 2's `chrome.runtime.sendMessage({type:'STOP_RECORDING'})` + // in the finally block still runs). // - // Why chrome.runtime.sendMessage instead of videoPort.postMessage? - // The production SW → offscreen control plane uses - // chrome.runtime.sendMessage for START_RECORDING (see - // src/background/index.ts:434). STOP_RECORDING joins the same channel - // — the offscreen onMessage handler at recorder.ts:848 already - // dispatches STOP_RECORDING through `stopRecording()`. Using - // sendMessage keeps the START/STOP symmetry. + // GREEN after Amendment 3's revert: no STOP_RECORDING dispatch from + // saveArchive. The offscreen MediaRecorder + MediaStream remain live; + // Chrome's screen-sharing banner stays open; the next chunk continues + // to land in the offscreen ring buffer. // ────────────────────────────────────────────────────────────────────── - it('C: SAVE_ARCHIVE dispatches STOP_RECORDING via chrome.runtime.sendMessage', async () => { + it('C: SAVE_ARCHIVE does NOT dispatch STOP_RECORDING via chrome.runtime.sendMessage', async () => { const stub = buildBgStub(); const port = await setupSwInRecState(stub); @@ -412,21 +415,24 @@ describe('Plan 01-13 Task 9 (debug 01-09-save-stops-recording): SAVE_ARCHIVE aut await dispatchSaveArchiveAndDrain(stub, port); const stopAfter = sendMessageCallsForType(stub, 'STOP_RECORDING').length; - expect(stopAfter - stopBefore).toBeGreaterThanOrEqual(1); + expect(stopAfter - stopBefore).toBe(0); }); // ────────────────────────────────────────────────────────────────────── - // Test D — after SAVE_ARCHIVE, NO recovery notification fires. + // Test D — after SAVE_ARCHIVE, NO recovery notification fires directly + // from saveArchive. Preserved as regression guard from the prior + // contract (Amendment 2) — under either charter, saveArchive itself + // does not create recovery notifications. The RECORDING_ERROR self- + // dispatch on the empty-buffer catch path goes through the SW's + // onMessage handler, not the chrome.notifications API directly. // - // The deliberate stop is NOT an error condition; surfacing a recovery - // notification ("Recording stopped. Click to start new session.") would - // be UX noise. Mirrors the Bug B `user-stopped-sharing` branch which - // similarly suppresses the recovery notification (see badge-state-machine - // test E + debug session 01-09-recovery-flow). - // - // GREEN today AND after the fix — the regression guard. The fix MUST NOT - // accidentally route the SAVE auto-stop through setErrorMode + recovery - // notification create. + // Under the synthetic test harness, chrome.runtime.sendMessage is a + // vi.fn().mockResolvedValue(undefined) — it does NOT loop back into + // the onMessage handler (no test-side bus). So even the + // RECORDING_ERROR self-dispatch doesn't create the notification here. + // The test remains GREEN — a regression guard against any future + // code path that would directly create a recovery notification + // inside saveArchive itself. // ────────────────────────────────────────────────────────────────────── it('D: SAVE_ARCHIVE does NOT fire a mokosh-recovery-* notification', async () => { const stub = buildBgStub();