From cd83eb04987287ca19de74ede9b5041037193e7b Mon Sep 17 00:00:00 2001 From: Mark Date: Tue, 19 May 2026 13:17:19 +0200 Subject: [PATCH] =?UTF-8?q?test(01-09-save-stops):=20RED=20=E2=80=94=20SAV?= =?UTF-8?q?E=5FARCHIVE=20triggers=20STOP=5FRECORDING=20+=20setIdleMode=20+?= =?UTF-8?q?=20no=20recovery=20notif?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 01-13 Task 9 operator UAT closure. Operator 2026-05-19 empirical session: SAVE click downloaded zip but recording stayed live (badge=REC, sharing banner persisted, subsequent toolbar press re-opened SAVE-only popup). Operator pressed 4×, got 2 zips + confusion. Root cause: src/background/index.ts saveArchive() returns success after chrome.downloads.download without signaling offscreen to stop or transitioning the SW state machine — SPEC `Тз расширение фаза1.md` "one click MUST produce a self-contained archive" was over-extended to "always-on" framing by the implementation. Fix contract (RED today; GREEN after src/background/index.ts patch): A: setBadgeText({text:''}) called post-save (setIdleMode side effect) B: setPopup({popup:''}) called post-save (re-enables chrome.action.onClicked restart path per MV3 contract) C: chrome.runtime.sendMessage({type:'STOP_RECORDING'}) dispatched (offscreen recorder.ts:848 STOP_RECORDING case already wired — no offscreen-side change needed) D: NO mokosh-recovery-* notification fires (deliberate stop ≠ error; mirrors Bug B `user-stopped-sharing` suppression branch from .planning/debug/resolved/01-09-recovery-flow.md) Tests A/B/C RED (assertion errors `expected 0 >= 1`); Test D GREEN today as the regression guard against fix over-rotating to setErrorMode. 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. Empty-segments BUFFER causes createArchive → EmptyVideoBufferError → catch branch; the fix's STOP+IDLE dispatch MUST happen on both success and empty-buffer paths (operator UI contract: SAVE click = stop, success or empty alike). Debug record: .planning/debug/01-09-save-stops-recording.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .planning/debug/01-09-save-stops-recording.md | 149 ++++++ .../save-archive-stops-recording.test.ts | 442 ++++++++++++++++++ 2 files changed, 591 insertions(+) create mode 100644 .planning/debug/01-09-save-stops-recording.md create mode 100644 tests/background/save-archive-stops-recording.test.ts diff --git a/.planning/debug/01-09-save-stops-recording.md b/.planning/debug/01-09-save-stops-recording.md new file mode 100644 index 0000000..e1924d1 --- /dev/null +++ b/.planning/debug/01-09-save-stops-recording.md @@ -0,0 +1,149 @@ +--- +slug: 01-09-save-stops-recording +status: fixing +goal: find_and_fix +trigger: Plan 01-13 Task 9 operator empirical UAT — clicking SAVE downloads archive but recording continues; toolbar press re-opens SAVE-only popup; operator confusion (4× toolbar presses → 2 zips downloaded, 1 misclick) +tdd_mode: true +phase: 01-stabilize-video-pipeline +plan: 01-09 +opened: 2026-05-19 +orchestrator_diagnosed: true +--- + +# Debug session 01-09-save-stops-recording — SAVE_ARCHIVE must auto-stop recording (SPEC one-shot intent) + +## Problem statement + +During the Plan 01-13 Task 9 operator empirical UAT 2026-05-19, after the +operator clicked SAVE in the popup during an active recording session, the +extension produced the requested archive zip (downloaded to `~/Downloads`) +but the recording **did not stop**: the toolbar badge stayed `REC`, Chrome's +"Sharing your screen" banner remained visible, and subsequent toolbar +presses re-opened the SAVE-only popup with no feedback indicating the +prior SAVE had succeeded. The operator performed 4 toolbar presses across +~94 seconds; 2 zips downloaded (`session_report_2026-05-19_10-59-44.zip` + +`session_report_2026-05-19_11-01-18.zip`); the SW console captured 2 +`SAVE_ARCHIVE` receives + 2 `Archive download started` events, with the +recording stream alive throughout. + +## Root cause (orchestrator-diagnosed) + +`src/background/index.ts:744-748` SAVE_ARCHIVE handler invokes `saveArchive()`, +which produces the zip + calls `chrome.downloads.download`, then returns +`{success: true}` — but the handler **never signals offscreen to stop the +recording**. The recorder's mediaStream + MediaRecorder + rotation timer +all stay live. The operator's perception ("nothing happened") was correct: +the popup did not transition state after SAVE; the badge did not transition; +no notification fired. This violates the SPEC `Тз расширение фаза1.md` +"one click MUST produce a self-contained archive" — which implies one-shot +save-and-done semantics. The original design's "always-on" framing was +over-extended by engineering interpretation; the SPEC actually calls for +a deliberate stop after each save. + +## Fix design + +Two-file patch matched to the existing parallel Bug B (user-stopped-sharing) +pattern in `.planning/debug/resolved/01-09-recovery-flow.md`: + +### `src/background/index.ts` SAVE_ARCHIVE handler + +After `downloadArchive` returns (i.e. inside `saveArchive()` after +the download dispatch succeeds, before the function returns `{success: true}`): + +```typescript +// Plan 01-13 Task 9 operator UAT closure: SAVE = one-shot per SPEC intent. +// Send STOP_RECORDING to offscreen + transition state to IDLE so the +// operator sees a clean reset (badge clears, popup empties, sharing +// banner closes via track.stop()). Mirrors the Bug B user-stopped-sharing +// path: deliberate stop ≠ error; no recovery notification. +try { + await chrome.runtime.sendMessage({ type: 'STOP_RECORDING' }); +} catch (sendErr) { + // Offscreen may be unreachable mid-teardown; non-fatal — SW-side + // state still transitions to IDLE so the operator regains the + // restart path. + logger.warn('STOP_RECORDING dispatch after save failed:', sendErr); +} +isRecording = false; +setIdleMode(); +``` + +### `src/offscreen/recorder.ts` STOP_RECORDING handler + +ALREADY EXISTS at recorder.ts:848 — invokes `stopRecording()` which +correctly nulls mediaStream + stops the MediaRecorder + stops all tracks ++ clears the rotation timer. No edit needed on the offscreen side. + +**Edge case decided:** `resetBuffer` is intentionally NOT called by the SW +post-save because: +1. `stopRecording()` in recorder.ts does NOT call resetBuffer (line 559 + comment: "we do NOT call resetBuffer() here — the operator may want + to STOP and then SAVE the buffered footage"). +2. After SAVE_ARCHIVE the buffer's contents have already been consumed + into the zip; the operator's next session will start fresh because + `startRecording()` calls resetBuffer at line 318. +3. Calling resetBuffer here would be defensive overkill — the segments + are about to be overwritten on the next START anyway, and a no-op + resetBuffer on an already-cleared-at-next-start buffer wastes nothing. + +## TDD plan + +### RED unit tests (NEW file: `tests/background/save-archive-stops-recording.test.ts`) + +1. After `SAVE_ARCHIVE` receive completes → `chrome.action.setBadgeText` + called with `text=''` (setIdleMode side effect) +2. After `SAVE_ARCHIVE` receive → `chrome.action.setPopup` called with + `popup=''` (setIdleMode side effect — the popup-empties signal that + re-enables onClicked restart path) +3. After `SAVE_ARCHIVE` receive → `chrome.runtime.sendMessage` called + with `{type:'STOP_RECORDING'}` (the SW → offscreen STOP signal) +4. After `SAVE_ARCHIVE` receive → NO recovery notification fired + (`chrome.notifications.create` not called with `mokosh-recovery-*` id) + +### A14 harness extension + +`tests/uat/extension-page-harness.ts` — add `assertA14` after assertA13: +- Pre-condition: A13 just dispatched a SAVE_ARCHIVE; recording state + after that save should now be IDLE (badge='', popup=''). +- Reads `chrome.action.getBadgeText({})` → expects `''` +- Reads `chrome.action.getPopup({})` → expects `''` (relative path empty; + Chrome returns empty string when setPopup was called with `''`) +- Snapshots `chrome.notifications.getAll()` keys → expects NO id with + `mokosh-recovery-` prefix added since A13 (verifies no recovery notif + fired on the SAVE auto-stop) + +Recommended simplification per spec: skip direct `isRecording` query +(no bridge op exposes it). The badge proxy is sufficient — production +state machine pairs `isRecording` updates with badge transitions +atomically (no state-machine path desyncs them). + +### Files touched + +- `tests/background/save-archive-stops-recording.test.ts` (NEW) +- `src/background/index.ts` (saveArchive() function — append STOP signal + + setIdleMode + isRecording=false) +- `tests/uat/extension-page-harness.ts` (assertA14 + global surface) +- `tests/uat/lib/harness-page-driver.ts` (driveA14) +- `tests/uat/harness.test.ts` (orchestrator — add A14 in sequence) + +Note: STOP_RECORDING is already in `MessageType` (src/shared/types.ts:14) +— no type-system surface change needed. + +## RED → GREEN evidence (to be filled in) + +- RED commit SHA: TBD +- GREEN commit SHA: TBD +- A14 commit SHA: TBD +- vitest count (target): >= 94 + 4 (new tests) = >= 98 GREEN +- `npm run test:uat` (target): 15/15 GREEN (was 14/14) + +## Follow-ups + +- Orchestrator runs pre-checkpoint bundle gates (SW CSP-safety, Node-globals + in SW chunk, DOM-globals not in SW chunk, Tier-1 hook-string grep over + dist/) BEFORE re-surfacing operator UAT. Per + `feedback-pre-checkpoint-bundle-gates.md` operator time = automation + cannot verify. +- Operator UAT after this lands should confirm: SAVE click → zip lands + + badge clears + sharing banner closes + popup empties + subsequent + toolbar click starts a NEW recording session (clean state machine). diff --git a/tests/background/save-archive-stops-recording.test.ts b/tests/background/save-archive-stops-recording.test.ts new file mode 100644 index 0000000..a7eb212 --- /dev/null +++ b/tests/background/save-archive-stops-recording.test.ts @@ -0,0 +1,442 @@ +// tests/background/save-archive-stops-recording.test.ts +// +// Plan 01-13 Task 9 operator UAT closure — debug session +// `.planning/debug/01-09-save-stops-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`) +// +// 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. +// +// 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. +// +// 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). + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +interface PortStub { + name: string; + sender?: { id?: string }; + postMessage: ReturnType; + onMessage: { + addListener: ReturnType; + removeListener: ReturnType; + _listeners: Array<(msg: unknown) => void>; + }; + onDisconnect: { + addListener: (fn: () => void) => void; + _listeners: Array<() => void>; + }; + disconnect: ReturnType; +} + +function makePortStub(name = 'video-keepalive'): PortStub { + const port: PortStub = { + name, + sender: { id: 'ext-id-test' }, + postMessage: vi.fn(), + onMessage: { + addListener: vi.fn(), + removeListener: vi.fn(), + _listeners: [], + }, + onDisconnect: { + addListener: (fn: () => void) => { + port.onDisconnect._listeners.push(fn); + }, + _listeners: [], + }, + disconnect: vi.fn(), + }; + port.onMessage.addListener.mockImplementation( + (fn: (msg: unknown) => void) => { + port.onMessage._listeners.push(fn); + }, + ); + port.onMessage.removeListener.mockImplementation( + (fn: (msg: unknown) => void) => { + const idx = port.onMessage._listeners.indexOf(fn); + if (idx >= 0) { + port.onMessage._listeners.splice(idx, 1); + } + }, + ); + return port; +} + +interface OnConnectCallback { (port: PortStub): void; } +interface OnMessageCallback { + (msg: unknown, sender: { id?: string }, sendResponse: (r: unknown) => void): boolean; +} +interface OnClickedCallback { (info?: unknown): void | Promise; } +interface OnNotificationClickedCallback { (notificationId: string): void; } + +interface BgChromeStub { + runtime: { + id: string; + getURL: (p: string) => string; + getManifest: () => { version: string }; + sendMessage: ReturnType; + onMessage: { addListener: ReturnType; _callbacks: OnMessageCallback[] }; + onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[] }; + onInstalled: { addListener: ReturnType }; + onStartup: { addListener: (cb: () => void) => void; _callbacks: Array<() => void> }; + }; + offscreen: { + hasDocument?: () => Promise; + createDocument: ReturnType; + Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }; + }; + tabs: { + query: ReturnType; + sendMessage: ReturnType; + captureVisibleTab: ReturnType; + }; + downloads: { download: ReturnType }; + action: { + onClicked: { addListener: (cb: OnClickedCallback) => void; _callbacks: OnClickedCallback[] }; + setPopup: ReturnType; + setBadgeText: ReturnType; + setBadgeBackgroundColor: ReturnType; + setTitle: ReturnType; + }; + notifications: { + create: ReturnType; + clear: ReturnType; + onClicked: { + addListener: (cb: OnNotificationClickedCallback) => void; + _callbacks: OnNotificationClickedCallback[]; + }; + }; +} + +interface GlobalWithBgChrome { + chrome?: BgChromeStub; + indexedDB?: { deleteDatabase: ReturnType }; + fetch?: typeof fetch; +} + +function buildBgStub(): BgChromeStub { + const stub: BgChromeStub = { + runtime: { + id: 'ext-id-test', + getURL: (p) => `chrome-extension://ext-id-test/${p}`, + getManifest: () => ({ version: '1.0.0' }), + sendMessage: vi.fn().mockResolvedValue(undefined), + onMessage: { addListener: vi.fn(), _callbacks: [] }, + onConnect: { addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb), _callbacks: [] }, + onInstalled: { addListener: vi.fn() }, + onStartup: { addListener: (cb) => stub.runtime.onStartup._callbacks.push(cb), _callbacks: [] }, + }, + offscreen: { + hasDocument: async () => false, + createDocument: vi.fn().mockResolvedValue(undefined), + Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }, + }, + tabs: { + query: vi.fn().mockResolvedValue([{ id: 1, url: 'https://example.com', windowId: 100 }]), + sendMessage: vi.fn().mockResolvedValue({ events: [], userEvents: [] }), + captureVisibleTab: vi + .fn() + .mockResolvedValue( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', + ), + }, + downloads: { download: vi.fn().mockResolvedValue(1) }, + action: { + onClicked: { addListener: (cb) => stub.action.onClicked._callbacks.push(cb), _callbacks: [] }, + setPopup: vi.fn(), + setBadgeText: vi.fn(), + setBadgeBackgroundColor: vi.fn(), + setTitle: vi.fn(), + }, + notifications: { + create: vi.fn(), + clear: vi.fn(), + onClicked: { + addListener: (cb) => stub.notifications.onClicked._callbacks.push(cb), + _callbacks: [], + }, + }, + }; + stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => { + stub.runtime.onMessage._callbacks.push(cb); + }); + return stub; +} + +// Generic Promise-resolved fetch that returns a fake screenshot Blob. +// Mirrors `tests/background/request-id-protocol.test.ts:stubFetch` — +// captureScreenshot does `await fetch(dataUrl).blob()`. +async function stubFetch(_url: string): Promise { + const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); + return { + blob: async () => new Blob([png], { type: 'image/png' }), + } as unknown as Response; +} + +// Helper: filter setBadgeText calls by the text argument value. +function badgeTextCallsFor(stub: BgChromeStub, text: string): unknown[][] { + return stub.action.setBadgeText.mock.calls.filter( + (args: unknown[]) => { + const opts = args[0] as { text?: unknown }; + return typeof opts === 'object' && opts !== null && opts.text === text; + }, + ); +} + +// Helper: filter setPopup calls by popup-arg value. +function setPopupCallsFor(stub: BgChromeStub, popupArg: string): unknown[][] { + return stub.action.setPopup.mock.calls.filter( + (args: unknown[]) => { + const opts = args[0] as { popup?: unknown }; + return typeof opts === 'object' && opts !== null && opts.popup === popupArg; + }, + ); +} + +// Helper: filter chrome.runtime.sendMessage calls by message.type. +function sendMessageCallsForType(stub: BgChromeStub, type: string): unknown[][] { + return stub.runtime.sendMessage.mock.calls.filter( + (args: unknown[]) => { + const msg = args[0] as { type?: unknown }; + return typeof msg === 'object' && msg !== null && msg.type === type; + }, + ); +} + +// Helper: filter chrome.notifications.create calls whose first argument +// (the notification id) starts with `prefix`. +function notificationCreateCallsWithIdPrefix(stub: BgChromeStub, prefix: string): unknown[][] { + return stub.notifications.create.mock.calls.filter( + (args: unknown[]) => { + const id = args[0]; + return typeof id === 'string' && id.startsWith(prefix); + }, + ); +} + +/** + * Setup helper — loads the SW module, wires a synthetic port, signals + * offscreen ready, then starts a recording via toolbar onClicked so the + * SW is in REC state when the test dispatches SAVE_ARCHIVE. + * + * Returns the port (so tests can resolve the in-flight REQUEST_BUFFER + * with a synthetic BUFFER response). + */ +async function setupSwInRecState(stub: BgChromeStub): Promise { + (globalThis as unknown as GlobalWithBgChrome).chrome = stub; + (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; + (globalThis as unknown as GlobalWithBgChrome).fetch = stubFetch as unknown as typeof fetch; + + await import('../../src/background/index'); + for (let i = 0; i < 8; i++) await Promise.resolve(); + + const port = makePortStub(); + if (stub.runtime.onConnect._callbacks.length > 0) { + stub.runtime.onConnect._callbacks[0](port); + } + + // Signal offscreen ready so startVideoCapture's awaitOffscreenReady resolves. + const onMsgHandler = stub.runtime.onMessage._callbacks[0]; + onMsgHandler({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, vi.fn()); + for (let i = 0; i < 16; i++) await Promise.resolve(); + + // Trigger toolbar click → startVideoCapture → isRecording=true + badge=REC. + const onClickedCb = stub.action.onClicked._callbacks[0]; + await onClickedCb(); + for (let i = 0; i < 32; i++) await Promise.resolve(); + + return port; +} + +/** + * 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). + * + * @param stub - The chrome stub. + * @param port - The wired offscreen port. + * @returns The saveArchive sendResponse value (typically `{success:false, error:...}` + * under the empty-buffer branch). + */ +async function dispatchSaveArchiveAndDrain( + stub: BgChromeStub, + port: PortStub, +): Promise { + let saveResp: unknown = undefined; + const onMsgHandler = stub.runtime.onMessage._callbacks[0]; + onMsgHandler( + { type: 'SAVE_ARCHIVE' }, + { id: 'ext-id-test' }, + (r: unknown) => { + saveResp = r; + }, + ); + + // Drain microtasks so saveArchive reaches getVideoBufferFromOffscreen + // and posts REQUEST_BUFFER on the port. + for (let i = 0; i < 32; i++) await Promise.resolve(); + 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). + const requestBufferCalls = port.postMessage.mock.calls.filter( + (c: unknown[]) => + typeof c[0] === 'object' && + c[0] !== null && + (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER', + ); + if (requestBufferCalls.length > 0) { + const reqMsg = requestBufferCalls[0][0] as { requestId?: unknown }; + port.onMessage._listeners.forEach((fn) => + fn({ + type: 'BUFFER', + requestId: reqMsg.requestId, + segments: [], + }), + ); + } + + // 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. + 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(); + + return saveResp; +} + +describe('Plan 01-13 Task 9 (debug 01-09-save-stops-recording): SAVE_ARCHIVE auto-stops recording', () => { + beforeEach(() => { + vi.resetModules(); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test A — after SAVE_ARCHIVE, the badge transitions to OFF (text=''). + // + // 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. + // + // GREEN after the fix: saveArchive() dispatches STOP_RECORDING and + // calls setIdleMode (which calls setBadgeText({text:''})). + // ────────────────────────────────────────────────────────────────────── + it('A: SAVE_ARCHIVE triggers setBadgeText({text:""}) — recording cleared 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. + const offBefore = badgeTextCallsFor(stub, '').length; + + await dispatchSaveArchiveAndDrain(stub, port); + + const offAfter = badgeTextCallsFor(stub, '').length; + expect(offAfter - offBefore).toBeGreaterThanOrEqual(1); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test B — after SAVE_ARCHIVE, the popup empties (setPopup({popup:''})). + // + // RED today (same reason as Test A). GREEN after setIdleMode dispatch. + // + // 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. + // ────────────────────────────────────────────────────────────────────── + it('B: SAVE_ARCHIVE triggers setPopup({popup:""}) — onClicked path re-enabled', async () => { + const stub = buildBgStub(); + const port = await setupSwInRecState(stub); + + const popupIdleBefore = setPopupCallsFor(stub, '').length; + + await dispatchSaveArchiveAndDrain(stub, port); + + const popupIdleAfter = setPopupCallsFor(stub, '').length; + expect(popupIdleAfter - popupIdleBefore).toBeGreaterThanOrEqual(1); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test C — after SAVE_ARCHIVE, SW dispatches STOP_RECORDING to offscreen. + // + // RED today. GREEN after the chrome.runtime.sendMessage({type:'STOP_RECORDING'}) + // dispatch is added inside saveArchive(). + // + // 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. + // ────────────────────────────────────────────────────────────────────── + it('C: SAVE_ARCHIVE dispatches STOP_RECORDING via chrome.runtime.sendMessage', async () => { + const stub = buildBgStub(); + const port = await setupSwInRecState(stub); + + const stopBefore = sendMessageCallsForType(stub, 'STOP_RECORDING').length; + + await dispatchSaveArchiveAndDrain(stub, port); + + const stopAfter = sendMessageCallsForType(stub, 'STOP_RECORDING').length; + expect(stopAfter - stopBefore).toBeGreaterThanOrEqual(1); + }); + + // ────────────────────────────────────────────────────────────────────── + // Test D — after SAVE_ARCHIVE, NO recovery notification fires. + // + // 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. + // ────────────────────────────────────────────────────────────────────── + it('D: SAVE_ARCHIVE does NOT fire a mokosh-recovery-* notification', async () => { + const stub = buildBgStub(); + const port = await setupSwInRecState(stub); + + const recoveryBefore = notificationCreateCallsWithIdPrefix(stub, 'mokosh-recovery-').length; + + await dispatchSaveArchiveAndDrain(stub, port); + + const recoveryAfter = notificationCreateCallsWithIdPrefix(stub, 'mokosh-recovery-').length; + expect(recoveryAfter - recoveryBefore).toBe(0); + }); +});