// tests/background/request-id-protocol.test.ts // // RED-gate tests for the Option C architectural refactor's SW-side // contract, per .planning/debug/empty-archive-port-race.md (Fix Strategy: // Option C). Companion to tests/offscreen/port-health-probe.test.ts. // // SW-side architectural responsibilities under the new design: // // 1. **Request-id'd BUFFER routing** — getVideoBufferFromOffscreen // generates a uuid, sends `{type:'REQUEST_BUFFER', requestId}`, // and only resolves on BUFFER messages that echo the same id. // Stale BUFFERs from prior requests are ignored. // // 2. **Retry on port replacement** — if `videoPort` changes (e.g. the // offscreen health probe reconnected the port mid-request), the // SW re-issues REQUEST_BUFFER on the new port with the SAME // requestId. This is the architectural mechanism that retires the // H2 silent-drop class — the BUFFER reaches the SW regardless of // port-replacement timing. // // 3. **Operator-visible error surface on empty video** — createArchive // throws when there are zero video segments. saveArchive catches // and returns `{success: false, error}` so the popup can surface // RECORDING_ERROR to the operator. Replaces the upstream silent- // skip branch (src/background/index.ts:346-352) that shipped a // video-less archive in silence. // // 4. **PING → PONG echo** — the SW-side port message sink replies to // PING with a PONG response, completing the offscreen's health- // probe loop. // // These are the SW-side acceptance criteria for the Option C refactor. // All four flip GREEN when the implementation lands in src/background/index.ts. import { describe, it, expect, vi, beforeEach } from 'vitest'; interface PortStub { name: string; sender?: { id?: string }; postMessage: ReturnType; onMessage: { addListener: ReturnType; removeListener: ReturnType; _listeners: Array<(msg: 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(), removeListener: vi.fn(), _listeners: [], }, onDisconnect: { addListener: (fn: () => void) => { port.onDisconnect._listeners.push(fn); }, _listeners: [], }, disconnect: vi.fn(), }; port.onMessage.addListener.mockImplementation( (fn: (msg: unknown) => void) => { port.onMessage._listeners.push(fn); } ); port.onMessage.removeListener.mockImplementation( (fn: (msg: unknown) => void) => { const idx = port.onMessage._listeners.indexOf(fn); if (idx >= 0) { port.onMessage._listeners.splice(idx, 1); } } ); return port; } interface OnConnectCallback { (port: PortStub): void; } interface OnMessageCallback { ( msg: unknown, sender: { id?: string }, sendResponse: (resp: unknown) => void ): boolean; } interface BackgroundChromeStub { runtime: { id: string; getURL: (path: string) => string; getManifest: () => { version: string }; sendMessage: ReturnType; onMessage: { addListener: ReturnType; _callbacks: OnMessageCallback[]; }; onConnect: { addListener: (cb: OnConnectCallback) => void; _callbacks: OnConnectCallback[]; }; onInstalled: { addListener: ReturnType }; }; offscreen: { hasDocument?: () => Promise; createDocument: ReturnType; Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' }; }; tabs: { query: ReturnType; sendMessage: ReturnType; captureVisibleTab: ReturnType; }; downloads: { download: ReturnType; }; } interface GlobalWithBackgroundChrome { chrome?: BackgroundChromeStub; indexedDB?: { deleteDatabase: ReturnType }; fetch?: typeof fetch; } function buildBackgroundChromeStub(): BackgroundChromeStub { const stub: BackgroundChromeStub = { runtime: { id: 'ext-id-test', getURL: (p: string) => `chrome-extension://ext-id-test/${p}`, getManifest: () => ({ version: '1.0.0' }), sendMessage: vi.fn(), onMessage: { addListener: vi.fn(), _callbacks: [] }, onConnect: { addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb), _callbacks: [] }, onInstalled: { addListener: vi.fn() }, }, 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,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==' ), }, downloads: { download: vi.fn().mockResolvedValue(1), }, }; stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => { stub.runtime.onMessage._callbacks.push(cb); }); return stub; } // Generic Promise-resolved fetch that returns a fake screenshot Blob. async function stubFetch(_url: string): Promise { // The SW's captureScreenshot does `await fetch(dataUrl).blob()`. We // return a minimal PNG Blob. const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]); return { blob: async () => new Blob([png], { type: 'image/png' }), } as unknown as Response; } describe('SW request-id protocol + retry + error surface (RED — Option C)', () => { beforeEach(() => { vi.resetModules(); }); // ────────────────────────────────────────────────────────────────────── // 1. REQUEST_BUFFER carries a requestId — the SW generates one per call // ────────────────────────────────────────────────────────────────────── it('1: getVideoBufferFromOffscreen sends REQUEST_BUFFER with a requestId', async () => { const stub = buildBackgroundChromeStub(); (globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub; (globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = { deleteDatabase: vi.fn(), }; (globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch; await import('../../src/background/index'); // Synthetically wire an offscreen port. expect(stub.runtime.onConnect._callbacks.length).toBeGreaterThanOrEqual(1); const port = makePortStub(); stub.runtime.onConnect._callbacks[0](port); // Trigger SAVE_ARCHIVE via the onMessage path. Capture the sendResponse // callback so we can resolve a synthetic BUFFER response. let saveResp: unknown = undefined; const sendResp = (r: unknown) => { saveResp = r; }; const onMessageHandler = stub.runtime.onMessage._callbacks[0]; onMessageHandler( { type: 'SAVE_ARCHIVE' }, { id: 'ext-id-test' }, sendResp, ); // Drain microtasks so the saveArchive chain calls getVideoBufferFromOffscreen. for (let i = 0; i < 32; i++) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 32; i++) await Promise.resolve(); // The first message posted on the port must be REQUEST_BUFFER WITH a // requestId field. Plain `{type:'REQUEST_BUFFER'}` without a requestId // is the legacy shape and not acceptable under Option C. const requestBufferCalls = port.postMessage.mock.calls.filter( (c: unknown[]) => typeof c[0] === 'object' && c[0] !== null && (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER' ); expect(requestBufferCalls.length).toBeGreaterThanOrEqual(1); const reqMsg = requestBufferCalls[0][0] as { requestId?: unknown }; expect(typeof reqMsg.requestId).toBe('string'); expect((reqMsg.requestId as string).length).toBeGreaterThan(0); // Cleanup — resolve the in-flight save by delivering a matching // BUFFER so the test doesn't leave a dangling Promise. port.onMessage._listeners.forEach((fn) => fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] }) ); for (let i = 0; i < 32; i++) await Promise.resolve(); void saveResp; }); // ────────────────────────────────────────────────────────────────────── // 2. Stale BUFFER (different requestId) is ignored // ────────────────────────────────────────────────────────────────────── it('2: SW ignores BUFFER with a mismatched requestId (no cross-talk)', async () => { const stub = buildBackgroundChromeStub(); (globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub; (globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = { deleteDatabase: vi.fn(), }; (globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch; await import('../../src/background/index'); const port = makePortStub(); stub.runtime.onConnect._callbacks[0](port); let saveResp: unknown = undefined; stub.runtime.onMessage._callbacks[0]( { type: 'SAVE_ARCHIVE' }, { id: 'ext-id-test' }, (r) => { saveResp = r; }, ); for (let i = 0; i < 32; i++) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 32; i++) await Promise.resolve(); const requestBufferCalls = port.postMessage.mock.calls.filter( (c: unknown[]) => typeof c[0] === 'object' && c[0] !== null && (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER' ); const reqMsg = requestBufferCalls[0][0] as { requestId: string }; // Deliver a STALE BUFFER (different requestId) — the SW must NOT // resolve its in-flight request on this. port.onMessage._listeners.forEach((fn) => fn({ type: 'BUFFER', requestId: 'completely-different-id-xxx', segments: [ { data: 'GkXfow==', type: 'video/webm', timestamp: 1, }, ], }) ); // Now drain microtasks; the in-flight request must still be pending. // We confirm by observing that saveResp is still undefined. for (let i = 0; i < 32; i++) await Promise.resolve(); expect(saveResp).toBeUndefined(); // Deliver the matching BUFFER to resolve cleanly. port.onMessage._listeners.forEach((fn) => fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] }) ); for (let i = 0; i < 32; i++) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 32; i++) await Promise.resolve(); }); // ────────────────────────────────────────────────────────────────────── // 3. SW retries REQUEST_BUFFER on port replacement // ────────────────────────────────────────────────────────────────────── // // The architectural fix: when `videoPort` is replaced by a fresh // onConnect call (the offscreen reconnected its port mid-request), the // SW re-issues REQUEST_BUFFER on the new port with the same requestId. // This is the mechanism that retires the H2 silent-drop class. it('3: SW re-issues REQUEST_BUFFER on the new port after a port replacement', async () => { const stub = buildBackgroundChromeStub(); (globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub; (globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = { deleteDatabase: vi.fn(), }; (globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch; await import('../../src/background/index'); const oldPort = makePortStub(); stub.runtime.onConnect._callbacks[0](oldPort); let saveResp: unknown = undefined; stub.runtime.onMessage._callbacks[0]( { type: 'SAVE_ARCHIVE' }, { id: 'ext-id-test' }, (r) => { saveResp = r; }, ); for (let i = 0; i < 32; i++) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 32; i++) await Promise.resolve(); const oldRequestBufferCalls = oldPort.postMessage.mock.calls.filter( (c: unknown[]) => typeof c[0] === 'object' && c[0] !== null && (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER' ); expect(oldRequestBufferCalls.length).toBeGreaterThanOrEqual(1); const reqMsg = oldRequestBufferCalls[0][0] as { requestId: string }; // Simulate port replacement: fire onDisconnect on the old port, then // a fresh onConnect for the new one. oldPort.onDisconnect._listeners.forEach((fn) => fn()); const newPort = makePortStub(); stub.runtime.onConnect._callbacks[0](newPort); // Drain microtasks for the SW to observe the new port and retry. for (let i = 0; i < 32; i++) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 32; i++) await Promise.resolve(); // The new port must have received a REQUEST_BUFFER too (the retry), // preserving the SAME requestId so the SW can route the eventual // BUFFER response back to the original in-flight Promise. const newRequestBufferCalls = newPort.postMessage.mock.calls.filter( (c: unknown[]) => typeof c[0] === 'object' && c[0] !== null && (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER' ); expect(newRequestBufferCalls.length).toBeGreaterThanOrEqual(1); const retryReq = newRequestBufferCalls[0][0] as { requestId: string }; expect(retryReq.requestId).toBe(reqMsg.requestId); // Deliver BUFFER on the new port — saveArchive resolves. newPort.onMessage._listeners.forEach((fn) => fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] }) ); for (let i = 0; i < 32; i++) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 32; i++) await Promise.resolve(); void saveResp; }); // ────────────────────────────────────────────────────────────────────── // 4. Operator-visible error surface on empty video // ────────────────────────────────────────────────────────────────────── // // The upstream H2 silent-skip in createArchive must be retired. // saveArchive must return `{success: false, error}` (not // `{success: true}` with a video-less archive) when the video buffer // is empty AND the archive would therefore ship without video. // // The popup already handles `{success: false, error}` by surfacing // RECORDING_ERROR to the operator. it('4: saveArchive returns success:false when video segments are empty', async () => { const stub = buildBackgroundChromeStub(); (globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub; (globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = { deleteDatabase: vi.fn(), }; (globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch; await import('../../src/background/index'); const port = makePortStub(); stub.runtime.onConnect._callbacks[0](port); let saveResp: { success?: boolean; error?: unknown } | undefined; stub.runtime.onMessage._callbacks[0]( { type: 'SAVE_ARCHIVE' }, { id: 'ext-id-test' }, (r) => { saveResp = r as { success?: boolean; error?: unknown }; }, ); for (let i = 0; i < 32; i++) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 32; i++) await Promise.resolve(); const requestBufferCalls = port.postMessage.mock.calls.filter( (c: unknown[]) => typeof c[0] === 'object' && c[0] !== null && (c[0] as { type?: unknown }).type === 'REQUEST_BUFFER' ); const reqMsg = requestBufferCalls[0][0] as { requestId: string }; // Resolve BUFFER with ZERO segments — the silent-skip scenario. port.onMessage._listeners.forEach((fn) => fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] }) ); // Drain microtasks for saveArchive to complete. for (let i = 0; i < 64; i++) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 64; i++) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); for (let i = 0; i < 64; i++) await Promise.resolve(); expect(saveResp).toBeDefined(); expect(saveResp?.success).toBe(false); expect(saveResp?.error).toBeDefined(); }); // ────────────────────────────────────────────────────────────────────── // 5. SW echoes PONG on PING (health-probe loop closure) // ────────────────────────────────────────────────────────────────────── // // The offscreen's health probe expects a PONG echo per PING. The SW's // port message sink must reply. it('5: SW replies to PING with PONG (closes the health-probe loop)', async () => { const stub = buildBackgroundChromeStub(); (globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub; (globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = { deleteDatabase: vi.fn(), }; (globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch; await import('../../src/background/index'); const port = makePortStub(); stub.runtime.onConnect._callbacks[0](port); // Dispatch PING. expect(port.onMessage._listeners.length).toBeGreaterThanOrEqual(1); port.onMessage._listeners.forEach((fn) => fn({ type: 'PING' })); // Drain microtasks. for (let i = 0; i < 16; i++) await Promise.resolve(); // The SW must have responded with PONG via postMessage on the port. const pongCalls = port.postMessage.mock.calls.filter( (c: unknown[]) => typeof c[0] === 'object' && c[0] !== null && (c[0] as { type?: unknown }).type === 'PONG' ); expect(pongCalls.length).toBeGreaterThanOrEqual(1); }); });