From 408aa3354ce0b19cbbe1361aaaea2b68e0ce411e Mon Sep 17 00:00:00 2001 From: Mark Date: Fri, 15 May 2026 17:25:03 +0200 Subject: [PATCH] test(01-02): add RED handshake + port tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three RED tests pin Pattern 4 (handshake) and Pattern 5 / Pitfall 4 (port reconnect on disconnect) contracts: handshake.test.ts: - 'sends OFFSCREEN_READY after listener registration' — exactly one OFFSCREEN_READY emitted at module load, AFTER onMessage.addListener port.test.ts: - 'connects on module load' — chrome.runtime.connect called once - 'reconnects when port disconnects' — firing onDisconnect triggers immediate re-connect (Pitfall 4 idle-timer reset) chrome.runtime is stubbed locally (no vitest-chrome dependency added). No 'as any' / no '@ts-ignore'; casts are 'as unknown as T'. Plan 04 must wire OFFSCREEN_READY send + port.connect({ name: 'video-keepalive' }) + onDisconnect-driven reconnect at the import-side effect layer of src/offscreen/recorder.ts. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/offscreen/handshake.test.ts | 67 +++++++++++++++++++++++ tests/offscreen/port.test.ts | 89 +++++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) create mode 100644 tests/offscreen/handshake.test.ts create mode 100644 tests/offscreen/port.test.ts diff --git a/tests/offscreen/handshake.test.ts b/tests/offscreen/handshake.test.ts new file mode 100644 index 0000000..72fe3ba --- /dev/null +++ b/tests/offscreen/handshake.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +interface PortStub { + name: string; + postMessage: ReturnType; + onMessage: { addListener: ReturnType }; + onDisconnect: { addListener: ReturnType }; + disconnect: ReturnType; +} + +interface ChromeStub { + runtime: { + id: string; + sendMessage: (m: unknown) => void; + onMessage: { addListener: ReturnType }; + connect: () => PortStub; + }; +} + +interface GlobalWithChrome { + chrome?: ChromeStub; + MediaRecorder?: { isTypeSupported: (mime: string) => boolean }; +} + +function buildChromeStub(calls: unknown[]): ChromeStub { + return { + runtime: { + id: 'ext-id-test', + sendMessage: (m: unknown) => { + calls.push(m); + }, + onMessage: { addListener: vi.fn() }, + connect: () => ({ + name: 'video-keepalive', + postMessage: vi.fn(), + onMessage: { addListener: vi.fn() }, + onDisconnect: { addListener: vi.fn() }, + disconnect: vi.fn(), + }), + }, + }; +} + +describe('OFFSCREEN_READY handshake', () => { + beforeEach(() => { + vi.resetModules(); + (globalThis as unknown as GlobalWithChrome).MediaRecorder = { + isTypeSupported: vi.fn().mockReturnValue(true), + }; + }); + + it('sends OFFSCREEN_READY after listener registration', async () => { + const calls: unknown[] = []; + const stub = buildChromeStub(calls); + (globalThis as unknown as GlobalWithChrome).chrome = stub; + await import('../../src/offscreen/recorder'); + expect(stub.runtime.onMessage.addListener).toHaveBeenCalled(); + expect(calls).toEqual( + expect.arrayContaining([expect.objectContaining({ type: 'OFFSCREEN_READY' })]) + ); + const readyCount = calls.filter( + (m): m is { type: string } => + typeof m === 'object' && m !== null && (m as { type?: unknown }).type === 'OFFSCREEN_READY' + ).length; + expect(readyCount).toBe(1); + }); +}); diff --git a/tests/offscreen/port.test.ts b/tests/offscreen/port.test.ts new file mode 100644 index 0000000..0fed9c2 --- /dev/null +++ b/tests/offscreen/port.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +interface PortStub { + name: string; + postMessage: ReturnType; + onMessage: { addListener: ReturnType }; + onDisconnect: { addListener: (fn: () => void) => void }; + disconnect: ReturnType; +} + +interface ChromeStub { + runtime: { + id: string; + sendMessage: ReturnType; + onMessage: { addListener: ReturnType }; + connect: () => PortStub; + }; +} + +interface GlobalWithChrome { + chrome?: ChromeStub; + MediaRecorder?: { isTypeSupported: (mime: string) => boolean }; +} + +describe('port reconnect', () => { + beforeEach(() => { + vi.resetModules(); + (globalThis as unknown as GlobalWithChrome).MediaRecorder = { + isTypeSupported: vi.fn().mockReturnValue(true), + }; + }); + + it('connects on module load', async () => { + let connectCount = 0; + const disconnectListeners: Array<() => void> = []; + const stub: ChromeStub = { + runtime: { + id: 'ext-id-test', + sendMessage: vi.fn(), + onMessage: { addListener: vi.fn() }, + connect: () => { + connectCount++; + return { + name: 'video-keepalive', + postMessage: vi.fn(), + onMessage: { addListener: vi.fn() }, + onDisconnect: { + addListener: (fn: () => void) => disconnectListeners.push(fn), + }, + disconnect: vi.fn(), + }; + }, + }, + }; + (globalThis as unknown as GlobalWithChrome).chrome = stub; + await import('../../src/offscreen/recorder'); + expect(connectCount).toBe(1); + }); + + it('reconnects when port disconnects', async () => { + let connectCount = 0; + const disconnectListeners: Array<() => void> = []; + const stub: ChromeStub = { + runtime: { + id: 'ext-id-test', + sendMessage: vi.fn(), + onMessage: { addListener: vi.fn() }, + connect: () => { + connectCount++; + return { + name: 'video-keepalive', + postMessage: vi.fn(), + onMessage: { addListener: vi.fn() }, + onDisconnect: { + addListener: (fn: () => void) => disconnectListeners.push(fn), + }, + disconnect: vi.fn(), + }; + }, + }, + }; + (globalThis as unknown as GlobalWithChrome).chrome = stub; + await import('../../src/offscreen/recorder'); + expect(connectCount).toBe(1); + // Fire the disconnect — module should reconnect + disconnectListeners.forEach((fn) => fn()); + expect(connectCount).toBeGreaterThanOrEqual(2); + }); +});