Milestone v1 (v2.0.0): Mokosh — Session Capture #1
488
tests/background/request-id-protocol.test.ts
Normal file
488
tests/background/request-id-protocol.test.ts
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
// tests/background/request-id-protocol.test.ts
|
||||||
|
//
|
||||||
|
// RED-gate tests for the Option C architectural refactor's SW-side
|
||||||
|
// contract, per .planning/debug/empty-archive-port-race.md (Fix Strategy:
|
||||||
|
// Option C). Companion to tests/offscreen/port-health-probe.test.ts.
|
||||||
|
//
|
||||||
|
// SW-side architectural responsibilities under the new design:
|
||||||
|
//
|
||||||
|
// 1. **Request-id'd BUFFER routing** — getVideoBufferFromOffscreen
|
||||||
|
// generates a uuid, sends `{type:'REQUEST_BUFFER', requestId}`,
|
||||||
|
// and only resolves on BUFFER messages that echo the same id.
|
||||||
|
// Stale BUFFERs from prior requests are ignored.
|
||||||
|
//
|
||||||
|
// 2. **Retry on port replacement** — if `videoPort` changes (e.g. the
|
||||||
|
// offscreen health probe reconnected the port mid-request), the
|
||||||
|
// SW re-issues REQUEST_BUFFER on the new port with the SAME
|
||||||
|
// requestId. This is the architectural mechanism that retires the
|
||||||
|
// H2 silent-drop class — the BUFFER reaches the SW regardless of
|
||||||
|
// port-replacement timing.
|
||||||
|
//
|
||||||
|
// 3. **Operator-visible error surface on empty video** — createArchive
|
||||||
|
// throws when there are zero video segments. saveArchive catches
|
||||||
|
// and returns `{success: false, error}` so the popup can surface
|
||||||
|
// RECORDING_ERROR to the operator. Replaces the upstream silent-
|
||||||
|
// skip branch (src/background/index.ts:346-352) that shipped a
|
||||||
|
// video-less archive in silence.
|
||||||
|
//
|
||||||
|
// 4. **PING → PONG echo** — the SW-side port message sink replies to
|
||||||
|
// PING with a PONG response, completing the offscreen's health-
|
||||||
|
// probe loop.
|
||||||
|
//
|
||||||
|
// These are the SW-side acceptance criteria for the Option C refactor.
|
||||||
|
// All four flip GREEN when the implementation lands in src/background/index.ts.
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
interface PortStub {
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePortStub(name = 'video-keepalive'): PortStub {
|
||||||
|
const port: PortStub = {
|
||||||
|
name,
|
||||||
|
sender: { id: 'ext-id-test' },
|
||||||
|
postMessage: vi.fn(),
|
||||||
|
onMessage: {
|
||||||
|
addListener: vi.fn(),
|
||||||
|
removeListener: vi.fn(),
|
||||||
|
_listeners: [],
|
||||||
|
},
|
||||||
|
onDisconnect: {
|
||||||
|
addListener: (fn: () => void) => {
|
||||||
|
port.onDisconnect._listeners.push(fn);
|
||||||
|
},
|
||||||
|
_listeners: [],
|
||||||
|
},
|
||||||
|
disconnect: vi.fn(),
|
||||||
|
};
|
||||||
|
port.onMessage.addListener.mockImplementation(
|
||||||
|
(fn: (msg: unknown) => void) => {
|
||||||
|
port.onMessage._listeners.push(fn);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
port.onMessage.removeListener.mockImplementation(
|
||||||
|
(fn: (msg: unknown) => void) => {
|
||||||
|
const idx = port.onMessage._listeners.indexOf(fn);
|
||||||
|
if (idx >= 0) {
|
||||||
|
port.onMessage._listeners.splice(idx, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnConnectCallback {
|
||||||
|
(port: PortStub): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OnMessageCallback {
|
||||||
|
(
|
||||||
|
msg: unknown,
|
||||||
|
sender: { id?: string },
|
||||||
|
sendResponse: (resp: unknown) => void
|
||||||
|
): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BackgroundChromeStub {
|
||||||
|
runtime: {
|
||||||
|
id: string;
|
||||||
|
getURL: (path: string) => string;
|
||||||
|
getManifest: () => { version: string };
|
||||||
|
sendMessage: ReturnType<typeof vi.fn>;
|
||||||
|
onMessage: {
|
||||||
|
addListener: ReturnType<typeof vi.fn>;
|
||||||
|
_callbacks: OnMessageCallback[];
|
||||||
|
};
|
||||||
|
onConnect: {
|
||||||
|
addListener: (cb: OnConnectCallback) => void;
|
||||||
|
_callbacks: OnConnectCallback[];
|
||||||
|
};
|
||||||
|
onInstalled: { addListener: ReturnType<typeof vi.fn> };
|
||||||
|
};
|
||||||
|
offscreen: {
|
||||||
|
hasDocument?: () => Promise<boolean>;
|
||||||
|
createDocument: ReturnType<typeof vi.fn>;
|
||||||
|
Reason: { DISPLAY_MEDIA: 'DISPLAY_MEDIA' };
|
||||||
|
};
|
||||||
|
tabs: {
|
||||||
|
query: ReturnType<typeof vi.fn>;
|
||||||
|
sendMessage: ReturnType<typeof vi.fn>;
|
||||||
|
captureVisibleTab: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
downloads: {
|
||||||
|
download: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GlobalWithBackgroundChrome {
|
||||||
|
chrome?: BackgroundChromeStub;
|
||||||
|
indexedDB?: { deleteDatabase: ReturnType<typeof vi.fn> };
|
||||||
|
fetch?: typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackgroundChromeStub(): BackgroundChromeStub {
|
||||||
|
const stub: BackgroundChromeStub = {
|
||||||
|
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(), _callbacks: [] },
|
||||||
|
onConnect: { addListener: (cb) => stub.runtime.onConnect._callbacks.push(cb), _callbacks: [] },
|
||||||
|
onInstalled: { addListener: vi.fn() },
|
||||||
|
},
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
stub.runtime.onMessage.addListener.mockImplementation((cb: OnMessageCallback) => {
|
||||||
|
stub.runtime.onMessage._callbacks.push(cb);
|
||||||
|
});
|
||||||
|
return stub;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic Promise-resolved fetch that returns a fake screenshot Blob.
|
||||||
|
async function stubFetch(_url: string): Promise<Response> {
|
||||||
|
// The SW's captureScreenshot does `await fetch(dataUrl).blob()`. We
|
||||||
|
// return a minimal PNG Blob.
|
||||||
|
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
|
||||||
|
return {
|
||||||
|
blob: async () => new Blob([png], { type: 'image/png' }),
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SW request-id protocol + retry + error surface (RED — Option C)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// 1. REQUEST_BUFFER carries a requestId — the SW generates one per call
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
it('1: getVideoBufferFromOffscreen sends REQUEST_BUFFER with a requestId', async () => {
|
||||||
|
const stub = buildBackgroundChromeStub();
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub;
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = {
|
||||||
|
deleteDatabase: vi.fn(),
|
||||||
|
};
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await import('../../src/background/index');
|
||||||
|
|
||||||
|
// Synthetically wire an offscreen port.
|
||||||
|
expect(stub.runtime.onConnect._callbacks.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const port = makePortStub();
|
||||||
|
stub.runtime.onConnect._callbacks[0](port);
|
||||||
|
|
||||||
|
// Trigger SAVE_ARCHIVE via the onMessage path. Capture the sendResponse
|
||||||
|
// callback so we can resolve a synthetic BUFFER response.
|
||||||
|
let saveResp: unknown = undefined;
|
||||||
|
const sendResp = (r: unknown) => {
|
||||||
|
saveResp = r;
|
||||||
|
};
|
||||||
|
const onMessageHandler = stub.runtime.onMessage._callbacks[0];
|
||||||
|
onMessageHandler(
|
||||||
|
{ type: 'SAVE_ARCHIVE' },
|
||||||
|
{ id: 'ext-id-test' },
|
||||||
|
sendResp,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drain microtasks so the saveArchive chain calls getVideoBufferFromOffscreen.
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
// The first message posted on the port must be REQUEST_BUFFER WITH a
|
||||||
|
// requestId field. Plain `{type:'REQUEST_BUFFER'}` without a requestId
|
||||||
|
// is the legacy shape and not acceptable under Option C.
|
||||||
|
const requestBufferCalls = port.postMessage.mock.calls.filter(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
typeof c[0] === 'object' &&
|
||||||
|
c[0] !== null &&
|
||||||
|
(c[0] as { type?: unknown }).type === 'REQUEST_BUFFER'
|
||||||
|
);
|
||||||
|
expect(requestBufferCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const reqMsg = requestBufferCalls[0][0] as { requestId?: unknown };
|
||||||
|
expect(typeof reqMsg.requestId).toBe('string');
|
||||||
|
expect((reqMsg.requestId as string).length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Cleanup — resolve the in-flight save by delivering a matching
|
||||||
|
// BUFFER so the test doesn't leave a dangling Promise.
|
||||||
|
port.onMessage._listeners.forEach((fn) =>
|
||||||
|
fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] })
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
void saveResp;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// 2. Stale BUFFER (different requestId) is ignored
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
it('2: SW ignores BUFFER with a mismatched requestId (no cross-talk)', async () => {
|
||||||
|
const stub = buildBackgroundChromeStub();
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub;
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = {
|
||||||
|
deleteDatabase: vi.fn(),
|
||||||
|
};
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await import('../../src/background/index');
|
||||||
|
|
||||||
|
const port = makePortStub();
|
||||||
|
stub.runtime.onConnect._callbacks[0](port);
|
||||||
|
|
||||||
|
let saveResp: unknown = undefined;
|
||||||
|
stub.runtime.onMessage._callbacks[0](
|
||||||
|
{ type: 'SAVE_ARCHIVE' },
|
||||||
|
{ id: 'ext-id-test' },
|
||||||
|
(r) => {
|
||||||
|
saveResp = r;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
const requestBufferCalls = port.postMessage.mock.calls.filter(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
typeof c[0] === 'object' &&
|
||||||
|
c[0] !== null &&
|
||||||
|
(c[0] as { type?: unknown }).type === 'REQUEST_BUFFER'
|
||||||
|
);
|
||||||
|
const reqMsg = requestBufferCalls[0][0] as { requestId: string };
|
||||||
|
|
||||||
|
// Deliver a STALE BUFFER (different requestId) — the SW must NOT
|
||||||
|
// resolve its in-flight request on this.
|
||||||
|
port.onMessage._listeners.forEach((fn) =>
|
||||||
|
fn({
|
||||||
|
type: 'BUFFER',
|
||||||
|
requestId: 'completely-different-id-xxx',
|
||||||
|
segments: [
|
||||||
|
{
|
||||||
|
data: 'GkXfow==',
|
||||||
|
type: 'video/webm',
|
||||||
|
timestamp: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Now drain microtasks; the in-flight request must still be pending.
|
||||||
|
// We confirm by observing that saveResp is still undefined.
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
expect(saveResp).toBeUndefined();
|
||||||
|
|
||||||
|
// Deliver the matching BUFFER to resolve cleanly.
|
||||||
|
port.onMessage._listeners.forEach((fn) =>
|
||||||
|
fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] })
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// 3. SW retries REQUEST_BUFFER on port replacement
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The architectural fix: when `videoPort` is replaced by a fresh
|
||||||
|
// onConnect call (the offscreen reconnected its port mid-request), the
|
||||||
|
// SW re-issues REQUEST_BUFFER on the new port with the same requestId.
|
||||||
|
// This is the mechanism that retires the H2 silent-drop class.
|
||||||
|
it('3: SW re-issues REQUEST_BUFFER on the new port after a port replacement', async () => {
|
||||||
|
const stub = buildBackgroundChromeStub();
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub;
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = {
|
||||||
|
deleteDatabase: vi.fn(),
|
||||||
|
};
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await import('../../src/background/index');
|
||||||
|
|
||||||
|
const oldPort = makePortStub();
|
||||||
|
stub.runtime.onConnect._callbacks[0](oldPort);
|
||||||
|
|
||||||
|
let saveResp: unknown = undefined;
|
||||||
|
stub.runtime.onMessage._callbacks[0](
|
||||||
|
{ type: 'SAVE_ARCHIVE' },
|
||||||
|
{ id: 'ext-id-test' },
|
||||||
|
(r) => {
|
||||||
|
saveResp = r;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
const oldRequestBufferCalls = oldPort.postMessage.mock.calls.filter(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
typeof c[0] === 'object' &&
|
||||||
|
c[0] !== null &&
|
||||||
|
(c[0] as { type?: unknown }).type === 'REQUEST_BUFFER'
|
||||||
|
);
|
||||||
|
expect(oldRequestBufferCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const reqMsg = oldRequestBufferCalls[0][0] as { requestId: string };
|
||||||
|
|
||||||
|
// Simulate port replacement: fire onDisconnect on the old port, then
|
||||||
|
// a fresh onConnect for the new one.
|
||||||
|
oldPort.onDisconnect._listeners.forEach((fn) => fn());
|
||||||
|
const newPort = makePortStub();
|
||||||
|
stub.runtime.onConnect._callbacks[0](newPort);
|
||||||
|
|
||||||
|
// Drain microtasks for the SW to observe the new port and retry.
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
// The new port must have received a REQUEST_BUFFER too (the retry),
|
||||||
|
// preserving the SAME requestId so the SW can route the eventual
|
||||||
|
// BUFFER response back to the original in-flight Promise.
|
||||||
|
const newRequestBufferCalls = newPort.postMessage.mock.calls.filter(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
typeof c[0] === 'object' &&
|
||||||
|
c[0] !== null &&
|
||||||
|
(c[0] as { type?: unknown }).type === 'REQUEST_BUFFER'
|
||||||
|
);
|
||||||
|
expect(newRequestBufferCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
const retryReq = newRequestBufferCalls[0][0] as { requestId: string };
|
||||||
|
expect(retryReq.requestId).toBe(reqMsg.requestId);
|
||||||
|
|
||||||
|
// Deliver BUFFER on the new port — saveArchive resolves.
|
||||||
|
newPort.onMessage._listeners.forEach((fn) =>
|
||||||
|
fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] })
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
void saveResp;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// 4. Operator-visible error surface on empty video
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The upstream H2 silent-skip in createArchive must be retired.
|
||||||
|
// saveArchive must return `{success: false, error}` (not
|
||||||
|
// `{success: true}` with a video-less archive) when the video buffer
|
||||||
|
// is empty AND the archive would therefore ship without video.
|
||||||
|
//
|
||||||
|
// The popup already handles `{success: false, error}` by surfacing
|
||||||
|
// RECORDING_ERROR to the operator.
|
||||||
|
it('4: saveArchive returns success:false when video segments are empty', async () => {
|
||||||
|
const stub = buildBackgroundChromeStub();
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub;
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = {
|
||||||
|
deleteDatabase: vi.fn(),
|
||||||
|
};
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await import('../../src/background/index');
|
||||||
|
|
||||||
|
const port = makePortStub();
|
||||||
|
stub.runtime.onConnect._callbacks[0](port);
|
||||||
|
|
||||||
|
let saveResp: { success?: boolean; error?: unknown } | undefined;
|
||||||
|
stub.runtime.onMessage._callbacks[0](
|
||||||
|
{ type: 'SAVE_ARCHIVE' },
|
||||||
|
{ id: 'ext-id-test' },
|
||||||
|
(r) => {
|
||||||
|
saveResp = r as { success?: boolean; error?: unknown };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
for (let i = 0; i < 32; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
const requestBufferCalls = port.postMessage.mock.calls.filter(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
typeof c[0] === 'object' &&
|
||||||
|
c[0] !== null &&
|
||||||
|
(c[0] as { type?: unknown }).type === 'REQUEST_BUFFER'
|
||||||
|
);
|
||||||
|
const reqMsg = requestBufferCalls[0][0] as { requestId: string };
|
||||||
|
|
||||||
|
// Resolve BUFFER with ZERO segments — the silent-skip scenario.
|
||||||
|
port.onMessage._listeners.forEach((fn) =>
|
||||||
|
fn({ type: 'BUFFER', requestId: reqMsg.requestId, segments: [] })
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drain microtasks for saveArchive to complete.
|
||||||
|
for (let i = 0; i < 64; i++) await Promise.resolve();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
for (let i = 0; i < 64; i++) await Promise.resolve();
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 0));
|
||||||
|
for (let i = 0; i < 64; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
expect(saveResp).toBeDefined();
|
||||||
|
expect(saveResp?.success).toBe(false);
|
||||||
|
expect(saveResp?.error).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
// 5. SW echoes PONG on PING (health-probe loop closure)
|
||||||
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// The offscreen's health probe expects a PONG echo per PING. The SW's
|
||||||
|
// port message sink must reply.
|
||||||
|
it('5: SW replies to PING with PONG (closes the health-probe loop)', async () => {
|
||||||
|
const stub = buildBackgroundChromeStub();
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).chrome = stub;
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).indexedDB = {
|
||||||
|
deleteDatabase: vi.fn(),
|
||||||
|
};
|
||||||
|
(globalThis as unknown as GlobalWithBackgroundChrome).fetch = stubFetch as unknown as typeof fetch;
|
||||||
|
|
||||||
|
await import('../../src/background/index');
|
||||||
|
|
||||||
|
const port = makePortStub();
|
||||||
|
stub.runtime.onConnect._callbacks[0](port);
|
||||||
|
|
||||||
|
// Dispatch PING.
|
||||||
|
expect(port.onMessage._listeners.length).toBeGreaterThanOrEqual(1);
|
||||||
|
port.onMessage._listeners.forEach((fn) => fn({ type: 'PING' }));
|
||||||
|
|
||||||
|
// Drain microtasks.
|
||||||
|
for (let i = 0; i < 16; i++) await Promise.resolve();
|
||||||
|
|
||||||
|
// The SW must have responded with PONG via postMessage on the port.
|
||||||
|
const pongCalls = port.postMessage.mock.calls.filter(
|
||||||
|
(c: unknown[]) =>
|
||||||
|
typeof c[0] === 'object' &&
|
||||||
|
c[0] !== null &&
|
||||||
|
(c[0] as { type?: unknown }).type === 'PONG'
|
||||||
|
);
|
||||||
|
expect(pongCalls.length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
309
tests/offscreen/port-health-probe.test.ts
Normal file
309
tests/offscreen/port-health-probe.test.ts
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
// 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<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 ChromeStub {
|
||||||
|
runtime: {
|
||||||
|
id: string;
|
||||||
|
sendMessage: ReturnType<typeof vi.fn>;
|
||||||
|
onMessage: { addListener: ReturnType<typeof vi.fn> };
|
||||||
|
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<typeof setTimeout>;
|
||||||
|
}) 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<typeof setInterval>;
|
||||||
|
}) 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<typeof setInterval>;
|
||||||
|
}) 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<void>((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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user