// tests/offscreen/display-surface-constraint.test.ts // // Plan 01-09 Task 1 — RED gate for the D-15-display-surface contract. // // The contract this test file pins (per .planning/phases/01-stabilize-video-pipeline/ // 01-CONTEXT.md amendment "D-15-display-surface" and the per-task spec in // 01-09-PLAN.md): // // 1. `startRecording` invokes `navigator.mediaDevices.getDisplayMedia` // with the EXACT constraints object `{video: { displaySurface: // 'monitor', cursor: 'always' }, audio: false}`. Strict deep-equality // (NOT `expect.objectContaining`) is used so that any future // refactor that silently drops `cursor: 'always'` while keeping // `displaySurface: 'monitor'` (or vice versa) fails loudly — both // constraints are load-bearing under D-15-display-surface (the // former for the picker default, the latter lifts the Phase 5 // cursor-visibility refinement opportunistically). // // 2. When the operator's picker yields a non-monitor source // (`displaySurface === 'browser'` for a tab share, etc.), the // recorder MUST: // a. Tear down the stream by calling `stop()` on every track. // b. Broadcast `{type: 'RECORDING_ERROR', error: // 'wrong-display-surface'}` via `chrome.runtime.sendMessage`. // This is the "tab footgun" retirement — UAT Test 3's // "Share this tab instead" affordance. // // 3. When `displaySurface === 'monitor'` (happy path), the recorder // proceeds WITHOUT emitting a wrong-display-surface RECORDING_ERROR. // Guards against the validation block over-firing. // // 4. `classifyCaptureError` returns `'wrong-display-surface'` for an // Error whose message starts with `'wrong-display-surface'`. // Extends the CaptureErrorCode union by one stable code. // // Test scaffold derivation: `tests/offscreen/handshake.test.ts` provides // the chrome stub + dynamic-import pattern used throughout the offscreen // suite. `tests/offscreen/codec-check.test.ts` provides the per-error- // code branch test idiom for `classifyCaptureError`. This file composes // both, extended with a `navigator.mediaDevices.getDisplayMedia` mock // because jsdom does not supply a capture pipeline. // // All four tests are RED before Task 2; all four flip GREEN after Task 2. import { describe, it, expect, vi, beforeEach } from 'vitest'; // Минимальный port stub. `recorder.ts:bootstrap` вызывает // `chrome.runtime.connect(...)` при загрузке модуля, и далее навешивает // `onMessage` / `onDisconnect` listeners + PING interval. Возвращаем // объект с no-op listenерами и `disconnect`, чтобы bootstrap не падал. interface PortStub { name: string; postMessage: ReturnType; onMessage: { addListener: ReturnType }; onDisconnect: { addListener: ReturnType }; disconnect: ReturnType; } function makePortStub(): PortStub { return { name: 'video-keepalive', postMessage: vi.fn(), onMessage: { addListener: vi.fn() }, onDisconnect: { addListener: vi.fn() }, disconnect: vi.fn(), }; } interface OnMessageCallback { ( msg: { type?: unknown }, sender: { id?: string }, sendResponse: (resp: unknown) => void, ): boolean | undefined; } interface RecorderChromeStub { runtime: { id: string; sendMessage: ReturnType; onMessage: { addListener: ReturnType; _callbacks: OnMessageCallback[]; }; connect: () => PortStub; }; } interface GlobalWithChrome { chrome?: RecorderChromeStub; MediaRecorder?: unknown; navigator?: { mediaDevices?: { getDisplayMedia?: ReturnType }; userAgent?: string; }; } function buildChromeStub(): RecorderChromeStub { const stub: RecorderChromeStub = { runtime: { id: 'ext-id-test', sendMessage: vi.fn(), onMessage: { addListener: vi.fn(), _callbacks: [], }, connect: () => makePortStub(), }, }; stub.runtime.onMessage.addListener.mockImplementation( (cb: OnMessageCallback) => { stub.runtime.onMessage._callbacks.push(cb); }, ); return stub; } // Минимальный MediaStream stub: один видео-трек с настраиваемым // `displaySurface` и spy-`stop`. `getTracks` возвращает массив с одним // треком; `getVideoTracks` — то же самое (производственный код вызывает // `getVideoTracks()[0]` для post-grant валидации). interface TrackStub { kind: 'video'; getSettings: () => { displaySurface?: string }; stop: ReturnType; addEventListener: ReturnType; } interface MediaStreamStub { getTracks: () => TrackStub[]; getVideoTracks: () => TrackStub[]; } function makeStreamStub(displaySurface: string | undefined): MediaStreamStub { const track: TrackStub = { kind: 'video', getSettings: () => ({ displaySurface }), stop: vi.fn(), addEventListener: vi.fn(), }; return { getTracks: () => [track], getVideoTracks: () => [track], }; } // Helper: install a navigator stub. Node's globalThis.navigator is a // getter-only property in Node >=21 / Vitest's node env, so direct // assignment throws "Cannot set property navigator of # which has // only a getter". Use Object.defineProperty with configurable:true so // subsequent tests can re-install their own variant. function installNavigatorStub( getDisplayMediaSpy: ReturnType, ): void { Object.defineProperty(globalThis, 'navigator', { value: { mediaDevices: { getDisplayMedia: getDisplayMediaSpy }, userAgent: 'test', }, configurable: true, writable: true, }); } // MediaRecorder заглушка — реальный класс jsdom не поставляет. Конструктор // должен бросать, чтобы `startRecording` падал ПОСЛЕ post-grant валидации: // тесты T2/T3 проверяют именно валидацию + RECORDING_ERROR-эмиссию, а не // успешный запуск рекордера. Tests T2/T3 ignore the post-validation throw // from the MediaRecorder constructor (jsdom лимитация — производство этот // путь не достигает). function installMediaRecorderStub(supported: boolean): void { class MediaRecorderThrow { static isTypeSupported(_mime: string): boolean { return supported; } constructor() { throw new Error('jsdom-no-MediaRecorder'); } } (globalThis as unknown as GlobalWithChrome).MediaRecorder = MediaRecorderThrow; } describe('Plan 01-09 D-15-display-surface: getDisplayMedia constraints + post-grant validation', () => { beforeEach(() => { vi.resetModules(); installMediaRecorderStub(true); }); // ────────────────────────────────────────────────────────────────────── // Test 1 — strict deep-equality on the constraints object. // // Asserts `getDisplayMedia` was called with the EXACT constraints // shape D-15-display-surface specifies. Strict-deep-equality (per // 01-09-PLAN.md Task 1 I-03 fix) catches a future refactor that // accidentally drops `cursor: 'always'` while keeping // `displaySurface: 'monitor'`. RED today: current call passes // `{video: true, audio: false}`. // ────────────────────────────────────────────────────────────────────── it('1: startRecording calls getDisplayMedia with the exact D-15 constraints object', async () => { const stub = buildChromeStub(); (globalThis as unknown as GlobalWithChrome).chrome = stub; const getDisplayMediaSpy = vi .fn() .mockResolvedValue(makeStreamStub('monitor')); installNavigatorStub(getDisplayMediaSpy); // Reset module so bootstrap runs against the just-installed stubs. await import('../../src/offscreen/recorder'); // Trigger START_RECORDING through the registered onMessage handler. expect(stub.runtime.onMessage._callbacks.length).toBeGreaterThanOrEqual(1); const handler = stub.runtime.onMessage._callbacks[0]; handler({ type: 'START_RECORDING' }, { id: 'ext-id-test' }, vi.fn()); // Drain microtasks so the async startRecording reaches getDisplayMedia. for (let i = 0; i < 16; i++) await Promise.resolve(); expect(getDisplayMediaSpy).toHaveBeenCalledTimes(1); // Strict deep-equality — NOT expect.objectContaining. Catches a // future drop of either `displaySurface` or `cursor` (D-15) OR // the Plan 01-14 top-level `monitorTypeSurfaces: 'include'` picker- // narrowing constraint. Key ordering matches the source call at // src/offscreen/recorder.ts: video → monitorTypeSurfaces → audio. expect(getDisplayMediaSpy).toHaveBeenCalledWith({ video: { displaySurface: 'monitor', cursor: 'always' }, monitorTypeSurfaces: 'include', audio: false, }); }); // ────────────────────────────────────────────────────────────────────── // Test 2 — tab-share footgun: validation tears down + emits RECORDING_ERROR. // ────────────────────────────────────────────────────────────────────── it('2: when picker yields non-monitor displaySurface, stream tracks stop AND RECORDING_ERROR wrong-display-surface fires', async () => { const stub = buildChromeStub(); (globalThis as unknown as GlobalWithChrome).chrome = stub; const stream = makeStreamStub('browser'); const track = stream.getTracks()[0]; const getDisplayMediaSpy = vi.fn().mockResolvedValue(stream); installNavigatorStub(getDisplayMediaSpy); await import('../../src/offscreen/recorder'); const handler = stub.runtime.onMessage._callbacks[0]; handler({ type: 'START_RECORDING' }, { id: 'ext-id-test' }, vi.fn()); for (let i = 0; i < 16; i++) await Promise.resolve(); // Track must be stopped — the stream was torn down. expect(track.stop).toHaveBeenCalled(); // RECORDING_ERROR with the wrong-display-surface code must have // been broadcast. The same code may also flow into the catch-block // emission inside `startRecording` (since the validation throws and // routes through classifyCaptureError); either way at least one // RECORDING_ERROR with the right code must be present. const wrongDisplayCalls = stub.runtime.sendMessage.mock.calls.filter( (args: unknown[]) => { const msg = args[0] as { type?: unknown; error?: unknown }; return ( typeof msg === 'object' && msg !== null && msg.type === 'RECORDING_ERROR' && msg.error === 'wrong-display-surface' ); }, ); expect(wrongDisplayCalls.length).toBeGreaterThanOrEqual(1); }); // ────────────────────────────────────────────────────────────────────── // Test 3 — monitor displaySurface does NOT trip the validation. // // The MediaRecorder stub will throw inside startNewSegment (jsdom // doesn't ship a real one), so we IGNORE that downstream emission; // the test only asserts that the wrong-display-surface code is NOT // among the RECORDING_ERRORs sent. // ────────────────────────────────────────────────────────────────────── it('3: when picker yields monitor displaySurface, no wrong-display-surface RECORDING_ERROR fires', async () => { const stub = buildChromeStub(); (globalThis as unknown as GlobalWithChrome).chrome = stub; const getDisplayMediaSpy = vi .fn() .mockResolvedValue(makeStreamStub('monitor')); installNavigatorStub(getDisplayMediaSpy); await import('../../src/offscreen/recorder'); const handler = stub.runtime.onMessage._callbacks[0]; handler({ type: 'START_RECORDING' }, { id: 'ext-id-test' }, vi.fn()); for (let i = 0; i < 16; i++) await Promise.resolve(); const wrongDisplayCalls = stub.runtime.sendMessage.mock.calls.filter( (args: unknown[]) => { const msg = args[0] as { type?: unknown; error?: unknown }; return ( typeof msg === 'object' && msg !== null && msg.type === 'RECORDING_ERROR' && msg.error === 'wrong-display-surface' ); }, ); expect(wrongDisplayCalls.length).toBe(0); }); // ────────────────────────────────────────────────────────────────────── // Test 4 — classifyCaptureError routes the new error-message prefix. // ────────────────────────────────────────────────────────────────────── it('4: classifyCaptureError returns "wrong-display-surface" for matching Error message prefix', async () => { // No need for the heavy chrome / navigator setup — codec stub + the // module import is all classifyCaptureError needs. (globalThis as unknown as GlobalWithChrome).chrome = buildChromeStub(); installNavigatorStub( vi.fn().mockResolvedValue(makeStreamStub('monitor')), ); const mod = await import('../../src/offscreen/recorder'); expect( mod.classifyCaptureError( new Error('wrong-display-surface: got "browser", expected "monitor"'), ), ).toBe('wrong-display-surface'); }); });