// 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); }); });