// tests/offscreen/port-health-probe.test.ts // // RED-gate tests for the Option C architectural refactor of the offscreen // port lifecycle, per .planning/debug/empty-archive-port-race.md // (Fix Strategy: Option C — Architectural). // // The two architectural goals these tests pin: // // 1. **Health-probe protocol** — PING expects a PONG echo within // PORT_PONG_TIMEOUT_MS. Replaces the 290 s pre-emptive setTimeout // reconnect (which created the H1 race window — see // port-reconnect-race.test.ts H1.b). A missed pong triggers a clean // teardown + reconnect via the same path the onDisconnect handler // uses, so the race is eliminated by construction. // // 2. **Request-id'd BUFFER** — REQUEST_BUFFER carries `requestId`; // the offscreen echoes it on the BUFFER response. The SW's // per-request listener now matches on requestId, so a stale BUFFER // from a prior REQUEST_BUFFER cannot route into a newer request's // Promise — the silent-cross-talk failure mode the original // port-replaced-during-fetch path masked. // // Together these eliminate the architectural class of bug the bisect // surfaced (port-replacement window weaponising the upstream silent-skip // in createArchive). The H1 test in port-reconnect-race.test.ts continues // to pin the operator-facing contract (no Uncaught Error from a ping // against a disconnected port) — that test must still flip GREEN as the // implementation lands. import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; interface PortStub { 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 ChromeStub { runtime: { id: string; sendMessage: ReturnType; onMessage: { addListener: ReturnType }; connect: () => PortStub; }; } interface GlobalWithChrome { chrome?: ChromeStub; MediaRecorder?: { isTypeSupported: (mime: string) => boolean }; } function makePortStub(): PortStub { const port: PortStub = { name: 'video-keepalive', postMessage: vi.fn(), onMessage: { addListener: vi.fn(), _listeners: [], }, onDisconnect: { addListener: (fn: () => void) => { port.onDisconnect._listeners.push(fn); }, _listeners: [], }, disconnect: vi.fn(() => { port._disconnected = true; }), _disconnected: false, }; port.onMessage.addListener.mockImplementation( (fn: (msg: unknown) => void) => { port.onMessage._listeners.push(fn); } ); port.postMessage.mockImplementation((msg: unknown) => { if (port._disconnected) { throw new Error('Attempting to use a disconnected port object'); } return msg; }); return port; } function buildChromeStub(ports: PortStub[]): ChromeStub { return { runtime: { id: 'ext-id-test', sendMessage: vi.fn(), onMessage: { addListener: vi.fn() }, connect: () => { const port = makePortStub(); ports.push(port); return port; }, }, }; } // Top-level snapshots of the real timer APIs — captured at module load, // BEFORE any test runs, so they are never accidentally re-snapshotted to a // value polluted by a prior failing test (a failing `expect` short-circuits // the rest of the test body, including any inline restore code). const ORIGINAL_SET_INTERVAL = globalThis.setInterval; const ORIGINAL_CLEAR_INTERVAL = globalThis.clearInterval; const ORIGINAL_SET_TIMEOUT = globalThis.setTimeout; const ORIGINAL_CLEAR_TIMEOUT = globalThis.clearTimeout; describe('port health probe + request-id protocol (RED — Option C contract)', () => { beforeEach(() => { vi.resetModules(); (globalThis as unknown as GlobalWithChrome).MediaRecorder = { isTypeSupported: vi.fn().mockReturnValue(true), }; }); afterEach(() => { // Restore unconditionally even after a test failure — vital because // a failing `expect` aborts the test body before any inline restore. globalThis.setInterval = ORIGINAL_SET_INTERVAL; globalThis.clearInterval = ORIGINAL_CLEAR_INTERVAL; globalThis.setTimeout = ORIGINAL_SET_TIMEOUT; globalThis.clearTimeout = ORIGINAL_CLEAR_TIMEOUT; }); // ────────────────────────────────────────────────────────────────────── // A. No pre-emptive 290 s setTimeout reconnect any more // ────────────────────────────────────────────────────────────────────── // // The architectural fix retires the timing-based pre-emptive reconnect // in favour of a health probe. The bootstrap must NOT install a // 290_000 ms setTimeout against the port. (Other unrelated timers like // scheduleRotation can still appear under specific conditions, but // they are gated on mediaStream which is null in this test.) it('A: bootstrap does NOT install the 290 s pre-emptive reconnect timer', async () => { const ports: PortStub[] = []; const stub = buildChromeStub(ports); (globalThis as unknown as GlobalWithChrome).chrome = stub; const timeoutDelays: number[] = []; globalThis.setTimeout = ((cb: () => void, ms: number) => { timeoutDelays.push(ms); return 1 as unknown as ReturnType; }) as typeof globalThis.setTimeout; globalThis.clearTimeout = (() => {}) as typeof globalThis.clearTimeout; await import('../../src/offscreen/recorder'); // The legacy implementation set a 290_000 ms timer. The new design // has none — health is probed via PING/PONG on the existing // setInterval. If the architectural intent is honoured, no setTimeout // call will use the 290_000 delay. expect(timeoutDelays).not.toContain(290_000); }); // ────────────────────────────────────────────────────────────────────── // B. Health probe — missed PONG triggers reconnect // ────────────────────────────────────────────────────────────────────── // // The new architecture: each ping carries an implicit expectation that // the SW echoes PONG within PORT_PONG_TIMEOUT_MS. If the offscreen has // sent N PINGs without receiving any PONG in that window, treat the // port as dead and reconnect (clean teardown via the same path // onDisconnect uses). // // This test simulates: 3 pings fire, no PONG arrives. The recorder // must initiate a clean reconnect — either by calling .disconnect() on // the port (which triggers our stub's onDisconnect handler chain) OR // by directly invoking chrome.runtime.connect a second time. Either // way, a SECOND port appears in `ports`. it('B: missed PONGs trigger a clean reconnect (architectural health probe)', async () => { const ports: PortStub[] = []; const stub = buildChromeStub(ports); (globalThis as unknown as GlobalWithChrome).chrome = stub; 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; await import('../../src/offscreen/recorder'); expect(ports.length).toBe(1); expect(intervalCallbacks.length).toBeGreaterThanOrEqual(1); const oldPort = ports[0]; const pingCb = intervalCallbacks[0]; // Simulate the SW being unresponsive: invoke the ping callback // multiple times WITHOUT delivering a PONG message back. The new // implementation must detect this as a dead port and reconnect. // // PORT_PONG_TIMEOUT_MS is short enough (≤ PORT_PING_MS × 3) that 5 // PING-without-PONG cycles is unambiguously above the threshold. for (let i = 0; i < 5; i++) { pingCb(); } // After the missed-PONG threshold, the recorder reconnects. The // bootstrap port is replaced; `ports.length` becomes ≥ 2. expect(ports.length).toBeGreaterThanOrEqual(2); // The old port should have been disconnected cleanly (no stray // listener leaks). This mirrors how onDisconnect-triggered reconnects // already work. expect(oldPort.disconnect).toHaveBeenCalled(); }); // ────────────────────────────────────────────────────────────────────── // C. Health probe — PONG received resets the dead-port counter // ────────────────────────────────────────────────────────────────────── // // Counter-test: if PONGs DO arrive, the recorder must NOT reconnect. // This pins that the health probe is not over-eager — operating on // a healthy port indefinitely is the steady state. it('C: PONG echoes within timeout keep the port alive (no reconnect)', async () => { const ports: PortStub[] = []; const stub = buildChromeStub(ports); (globalThis as unknown as GlobalWithChrome).chrome = stub; 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; await import('../../src/offscreen/recorder'); const port = ports[0]; const pingCb = intervalCallbacks[0]; // Fire 10 ping/pong cycles. Each ping must be followed by a PONG // delivered via the captured onMessage listener. The recorder must // remain on the same port (no reconnect, no extra ports). for (let i = 0; i < 10; i++) { pingCb(); // Deliver PONG via the captured listener — the recorder records // the live timestamp and the dead-port counter resets. port.onMessage._listeners.forEach((fn) => fn({ type: 'PONG' })); } expect(ports.length).toBe(1); expect(port.disconnect).not.toHaveBeenCalled(); }); // ────────────────────────────────────────────────────────────────────── // D. Request-id'd REQUEST_BUFFER → BUFFER echo // ────────────────────────────────────────────────────────────────────── // // REQUEST_BUFFER carries `requestId` (uuid). The offscreen's encode // path must echo the same id in the BUFFER response. This is the // architectural mechanism that lets the SW retry on port replacement // without cross-talk: each pending request matches on its own id, so // a stale BUFFER from a prior request cannot resolve a newer one. it('D: REQUEST_BUFFER with requestId → BUFFER echoes the same requestId', async () => { const ports: PortStub[] = []; const stub = buildChromeStub(ports); (globalThis as unknown as GlobalWithChrome).chrome = stub; const recorder = await import('../../src/offscreen/recorder'); // Seed one finalized segment so the encode path actually runs. const ebmlMagic = new Uint8Array([0x1a, 0x45, 0xdf, 0xa3]); recorder.resetBuffer(); const payload = new Uint8Array(1024); payload.set(ebmlMagic, 0); recorder.pushSegmentForTest(new Blob([payload], { type: 'video/webm' })); expect(ports.length).toBe(1); const port = ports[0]; // Dispatch REQUEST_BUFFER with a known requestId. const requestId = 'test-request-id-abc-123'; port.onMessage._listeners[0]({ type: 'REQUEST_BUFFER', requestId }); // Drain microtasks for the encode loop. 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 BUFFER response on the port must carry the same requestId. const bufferCalls = port.postMessage.mock.calls.filter( (c: unknown[]) => typeof c[0] === 'object' && c[0] !== null && (c[0] as { type?: unknown }).type === 'BUFFER' ); expect(bufferCalls.length).toBeGreaterThanOrEqual(1); const bufferMsg = bufferCalls[0][0] as { requestId?: unknown }; expect(bufferMsg.requestId).toBe(requestId); recorder.resetBuffer(); }); });