// tests/background/save-archive-does-not-stop-recording.test.ts // // 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 (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. // // 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 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 (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'; 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 — 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. * @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. // 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' && 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 → 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(); return saveResp; } 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 stays REC (no new '' painted). // // 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 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 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'. 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).toBe(0); }); // ────────────────────────────────────────────────────────────────────── // 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 (Amendment 2's finally block calls setIdleMode which calls // setPopup({popup:''})). // // 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 does NOT trigger setPopup({popup:""}) — popup stays pinned to popup.html', 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).toBe(0); }); // ────────────────────────────────────────────────────────────────────── // 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 (Amendment 2's `chrome.runtime.sendMessage({type:'STOP_RECORDING'})` // in the finally block still runs). // // 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 does NOT dispatch 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).toBe(0); }); // ────────────────────────────────────────────────────────────────────── // 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. // // 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(); 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); }); });