Milestone v1 (v2.0.0): Mokosh — Session Capture #1

Merged
strategy155 merged 297 commits from gsd/phase-04-harden-clean-up-optional into main 2026-05-31 15:34:17 +00:00
Showing only changes of commit 333e0dcb18 - Show all commits

View File

@@ -0,0 +1,327 @@
// 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');
});
});