// tests/background/badge-state-machine.test.ts // // Plan 01-09 Task 3 RED — pins the 3-state badge contract: // REC (green, 'REC', recording title) // OFF (red, '', idle title) // ERROR (yellow, 'ERR', error title) // // Plus Test D: receiving RECORDING_ERROR via onMessage triggers // setBadgeState('ERROR') (asserted via setBadgeText spy receiving 'ERR'). // // Bug B fix (debug session 01-09-recovery-flow) — Tests E + F: // `RECORDING_ERROR{error:'user-stopped-sharing'}` must route through // setIdleMode (badge OFF, popup empties) instead of setErrorMode, because // the operator-initiated stop is NOT an error — leaving popup in SAVE-only // + badge in ERROR locks the operator out of restart (chrome.action.onClicked // is gated on popup absence; SAVE-only popup wins the click forever). // Other error codes preserve setErrorMode routing (defensive fallback for // genuine capture failures: codec-unsupported, wrong-display-surface, etc.). import { describe, it, expect, vi, beforeEach } from 'vitest'; interface OnConnectCallback { (port: unknown): 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 }; } 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,xx'), }, 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; } // 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 setBadgeBackgroundColor calls by hex color (case-insensitive). function badgeColorCallsFor(stub: BgChromeStub, hexPattern: RegExp): unknown[][] { return stub.action.setBadgeBackgroundColor.mock.calls.filter( (args: unknown[]) => { const opts = args[0] as { color?: unknown }; const color = typeof opts === 'object' && opts !== null ? opts.color : undefined; return typeof color === 'string' && hexPattern.test(color); }, ); } // 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; }, ); } describe('Plan 01-09 Task 3: badge state machine REC/OFF/ERROR contract', () => { beforeEach(() => { vi.resetModules(); }); // ────────────────────────────────────────────────────────────────────── // Test A — REC state: setBadgeText('REC') + setBadgeBackgroundColor(green) + setTitle // // Triggered by chrome.action.onClicked firing startVideoCapture // successfully (which calls setRecordingMode → setBadgeState('REC')). // ────────────────────────────────────────────────────────────────────── it('A: setBadgeState("REC") sets text="REC" + green background + recording title', async () => { const stub = buildBgStub(); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; await import('../../src/background/index'); // Connect a port + signal offscreen ready so startVideoCapture completes. const port = { name: 'video-keepalive', sender: { id: 'ext-id-test' }, postMessage: vi.fn(), onMessage: { addListener: vi.fn() }, onDisconnect: { addListener: vi.fn() }, disconnect: vi.fn(), }; if (stub.runtime.onConnect._callbacks.length > 0) { stub.runtime.onConnect._callbacks[0](port); } const onMsgHandler = stub.runtime.onMessage._callbacks[0]; onMsgHandler({ type: 'OFFSCREEN_READY' }, { id: 'ext-id-test' }, vi.fn()); const cb = stub.action.onClicked._callbacks[0]; cb(); for (let i = 0; i < 32; i++) await Promise.resolve(); // setBadgeText was called with text='REC'. expect(badgeTextCallsFor(stub, 'REC').length).toBeGreaterThanOrEqual(1); // Plan 01-12 Wave 4: BADGE_REC_COLOR migrated from #00C853 (material // green) to #b2543d (--mks-madder-600 per D-04 loom palette). The // assertion below is updated lockstep. const recColorCalls = stub.action.setBadgeBackgroundColor.mock.calls.filter( (args: unknown[]) => { const opts = args[0] as { color?: unknown }; const color = typeof opts === 'object' && opts !== null ? opts.color : undefined; return typeof color === 'string' && /^#b2543d$/i.test(color); }, ); expect(recColorCalls.length).toBeGreaterThanOrEqual(1); // setTitle was called with the recording title. Plan 01-12 Wave 4: // setBadgeState now reads chrome.i18n.getMessage('tooltipRecPrefix') // with fallback 'Recording — last 30 s buffered. Click to save.'. // Tests without a chrome.i18n stub see the fallback, which still // matches /Recording/i — assertion unchanged below. const titleCalls = stub.action.setTitle.mock.calls.filter( (args: unknown[]) => { const opts = args[0] as { title?: unknown }; const title = typeof opts === 'object' && opts !== null ? opts.title : undefined; return typeof title === 'string' && /Recording|recording/i.test(title); }, ); expect(titleCalls.length).toBeGreaterThanOrEqual(1); }); // ────────────────────────────────────────────────────────────────────── // Test B — OFF state on SW init (initialize → setIdleMode → setBadgeState('OFF')) // ────────────────────────────────────────────────────────────────────── it('B: setBadgeState("OFF") sets text="" + red background + idle title (called at init)', async () => { const stub = buildBgStub(); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; await import('../../src/background/index'); // Drain so initialize completes. for (let i = 0; i < 16; i++) await Promise.resolve(); // OFF text is empty string. expect(badgeTextCallsFor(stub, '').length).toBeGreaterThanOrEqual(1); const redCalls = stub.action.setBadgeBackgroundColor.mock.calls.filter( (args: unknown[]) => { const opts = args[0] as { color?: unknown }; const color = typeof opts === 'object' && opts !== null ? opts.color : undefined; return typeof color === 'string' && /^#D32F2F$/i.test(color); }, ); expect(redCalls.length).toBeGreaterThanOrEqual(1); const titleCalls = stub.action.setTitle.mock.calls.filter( (args: unknown[]) => { const opts = args[0] as { title?: unknown }; const title = typeof opts === 'object' && opts !== null ? opts.title : undefined; return typeof title === 'string' && /Not recording/i.test(title); }, ); expect(titleCalls.length).toBeGreaterThanOrEqual(1); }); // ────────────────────────────────────────────────────────────────────── // Test C — ERROR state: setBadgeText('ERR') + yellow background + error title. // ────────────────────────────────────────────────────────────────────── it('C: setBadgeState("ERROR") sets text="ERR" + yellow background + error title', async () => { const stub = buildBgStub(); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; await import('../../src/background/index'); for (let i = 0; i < 8; i++) await Promise.resolve(); // Trigger ERROR via RECORDING_ERROR onMessage path. const onMsgHandler = stub.runtime.onMessage._callbacks[0]; onMsgHandler( { type: 'RECORDING_ERROR', error: 'codec-unsupported' }, { id: 'ext-id-test' }, vi.fn(), ); for (let i = 0; i < 16; i++) await Promise.resolve(); expect(badgeTextCallsFor(stub, 'ERR').length).toBeGreaterThanOrEqual(1); const yellowCalls = stub.action.setBadgeBackgroundColor.mock.calls.filter( (args: unknown[]) => { const opts = args[0] as { color?: unknown }; const color = typeof opts === 'object' && opts !== null ? opts.color : undefined; return typeof color === 'string' && /^#F9A825$/i.test(color); }, ); expect(yellowCalls.length).toBeGreaterThanOrEqual(1); const titleCalls = stub.action.setTitle.mock.calls.filter( (args: unknown[]) => { const opts = args[0] as { title?: unknown }; const title = typeof opts === 'object' && opts !== null ? opts.title : undefined; return typeof title === 'string' && /error/i.test(title); }, ); expect(titleCalls.length).toBeGreaterThanOrEqual(1); }); // ────────────────────────────────────────────────────────────────────── // Test D — RECORDING_ERROR via onMessage triggers ERROR state (already // covered by Test C but pinned here as a separate dispatch assertion). // ────────────────────────────────────────────────────────────────────── it('D: RECORDING_ERROR onMessage triggers setBadgeText("ERR") within a microtask', async () => { const stub = buildBgStub(); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; await import('../../src/background/index'); for (let i = 0; i < 8; i++) await Promise.resolve(); // Snapshot call count before — only the init OFF call should have fired. const errCallsBefore = badgeTextCallsFor(stub, 'ERR').length; const onMsgHandler = stub.runtime.onMessage._callbacks[0]; onMsgHandler( { type: 'RECORDING_ERROR', error: 'capture-failed' }, { id: 'ext-id-test' }, vi.fn(), ); for (let i = 0; i < 16; i++) await Promise.resolve(); const errCallsAfter = badgeTextCallsFor(stub, 'ERR').length; expect(errCallsAfter - errCallsBefore).toBeGreaterThanOrEqual(1); }); // ────────────────────────────────────────────────────────────────────── // Test E — Bug B (debug 01-09-recovery-flow) — operator-initiated // stop-sharing routes to IDLE, NOT to ERROR. // // Contract: when the operator clicks Chrome's "Stop sharing" banner // mid-recording, the offscreen recorder broadcasts // RECORDING_ERROR{ error: 'user-stopped-sharing' } // (src/offscreen/recorder.ts: onUserStoppedSharing emits this exact // payload after resetBuffer + track release). // // The SW MUST route this through setIdleMode (badge OFF + popup '') // rather than setErrorMode, because: // 1. operator-initiated stop is not an error condition; // 2. setErrorMode leaves the popup pointing at src/popup/index.html // (SAVE-only), so toolbar.onClicked CANNOT re-fire — the popup // wins the click and the operator has no restart path; // 3. resetBuffer has already cleared the offscreen buffer, so SAVE // mode is meaningless (nothing to save). // // Assertions on the post-dispatch state machine: // - setBadgeText was called with '' (OFF text), NOT with 'ERR' (after // the snapshot baseline at init). // - setBadgeBackgroundColor was called with the OFF/red palette // (#D32F2F), NOT with the ERROR/yellow palette (#F9A825). // - setPopup was called with '' (idle popup) so the next toolbar // click fires chrome.action.onClicked (the restart path). // // RED today because src/background/index.ts:725 routes ALL error codes // through setErrorMode unconditionally. // ────────────────────────────────────────────────────────────────────── it('E: RECORDING_ERROR{error:"user-stopped-sharing"} routes to IDLE — badge OFF, popup empties', async () => { const stub = buildBgStub(); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; await import('../../src/background/index'); for (let i = 0; i < 8; i++) await Promise.resolve(); // Snapshot baseline AFTER init (which paints OFF). Bug B assertions // are about the DELTA introduced by the user-stopped-sharing dispatch. const offTextBefore = badgeTextCallsFor(stub, '').length; const errTextBefore = badgeTextCallsFor(stub, 'ERR').length; const redColorBefore = badgeColorCallsFor(stub, /^#D32F2F$/i).length; const yellowColorBefore = badgeColorCallsFor(stub, /^#F9A825$/i).length; const popupIdleBefore = setPopupCallsFor(stub, '').length; const popupHtmlBefore = setPopupCallsFor(stub, 'src/popup/index.html').length; // Dispatch the operator-stopped-sharing payload that the offscreen // recorder emits via onUserStoppedSharing. const onMsgHandler = stub.runtime.onMessage._callbacks[0]; onMsgHandler( { type: 'RECORDING_ERROR', error: 'user-stopped-sharing' }, { id: 'ext-id-test' }, vi.fn(), ); for (let i = 0; i < 16; i++) await Promise.resolve(); const offTextAfter = badgeTextCallsFor(stub, '').length; const errTextAfter = badgeTextCallsFor(stub, 'ERR').length; const redColorAfter = badgeColorCallsFor(stub, /^#D32F2F$/i).length; const yellowColorAfter = badgeColorCallsFor(stub, /^#F9A825$/i).length; const popupIdleAfter = setPopupCallsFor(stub, '').length; const popupHtmlAfter = setPopupCallsFor(stub, 'src/popup/index.html').length; // Positive: an OFF transition fired (empty badge text + red color + // popup empties). expect(offTextAfter - offTextBefore).toBeGreaterThanOrEqual(1); expect(redColorAfter - redColorBefore).toBeGreaterThanOrEqual(1); expect(popupIdleAfter - popupIdleBefore).toBeGreaterThanOrEqual(1); // Negative: NO ERROR transition fired (no 'ERR' badge text, no // yellow color, no popup-html re-set). expect(errTextAfter - errTextBefore).toBe(0); expect(yellowColorAfter - yellowColorBefore).toBe(0); expect(popupHtmlAfter - popupHtmlBefore).toBe(0); }); // ────────────────────────────────────────────────────────────────────── // Test F — Bug B preservation guard: any RECORDING_ERROR whose code is // NOT 'user-stopped-sharing' continues to route through setErrorMode. // // This is the defensive-fallback half of the conditional routing patch // — it pins the contract that genuine capture failures (codec- // unsupported, wrong-display-surface, capture-failed, permission-denied, // empty-video-buffer, unknown, etc.) still raise the badge to ERROR // (yellow 'ERR'). Without this test the fix could over-rotate (route // ALL codes to IDLE) and silently lose the operator-facing error // surface for genuine failures. // // Uses 'codec-unsupported' as the representative non-stop code (a // realistic capture failure from src/offscreen/recorder.ts // CaptureErrorCode union, exercised in tests/offscreen/codec-check.test.ts). // // GREEN today (the current handler unconditionally raises ERROR), and // MUST remain GREEN after the Bug B patch — this is the regression // pin proving the fix didn't over-rotate. // ────────────────────────────────────────────────────────────────────── it('F: RECORDING_ERROR{error:"codec-unsupported"} still routes to ERROR — badge ERR + yellow (preserved)', async () => { const stub = buildBgStub(); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; await import('../../src/background/index'); for (let i = 0; i < 8; i++) await Promise.resolve(); // Snapshot baseline AFTER init. const errTextBefore = badgeTextCallsFor(stub, 'ERR').length; const yellowColorBefore = badgeColorCallsFor(stub, /^#F9A825$/i).length; const popupHtmlBefore = setPopupCallsFor(stub, 'src/popup/index.html').length; const onMsgHandler = stub.runtime.onMessage._callbacks[0]; onMsgHandler( { type: 'RECORDING_ERROR', error: 'codec-unsupported' }, { id: 'ext-id-test' }, vi.fn(), ); for (let i = 0; i < 16; i++) await Promise.resolve(); const errTextAfter = badgeTextCallsFor(stub, 'ERR').length; const yellowColorAfter = badgeColorCallsFor(stub, /^#F9A825$/i).length; const popupHtmlAfter = setPopupCallsFor(stub, 'src/popup/index.html').length; expect(errTextAfter - errTextBefore).toBeGreaterThanOrEqual(1); expect(yellowColorAfter - yellowColorBefore).toBeGreaterThanOrEqual(1); expect(popupHtmlAfter - popupHtmlBefore).toBeGreaterThanOrEqual(1); }); });