From 2d7ff7d4e326c8e3998e296bc24c430caa5df4df Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 17 May 2026 15:27:41 +0200 Subject: [PATCH] =?UTF-8?q?test(01-09):=20RED=20=E2=80=94=20toolbar-onClic?= =?UTF-8?q?ked=20+=20badge=20state=20machine=20+=20onStartup=20notificatio?= =?UTF-8?q?n=20+=20popup=20SAVE-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan 01-09 Task 3 RED — 13 tests across 3 new files: tests/background/toolbar-action.test.ts (5 tests): A: chrome.action.onClicked.addListener registered at SW init B: onClicked while not recording triggers startVideoCapture C: onClicked while isRecording does NOT double-start D: setPopup('') in OFF mode, popup html path in REC mode E: popup init does NOT send REQUEST_PERMISSIONS + saveButton enabled (W-02 fix — without jsdom, uses node-env document stub) tests/background/badge-state-machine.test.ts (4 tests): A: REC state = text 'REC' + #00C853 green + Recording title B: OFF state = text '' + #D32F2F red + Not recording title (fired at SW init via initialize → setIdleMode) C: ERROR state = text 'ERR' + #F9A825 yellow + error title D: RECORDING_ERROR onMessage triggers setBadgeText('ERR') within microtask tests/background/onstartup-notification.test.ts (4 tests): A: chrome.runtime.onStartup.addListener registered at SW load B: onStartup fires exactly one mokosh-startup- notification with basic type + 'Mokosh ready' title + Click-instructed message C: notifications.onClicked with mokosh- id clears + triggers START_RECORDING D: RECORDING_ERROR onMessage triggers mokosh-recovery- notification Task 4 will flip all 13 to GREEN by adding the listeners + state machine + helpers in src/background/index.ts, popup SAVE-only, manifest update. Deviation Rule 3: jsdom not in node_modules; refactored Test E to use a node-env document stub instead of @vitest-environment jsdom pragma. --- tests/background/badge-state-machine.test.ts | 275 +++++++++++++ .../background/onstartup-notification.test.ts | 234 +++++++++++ tests/background/toolbar-action.test.ts | 378 ++++++++++++++++++ 3 files changed, 887 insertions(+) create mode 100644 tests/background/badge-state-machine.test.ts create mode 100644 tests/background/onstartup-notification.test.ts create mode 100644 tests/background/toolbar-action.test.ts diff --git a/tests/background/badge-state-machine.test.ts b/tests/background/badge-state-machine.test.ts new file mode 100644 index 0000000..688a160 --- /dev/null +++ b/tests/background/badge-state-machine.test.ts @@ -0,0 +1,275 @@ +// 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'). + +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; + }, + ); +} + +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); + // setBadgeBackgroundColor was called with a green-ish color. + const greenCalls = 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' && /^#00[Cc]853$/.test(color); + }, + ); + expect(greenCalls.length).toBeGreaterThanOrEqual(1); + // setTitle was called with the recording title. + 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/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); + }); +}); diff --git a/tests/background/onstartup-notification.test.ts b/tests/background/onstartup-notification.test.ts new file mode 100644 index 0000000..6860c16 --- /dev/null +++ b/tests/background/onstartup-notification.test.ts @@ -0,0 +1,234 @@ +// 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); + }); +}); diff --git a/tests/background/toolbar-action.test.ts b/tests/background/toolbar-action.test.ts new file mode 100644 index 0000000..6f369d1 --- /dev/null +++ b/tests/background/toolbar-action.test.ts @@ -0,0 +1,378 @@ +// 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); + }); +});