Files
mokosh/tests/offscreen/display-surface-constraint.test.ts
Mark 333e0dcb18 test(01-09): RED — displaySurface:'monitor' + cursor:'always' constraint contract
Plan 01-09 Task 1 RED — pins 4 tests for D-15-display-surface contract:
1. getDisplayMedia called with strict {video:{displaySurface:'monitor',
   cursor:'always'},audio:false} (deep-equality, NOT objectContaining).
2. Non-monitor pick (browser/window) tears down stream + emits
   RECORDING_ERROR wrong-display-surface.
3. Monitor pick does NOT trip wrong-display-surface (over-fire guard).
4. classifyCaptureError routes 'wrong-display-surface' message prefix
   to 'wrong-display-surface' code.

Task 2 will flip Tests 1, 2, 4 to GREEN by adding constraints +
post-grant validation + extending CaptureErrorCode union.

Deviation Rule 3: navigator getter-only in Vitest's node env required
Object.defineProperty wrapper (installNavigatorStub helper) instead
of direct assignment.
2026-05-17 15:08:35 +02:00

328 lines
14 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/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<typeof vi.fn>;
onMessage: { addListener: ReturnType<typeof vi.fn> };
onDisconnect: { addListener: ReturnType<typeof vi.fn> };
disconnect: ReturnType<typeof vi.fn>;
}
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<typeof vi.fn>;
onMessage: {
addListener: ReturnType<typeof vi.fn>;
_callbacks: OnMessageCallback[];
};
connect: () => PortStub;
};
}
interface GlobalWithChrome {
chrome?: RecorderChromeStub;
MediaRecorder?: unknown;
navigator?: {
mediaDevices?: { getDisplayMedia?: ReturnType<typeof vi.fn> };
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<typeof vi.fn>;
addEventListener: ReturnType<typeof vi.fn>;
}
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 #<Object> which has
// only a getter". Use Object.defineProperty with configurable:true so
// subsequent tests can re-install their own variant.
function installNavigatorStub(
getDisplayMediaSpy: ReturnType<typeof vi.fn>,
): 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`.
expect(getDisplayMediaSpy).toHaveBeenCalledWith({
video: { displaySurface: 'monitor', cursor: 'always' },
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');
});
});