// tests/background/onstartup-notification.test.ts // // Plan 01-09 Task 3 RED — pins the onStartup notification + recovery // notification + notifications.onClicked contract. 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; } describe('Plan 01-09 Task 3: chrome.runtime.onStartup notification contract', () => { beforeEach(() => { vi.resetModules(); }); // ────────────────────────────────────────────────────────────────────── // Test A — chrome.runtime.onStartup.addListener is registered at SW load // ────────────────────────────────────────────────────────────────────── it('A: chrome.runtime.onStartup.addListener is registered when SW module loads', 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.runtime.onStartup._callbacks.length).toBeGreaterThanOrEqual(1); }); // ────────────────────────────────────────────────────────────────────── // Test B — onStartup fires exactly one chrome.notifications.create call // with the mokosh-startup- prefix and the expected message shape. // ────────────────────────────────────────────────────────────────────── it('B: firing onStartup creates exactly one mokosh-startup- notification', 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 createCallsBefore = stub.notifications.create.mock.calls.length; // Fire onStartup. const startupCb = stub.runtime.onStartup._callbacks[0]; startupCb(); for (let i = 0; i < 8; i++) await Promise.resolve(); const startupCalls = stub.notifications.create.mock.calls .slice(createCallsBefore) .filter((args: unknown[]) => { const id = args[0] as unknown; return typeof id === 'string' && id.startsWith('mokosh-startup-'); }); expect(startupCalls.length).toBe(1); // Validate the options shape. const opts = startupCalls[0][1] as { type?: unknown; title?: unknown; message?: unknown; }; expect(opts.type).toBe('basic'); expect(opts.title).toBe('Mokosh ready'); expect(typeof opts.message).toBe('string'); expect(/Click/i.test(String(opts.message))).toBe(true); }); // ────────────────────────────────────────────────────────────────────── // Test C — chrome.notifications.onClicked with a mokosh-startup- id // calls clear() AND triggers startVideoCapture (sendMessage START_RECORDING). // ────────────────────────────────────────────────────────────────────── it('C: notifications.onClicked with mokosh- id clears + triggers 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'); // Set up port + OFFSCREEN_READY so startVideoCapture proceeds. 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()); // Synthesize the notification click. const notifCb = stub.notifications.onClicked._callbacks[0]; notifCb('mokosh-startup-1234567890'); for (let i = 0; i < 32; i++) await Promise.resolve(); // clear() was called with the same id. const clearCalls = stub.notifications.clear.mock.calls.filter( (args: unknown[]) => args[0] === 'mokosh-startup-1234567890', ); expect(clearCalls.length).toBeGreaterThanOrEqual(1); // START_RECORDING sent downstream. 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 D — receiving RECORDING_ERROR via onMessage triggers a recovery // notification (id prefix 'mokosh-recovery-'). // ────────────────────────────────────────────────────────────────────── it('D: RECORDING_ERROR onMessage triggers a mokosh-recovery- notification', 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 createCallsBefore = stub.notifications.create.mock.calls.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 recoveryCalls = stub.notifications.create.mock.calls .slice(createCallsBefore) .filter((args: unknown[]) => { const id = args[0] as unknown; return typeof id === 'string' && id.startsWith('mokosh-recovery-'); }); expect(recoveryCalls.length).toBeGreaterThanOrEqual(1); }); });