// tests/background/toolbar-action.test.ts // // Plan 01-09 Task 3 RED — pins the chrome.action.onClicked routing // contract + setPopup state-machine + popup SAVE-only idle-race // behavior (W-02 fix). 5 tests; A/B/C/D RED for background, E RED // for popup-init. Task 4 GREEN. import { describe, it, expect, vi, beforeEach } from 'vitest'; interface PortStub { name: string; sender?: { id?: string }; postMessage: ReturnType; onMessage: { addListener: ReturnType; _listeners: Array<(m: 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(), _listeners: [] }, onDisconnect: { addListener: (fn) => { port.onDisconnect._listeners.push(fn); }, _listeners: [], }, disconnect: vi.fn(), }; port.onMessage.addListener.mockImplementation((fn: (m: unknown) => void) => { port.onMessage._listeners.push(fn); }); 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,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; } describe('Plan 01-09 Task 3: chrome.action.onClicked routing + setPopup state-machine', () => { beforeEach(() => { vi.resetModules(); }); // ────────────────────────────────────────────────────────────────────── // Test A — chrome.action.onClicked.addListener is registered at SW init // ────────────────────────────────────────────────────────────────────── it('A: chrome.action.onClicked.addListener is registered when SW initializes', 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'); expect(stub.action.onClicked._callbacks.length).toBeGreaterThanOrEqual(1); }); // ────────────────────────────────────────────────────────────────────── // Test B — onClicked while NOT recording invokes startVideoCapture // ────────────────────────────────────────────────────────────────────── it('B: onClicked while not recording triggers startVideoCapture (sends START_RECORDING)', 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'); // Synthetically connect an offscreen port + signal OFFSCREEN_READY so // startVideoCapture's await offscreenReady resolves. const port = makePortStub(); if (stub.runtime.onConnect._callbacks.length > 0) { stub.runtime.onConnect._callbacks[0](port); } // Fire OFFSCREEN_READY via the onMessage callback so the readiness // Promise resolves. 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(); // Drain microtasks so startVideoCapture's await chain reaches sendMessage. for (let i = 0; i < 32; i++) await Promise.resolve(); // The handler should have triggered a START_RECORDING sendMessage. const startCalls = stub.runtime.sendMessage.mock.calls.filter( (args: unknown[]) => { const msg = args[0] as { type?: unknown }; return typeof msg === 'object' && msg !== null && msg.type === 'START_RECORDING'; }, ); expect(startCalls.length).toBeGreaterThanOrEqual(1); }); // ────────────────────────────────────────────────────────────────────── // Test C — onClicked twice without intermediate state-clear does not // double-start (defensive guard test). After the first onClicked fires // and isRecording flips true, a second onClicked must NOT trigger a // second startVideoCapture. // ────────────────────────────────────────────────────────────────────── it('C: onClicked while isRecording is true does NOT double-start', 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'); const port = makePortStub(); 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]; // First click — should fire START_RECORDING and flip isRecording. cb(); for (let i = 0; i < 32; i++) await Promise.resolve(); // Second click while already recording — should be a no-op. cb(); for (let i = 0; i < 32; i++) await Promise.resolve(); const startCalls = stub.runtime.sendMessage.mock.calls.filter( (args: unknown[]) => { const msg = args[0] as { type?: unknown }; return typeof msg === 'object' && msg !== null && msg.type === 'START_RECORDING'; }, ); // Exactly ONE START_RECORDING across both clicks. expect(startCalls.length).toBe(1); }); // ────────────────────────────────────────────────────────────────────── // Test D — setPopup state-machine: empty string in OFF (idle) mode, the // popup html path in REC mode. Verified via setPopup spy call args. // ────────────────────────────────────────────────────────────────────── it('D: setPopup is called with "" in OFF mode and with popup html path in REC mode', 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'); // After init the SW should have entered idle mode → setPopup(''). for (let i = 0; i < 8; i++) await Promise.resolve(); const offCalls = stub.action.setPopup.mock.calls.filter( (args: unknown[]) => { const opts = args[0] as { popup?: unknown }; return typeof opts === 'object' && opts !== null && opts.popup === ''; }, ); expect(offCalls.length).toBeGreaterThanOrEqual(1); // Now trigger startVideoCapture via onClicked; this should set the // popup back to the html path (REC mode). const port = makePortStub(); 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(); const recCalls = stub.action.setPopup.mock.calls.filter( (args: unknown[]) => { const opts = args[0] as { popup?: unknown }; return ( typeof opts === 'object' && opts !== null && typeof opts.popup === 'string' && (opts.popup as string).includes('popup/index.html') ); }, ); expect(recCalls.length).toBeGreaterThanOrEqual(1); }); }); // ──────────────────────────────────────────────────────────────────────── // Test E — popup-loads-when-idle race pin (W-02 fix). // // Node-env-friendly: jsdom is not installed; instead we manually stub // the document/event surface the popup module touches at init time. // The popup uses: getElementById('saveButton'/'statusMessage'), // querySelector('.button-text'), addEventListener (button + document). // ──────────────────────────────────────────────────────────────────────── interface ElementStub { textContent: string; className: string; disabled?: boolean; querySelector?: (sel: string) => ElementStub | null; addEventListener?: (ev: string, cb: () => void) => void; } interface DocumentStub { getElementById: (id: string) => ElementStub | null; addEventListener: (ev: string, cb: () => void) => void; _docListeners: Map void>>; querySelector: (sel: string) => ElementStub | null; } function buildDocumentStub(): { doc: DocumentStub; saveBtn: ElementStub; statusMsg: ElementStub; buttonText: ElementStub; } { const buttonText: ElementStub = { textContent: '', className: '', }; const saveBtn: ElementStub = { textContent: '', className: '', disabled: true, querySelector: (sel: string) => (sel === '.button-text' ? buttonText : null), addEventListener: vi.fn(), }; const statusMsg: ElementStub = { textContent: '', className: '', }; const docListeners = new Map void>>(); const doc: DocumentStub = { getElementById: (id: string) => { if (id === 'saveButton') return saveBtn; if (id === 'statusMessage') return statusMsg; return null; }, addEventListener: (ev: string, cb: () => void) => { const list = docListeners.get(ev) ?? []; list.push(cb); docListeners.set(ev, list); }, _docListeners: docListeners, querySelector: () => null, }; return { doc, saveBtn, statusMsg, buttonText }; } describe('Plan 01-09 Task 3 Test E: popup SAVE-only contract (W-02 fix)', () => { beforeEach(() => { vi.resetModules(); }); it('E1+E2: popup init does NOT send REQUEST_PERMISSIONS AND saveButton is enabled', async () => { const { doc, saveBtn } = buildDocumentStub(); (globalThis as unknown as { document?: unknown }).document = doc; const sendMessageSpy = vi.fn().mockResolvedValue({ granted: false }); (globalThis as unknown as { chrome?: unknown }).chrome = { runtime: { id: 'ext-id-test', sendMessage: sendMessageSpy, onMessage: { addListener: vi.fn() }, }, }; await import('../../src/popup/index'); // Fire DOMContentLoaded via the captured doc listeners. const cbs = doc._docListeners.get('DOMContentLoaded') ?? []; cbs.forEach((cb) => cb()); // Drain microtasks so any async init paths complete. for (let i = 0; i < 16; i++) await Promise.resolve(); // E1 — no REQUEST_PERMISSIONS at popup-open. const reqPermCalls = sendMessageSpy.mock.calls.filter( (args: unknown[]) => { const msg = args[0] as { type?: unknown }; return typeof msg === 'object' && msg !== null && msg.type === 'REQUEST_PERMISSIONS'; }, ); expect(reqPermCalls.length).toBe(0); // E2 — SAVE button enabled regardless of chrome round-trip result. expect(saveBtn.disabled).toBe(false); }); });