Files
mokosh/tests/background/port-lifecycle-continuous.test.ts
Mark 246eadb2ef test(option-c): continuous 600 s port lifecycle pinning contract
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) <noreply@anthropic.com>
2026-05-16 14:53:47 +02:00

459 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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<string, { size: number }> = 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<Blob> {
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<typeof vi.fn>;
onMessage: {
addListener: ReturnType<typeof vi.fn>;
_listeners: Array<(msg: unknown) => void>;
};
onDisconnect: {
addListener: (fn: () => void) => void;
_listeners: Array<() => void>;
};
disconnect: ReturnType<typeof vi.fn>;
_disconnected: boolean;
}
interface SwPortStub {
name: string;
sender?: { id?: string };
postMessage: ReturnType<typeof vi.fn>;
onMessage: {
addListener: ReturnType<typeof vi.fn>;
removeListener: ReturnType<typeof vi.fn>;
_listeners: Array<(msg: unknown) => void>;
};
onDisconnect: {
addListener: (fn: () => void) => void;
_listeners: Array<() => void>;
};
disconnect: ReturnType<typeof vi.fn>;
}
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<typeof setInterval>;
}) 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<typeof vi.fn> };
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<void>((resolve) => setTimeout(resolve, 0));
}
if (resp === undefined) {
throw new Error('SAVE_ARCHIVE did not respond within drain window');
}
return resp;
}
// ──────────────────────────────────────────────────────────────────
// Stage 1 (T ≈ 0289 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();
});
});