From 246eadb2ef7de621a3c2ea913bc39872012427d6 Mon Sep 17 00:00:00 2001 From: Mark Date: Sat, 16 May 2026 14:53:47 +0200 Subject: [PATCH] test(option-c): continuous 600 s port lifecycle pinning contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements Option C step 3 per .planning/debug/empty-archive-port-race.md: "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 surfaced because no test exercised the full operator timeline — 5+ minute recording with port-replacement windows crossing real SAVE_ARCHIVE round-trips. This file pins that contract end-to-end at the unit-test level. What's exercised: - Both SW (src/background/index.ts) and offscreen recorder (src/offscreen/recorder.ts) loaded into the SAME chrome stub, with paired port-pair factory (one connect() yields offPort + swPort that talk to each other through captured listeners). - 12 ping/pong cycles (~300 s simulated wall-clock). - 3 SAVE_ARCHIVE round-trips (one before reconnect, two after each of the two forced reconnects). - 2 EXTERNAL port disconnects (port._disconnected=true) — simulates the SW eviction / port glitch path that the H1.b test pins. - JSZip mocked at file scope (vi.mock) because Node 22+ JSZip can't read native Blobs — preserves integration shape (size accounting) without depending on JSZip's Node compatibility. Final assertions: 1. All 3 saveArchive calls return success:true. 2. EVERY BUFFER message that crossed the wire carried segments (no silent-loss path was reachable). 3. PONGs round-tripped (proves health-probe loop closes). Suite: 53 GREEN / 53 tests. tsc --noEmit exit 0; type-safety grep clean; npm run build exit 0. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../port-lifecycle-continuous.test.ts | 458 ++++++++++++++++++ 1 file changed, 458 insertions(+) create mode 100644 tests/background/port-lifecycle-continuous.test.ts diff --git a/tests/background/port-lifecycle-continuous.test.ts b/tests/background/port-lifecycle-continuous.test.ts new file mode 100644 index 0000000..4f0ac99 --- /dev/null +++ b/tests/background/port-lifecycle-continuous.test.ts @@ -0,0 +1,458 @@ +// 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(); + }); +});