Milestone v1 (v2.0.0): Mokosh — Session Capture #1
458
tests/background/port-lifecycle-continuous.test.ts
Normal file
458
tests/background/port-lifecycle-continuous.test.ts
Normal file
@@ -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<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 ≈ 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user