// tests/background/port-lifecycle-continuous.test.ts // // Continuous-end-to-end pinning contract for the Option C port lifecycle // per .planning/debug/empty-archive-port-race.md step 3: // // "Continuous end-to-end vitest covering 600 s of port lifecycle // (2 reconnects + simulated REQUEST_BUFFER round-trips). Becomes the // new pinning contract for the port lifecycle." // // The UAT Test 3 BLOCKER had two visible symptoms after recording for // 5+ minutes (past the 290 s legacy pre-emptive reconnect mark): // (a) 3x Uncaught "Attempting to use a disconnected port object" // (b) Archive shipped with NO video/last_30sec.webm (silent data loss) // // This test simulates the same operator timeline at the unit-test level: // - 600 s of "wall-clock" simulated by driving setInterval callbacks // directly (no real wait). // - 2 forced port reconnects mid-stream (covering the legacy 290 s + a // second one at ~580 s — well within a realistic operator session). // - REQUEST_BUFFER round-trips at points BEFORE and AFTER each reconnect // window. // // PASS criteria: // 1. No Uncaught error escapes from the recorder's bootstrap or its // port lifecycle (H1 contract — protects against the offscreen // console's UAT 3x error pattern). // 2. Every REQUEST_BUFFER round-trip resolves with success (H2 // contract — protects against the silent empty-archive shipping). // 3. PONGs flow back from SW (closes the health-probe loop). // 4. BUFFER messages carry non-empty segments (no silent-drop path). import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // vi.mock("jszip", ...) replaces the production JSZip module with a stub // that round-trips file additions without actually generating a real zip. // Production JSZip's generateAsync({type:'blob'}) accepts Node Blobs but // fails on them in Node v22+ (the file's read pipeline does an // instanceof Blob check that only matches the platform's native Blob, not // the polyfilled one — observed: "Can't read the data of 'screenshot.png'. // Is it in a supported JavaScript type ... ?"). The mock preserves the // integration shape (zip.file() collects, zip.generateAsync() returns a // Blob whose .size reflects total content) so saveArchive's success path // is reachable without depending on JSZip's Node compatibility. vi.mock('jszip', () => { class JSZipStub { private readonly entries: Map = new Map(); file(path: string, content: unknown): void { let size = 0; if (typeof content === 'string') { size = content.length; } else if (content instanceof Blob) { size = content.size; } else if (content instanceof Uint8Array) { size = content.byteLength; } else if (content instanceof ArrayBuffer) { size = content.byteLength; } this.entries.set(path, { size }); } async generateAsync(_opts: { type: 'blob' }): Promise { let total = 0; for (const { size } of this.entries.values()) { total += size; } return new Blob([new Uint8Array(total)], { type: 'application/zip' }); } } return { default: JSZipStub }; }); // ─── Stubs ────────────────────────────────────────────────────────────── interface OffPortStub { name: string; postMessage: ReturnType; onMessage: { addListener: ReturnType; _listeners: Array<(msg: unknown) => void>; }; onDisconnect: { addListener: (fn: () => void) => void; _listeners: Array<() => void>; }; disconnect: ReturnType; _disconnected: boolean; } interface SwPortStub { 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; } const ORIGINAL_SET_INTERVAL = globalThis.setInterval; const ORIGINAL_CLEAR_INTERVAL = globalThis.clearInterval; const ORIGINAL_SET_TIMEOUT = globalThis.setTimeout; const ORIGINAL_CLEAR_TIMEOUT = globalThis.clearTimeout; // Build a paired offscreen-port + SW-port that talk to each other through // the captured onMessage listeners. The recorder.ts posts on its port // reference (offPort); we route those messages into the SW-port's // listeners. And vice-versa for SW -> offscreen. // // This is a deliberately-minimal harness: the goal is to surface the // port lifecycle contract, not to replicate Chrome's full IPC fidelity. function pairPorts(): { offPort: OffPortStub; swPort: SwPortStub } { // Forward declare to satisfy the postMessage closures. const offPort: OffPortStub = { name: 'video-keepalive', postMessage: vi.fn(), onMessage: { addListener: vi.fn(), _listeners: [] }, onDisconnect: { addListener: (fn: () => void) => { offPort.onDisconnect._listeners.push(fn); }, _listeners: [], }, disconnect: vi.fn(() => { offPort._disconnected = true; }), _disconnected: false, }; offPort.onMessage.addListener.mockImplementation( (fn: (msg: unknown) => void) => { offPort.onMessage._listeners.push(fn); } ); const swPort: SwPortStub = { name: 'video-keepalive', sender: { id: 'ext-id-test' }, postMessage: vi.fn(), onMessage: { addListener: vi.fn(), removeListener: vi.fn(), _listeners: [], }, onDisconnect: { addListener: (fn: () => void) => { swPort.onDisconnect._listeners.push(fn); }, _listeners: [], }, disconnect: vi.fn(), }; swPort.onMessage.addListener.mockImplementation( (fn: (msg: unknown) => void) => { swPort.onMessage._listeners.push(fn); } ); swPort.onMessage.removeListener.mockImplementation( (fn: (msg: unknown) => void) => { const idx = swPort.onMessage._listeners.indexOf(fn); if (idx >= 0) { swPort.onMessage._listeners.splice(idx, 1); } } ); // Wire postMessage AFTER both ports exist so each side can reach the // other's listeners. offPort.postMessage.mockImplementation((msg: unknown) => { if (offPort._disconnected) { throw new Error('Attempting to use a disconnected port object'); } swPort.onMessage._listeners.forEach((fn) => fn(msg)); }); swPort.postMessage.mockImplementation((msg: unknown) => { offPort.onMessage._listeners.forEach((fn) => fn(msg)); }); return { offPort, swPort }; } describe('port lifecycle — continuous 600 s simulation (Option C pinning)', () => { beforeEach(() => { vi.resetModules(); }); afterEach(() => { globalThis.setInterval = ORIGINAL_SET_INTERVAL; globalThis.clearInterval = ORIGINAL_CLEAR_INTERVAL; globalThis.setTimeout = ORIGINAL_SET_TIMEOUT; globalThis.clearTimeout = ORIGINAL_CLEAR_TIMEOUT; }); it('600 s lifecycle with 2 reconnects + 3 REQUEST_BUFFER round-trips: no Uncaught, no silent loss', async () => { // Capture the offscreen recorder's ping interval callback so we can // drive it synchronously (no wall-clock waits). const intervalCallbacks: Array<() => void> = []; globalThis.setInterval = ((cb: () => void, _ms: number) => { intervalCallbacks.push(cb); return 999 as unknown as ReturnType; }) as typeof globalThis.setInterval; globalThis.clearInterval = (() => {}) as typeof globalThis.clearInterval; // Shared registries for tracking ports across both SW + offscreen. const offPorts: OffPortStub[] = []; const swPorts: SwPortStub[] = []; const onConnectCbs: Array<(p: SwPortStub) => void> = []; const onMessageHandlers: Array< ( msg: unknown, sender: { id?: string }, sendResponse: (resp: unknown) => void, ) => boolean > = []; // ONE unified chrome stub that the SW and offscreen modules BOTH // observe (they share the same `globalThis.chrome`). The SW // registers via onConnect.addListener / onMessage.addListener; the // offscreen calls connect() — both routed through the same object. const sharedChromeStub = { 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().mockImplementation( ( cb: ( msg: unknown, sender: { id?: string }, sendResponse: (resp: unknown) => void, ) => boolean, ) => { onMessageHandlers.push(cb); }, ), }, onConnect: { addListener: (cb: (p: SwPortStub) => void) => { onConnectCbs.push(cb); }, }, onInstalled: { addListener: vi.fn() }, connect: () => { const pair = pairPorts(); offPorts.push(pair.offPort); swPorts.push(pair.swPort); // Fire the SW's onConnect handler in a microtask so the // offscreen's connect() returns first (matching Chrome's // async-but-effectively-immediate cross-context wiring). queueMicrotask(() => { onConnectCbs.forEach((cb) => cb(pair.swPort)); }); return pair.offPort; }, }, 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), }, }; interface GlobalScope { chrome?: unknown; MediaRecorder?: { isTypeSupported: (mime: string) => boolean }; indexedDB?: { deleteDatabase: ReturnType }; fetch?: typeof fetch; } const g = globalThis as unknown as GlobalScope; g.chrome = sharedChromeStub; g.MediaRecorder = { isTypeSupported: vi.fn().mockReturnValue(true) }; g.indexedDB = { deleteDatabase: vi.fn() }; g.fetch = (async () => ({ blob: async () => new Blob([new Uint8Array([0x89, 0x50])], { type: 'image/png' }), } as unknown as Response)) as unknown as typeof fetch; // Import SW first so its onConnect + onMessage listeners are // registered before the offscreen recorder calls connect(). await import('../../src/background/index'); expect(onConnectCbs.length).toBeGreaterThanOrEqual(1); expect(onMessageHandlers.length).toBeGreaterThanOrEqual(1); // Now the offscreen recorder. Its bootstrap calls // chrome.runtime.connect() → port 0 wired on both sides. const recorder = await import('../../src/offscreen/recorder'); // Drain microtasks so the queueMicrotask-deferred SW onConnect fires. for (let i = 0; i < 8; i++) await Promise.resolve(); expect(offPorts.length).toBe(1); expect(swPorts.length).toBe(1); // Seed the offscreen ring buffer with 3 segments so REQUEST_BUFFER // round-trips have data to ship. recorder.resetBuffer(); const ebmlMagic = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]); for (let i = 0; i < 3; i++) { const payload = new Uint8Array(1024); payload.set(ebmlMagic, 0); payload.fill(i + 0x10, ebmlMagic.length); recorder.pushSegmentForTest(new Blob([payload], { type: 'video/webm' })); } expect(recorder.getSegments().length).toBe(3); // Helper to dispatch SAVE_ARCHIVE through the SW's onMessage handler // and drain microtasks until the response settles. async function dispatchSaveArchive(): Promise<{ success?: boolean; error?: unknown; }> { let resp: { success?: boolean; error?: unknown } | undefined; onMessageHandlers[0]( { type: 'SAVE_ARCHIVE' }, { id: 'ext-id-test' }, (r) => { resp = r as { success?: boolean; error?: unknown }; }, ); // Generous drain: SAVE_ARCHIVE chains several awaits (captureScreenshot, // getVideoBufferFromOffscreen, tabs.sendMessage, createArchive's // zip.generateAsync). JSZip in particular uses internal Promise // chains that need multiple macrotask flushes to settle. for (let phase = 0; phase < 5; phase++) { for (let i = 0; i < 64; i++) await Promise.resolve(); await new Promise((resolve) => setTimeout(resolve, 0)); } if (resp === undefined) { throw new Error('SAVE_ARCHIVE did not respond within drain window'); } return resp; } // ────────────────────────────────────────────────────────────────── // Stage 1 (T ≈ 0–289 s) — ping/pong cycles. PINGs reach SW; SW // echoes PONG; the offscreen's missedPongs counter stays at 0. // ────────────────────────────────────────────────────────────────── const pingCb = intervalCallbacks[0]; expect(pingCb).toBeTruthy(); // 12 ping cycles ≈ 300 s at PORT_PING_MS = 25_000 ms. for (let i = 0; i < 12; i++) { // The offscreen's ping callback should not throw on a healthy port. expect(() => pingCb()).not.toThrow(); } // No new port should have been created — the health probe is happy. expect(offPorts.length).toBe(1); // ────────────────────────────────────────────────────────────────── // Stage 2 (T ≈ 290 s) — first REQUEST_BUFFER round-trip via SW // SAVE_ARCHIVE. The legacy code's 290 s pre-emptive reconnect would // have fired here; the Option C design has no such timer, so the // round-trip completes cleanly on the original port. // ────────────────────────────────────────────────────────────────── const save1 = await dispatchSaveArchive(); expect(save1.success).toBe(true); // ────────────────────────────────────────────────────────────────── // Stage 3 (T ≈ 295 s) — simulate an EXTERNAL reconnect. The Option C // health probe doesn't fire one on its own (no PINGs missed), so we // force it via the path the H1.b test pins: external disconnect → // ping detects → reconnectPort initiates. Fresh port pair appears. // ────────────────────────────────────────────────────────────────── offPorts[0]._disconnected = true; expect(() => pingCb()).not.toThrow(); // Drain microtasks for the reconnect to wire up port 1. for (let i = 0; i < 32; i++) await Promise.resolve(); expect(offPorts.length).toBe(2); expect(swPorts.length).toBe(2); // ────────────────────────────────────────────────────────────────── // Stage 4 (T ≈ 296 s) — second REQUEST_BUFFER round-trip on the // FRESH port. This is the round-trip that the legacy code would have // failed silently (the SW's per-request listener was bound to the // OLD port). Under Option C the SW's pendingBufferRequests map is // port-agnostic — the BUFFER lands on whichever port the offscreen // posts on, and the requestId match resolves cleanly. // ────────────────────────────────────────────────────────────────── expect(recorder.getSegments().length).toBe(3); const save2 = await dispatchSaveArchive(); expect(save2.success).toBe(true); // ────────────────────────────────────────────────────────────────── // Stage 5 (T ≈ 580 s) — second forced reconnect + third // REQUEST_BUFFER round-trip. Pins that the lifecycle remains stable // across multiple cycles (the UAT session observed 3 errors at // 290/315/340 s — recurring at PORT_PING_MS = 25_000 ms intervals // after the initial pre-emptive reconnect). // ────────────────────────────────────────────────────────────────── offPorts[1]._disconnected = true; expect(() => pingCb()).not.toThrow(); for (let i = 0; i < 32; i++) await Promise.resolve(); expect(offPorts.length).toBe(3); expect(swPorts.length).toBe(3); const save3 = await dispatchSaveArchive(); expect(save3.success).toBe(true); // ────────────────────────────────────────────────────────────────── // Final assertions // ────────────────────────────────────────────────────────────────── // a) Every BUFFER posted (across all ports) carried segments — no // silent-loss path was reachable in this simulation. let buffersWithSegments = 0; let buffersTotal = 0; for (const port of offPorts) { for (const call of port.postMessage.mock.calls) { const msg = call[0] as { type?: unknown; segments?: unknown }; if (msg.type === 'BUFFER') { buffersTotal++; if (Array.isArray(msg.segments) && msg.segments.length > 0) { buffersWithSegments++; } } } } expect(buffersTotal).toBeGreaterThanOrEqual(3); // EVERY buffer that crossed the wire carried segments. expect(buffersWithSegments).toBe(buffersTotal); // b) PONGs round-tripped — the SW echoed at least one PONG (proving // the health-probe loop closes). let pongCount = 0; for (const port of swPorts) { for (const call of port.postMessage.mock.calls) { const msg = call[0] as { type?: unknown }; if (msg.type === 'PONG') { pongCount++; } } } expect(pongCount).toBeGreaterThanOrEqual(1); recorder.resetBuffer(); }); });