// tests/background/start-video-capture-no-tab.test.ts // // Plan 01-09 — Debug `01-09-notification-start-no-active-tab` // // Regression pin: startVideoCapture must NOT depend on the presence of // an active tab. Post Plan 01-09 D-01 (chrome.tabCapture → getDisplayMedia // in offscreen) the SW-side recording-start path has no functional need // for a tab — the only consumer of the legacy tab query was a log line. // // chrome.notifications.onClicked does NOT grant the activeTab permission // and the extension manifest carries no `tabs` permission (per // .planning/intel/01-11-SUMMARY.md follow-up backlog). So in production, // chrome.tabs.query({ active: true, currentWindow: true }) called from // inside the notifications.onClicked path resolves to `[]` (or a tab // stripped of `url` / `id`). The previous implementation rejected with // "No active tab found" before reaching the ensureOffscreen + START_RECORDING // dispatch — the operator-observed silent failure of the onStartup // notification CTA path. // // These tests pin both halves of the contract: // * empty tabs.query result → still dispatches START_RECORDING (no throw) // * url-less tab → still dispatches START_RECORDING (no throw) // // They are isomorphic to the action.onClicked path which today works // because activeTab is granted on the click gesture. Removing the dead // tab query equalises the two callers. 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(tabsQueryResult: unknown[]): 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: { // Mirror the production notifications.onClicked context: no activeTab // grant + no `tabs` permission → tabs.query returns `[]`. The // url-less-tab variant is supplied via the parameter. query: vi.fn().mockResolvedValue(tabsQueryResult), 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; } /** * Drive the SW startVideoCapture path via notifications.onClicked + a * connected video-keepalive port + an OFFSCREEN_READY onMessage event, * which is the same await-offscreenReady satisfaction that the existing * onstartup-notification.test.ts Test C uses. */ async function driveNotificationStart(stub: BgChromeStub): Promise { 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 click on a mokosh-* notification id. const notifCb = stub.notifications.onClicked._callbacks[0]; notifCb('mokosh-startup-1234567890'); // Drain microtasks so the entire async chain reaches sendMessage. for (let i = 0; i < 64; i++) await Promise.resolve(); } function countStartRecording(stub: BgChromeStub): number { return 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'; }).length; } describe('Plan 01-09 — startVideoCapture: no active-tab dependency', () => { beforeEach(() => { vi.resetModules(); }); // ────────────────────────────────────────────────────────────────────── // Test A — RED today, GREEN after fix: // chrome.tabs.query returns [] (real production notifications.onClicked // context, since the extension has no `tabs` permission and notification // clicks do not grant activeTab) → startVideoCapture must NOT throw // "No active tab found"; START_RECORDING must still be dispatched. // ────────────────────────────────────────────────────────────────────── it('A: tabs.query → [] still dispatches START_RECORDING (no "No active tab" throw)', 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'); await driveNotificationStart(stub); expect(countStartRecording(stub)).toBeGreaterThanOrEqual(1); }); // ────────────────────────────────────────────────────────────────────── // Test B — RED today, GREEN after fix: // tabs.query returns a tab whose `url` is undefined (the activeTab-less // shape Chrome surfaces when the caller lacks both activeTab and `tabs` // permission) → startVideoCapture must NOT throw; START_RECORDING is // dispatched. // ────────────────────────────────────────────────────────────────────── it('B: tabs.query → [{id, url: undefined}] still dispatches START_RECORDING', async () => { const stub = buildBgStub([{ id: 1 }]); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; await import('../../src/background/index'); await driveNotificationStart(stub); expect(countStartRecording(stub)).toBeGreaterThanOrEqual(1); }); // ────────────────────────────────────────────────────────────────────── // Test C — REGRESSION GUARD (was GREEN today, MUST stay GREEN after fix): // tabs.query returns a fully-populated tab (the action.onClicked / // click-gesture-activeTab context) → still dispatches START_RECORDING. // Pins that the fix does not over-trim and break the toolbar path. // ────────────────────────────────────────────────────────────────────── it('C: tabs.query → [{id, url}] still dispatches START_RECORDING (regression guard)', async () => { const stub = buildBgStub([{ id: 1, url: 'https://example.com', windowId: 100 }]); (globalThis as unknown as GlobalWithBgChrome).chrome = stub; (globalThis as unknown as GlobalWithBgChrome).indexedDB = { deleteDatabase: vi.fn() }; await import('../../src/background/index'); await driveNotificationStart(stub); expect(countStartRecording(stub)).toBeGreaterThanOrEqual(1); }); });