Files
mokosh/tests/offscreen/display-surface-constraint.test.ts
Mark b467123578 feat(01-14): monitorTypeSurfaces:'include' — narrow picker to monitor surfaces only
[per Plan 01-14; closes B-01-14-01 via Step 1b lockstep]

- src/offscreen/recorder.ts: add monitorTypeSurfaces:'include' as top-level
  DisplayMediaStreamOptions sibling of video: (W3C Screen Capture spec §6.1;
  Chrome >= 119; removes tab/window panes from the operator's picker per
  Plan 01-10 RESEARCH §5 + §Pitfall-5 recommendation). Typed widening cast
  extended in lockstep to keep the explicit-typing contract (no `as any`).
  D-15 post-grant validation block at recorder.ts:294 UNCHANGED — belt
  (picker narrowing) + suspenders (post-grant tear-down) chain preserved.

- tests/offscreen/display-surface-constraint.test.ts: lockstep update of
  the strict-deep-equality assertion at lines 223-226 with the same key
  ordering as the source change (video -> monitorTypeSurfaces -> audio).
  toHaveBeenCalledWith contract preserved (NO expect.objectContaining —
  the test author's "catches future drops of ANY field" discipline is
  honored). This edit + the source change land in the SAME commit so the
  98/98 baseline never crosses a commit boundary in RED state.

- src/test-hooks/offscreen-hooks.ts: capture last constraints object in
  module-scoped `lastGetDisplayMediaConstraints` cell (was `_constraints`
  received-but-unused; renamed to `constraints`); add `get-last-getDisplayMedia-constraints`
  bridge op to the __mokoshOffscreenQuery dispatcher between
  get-display-surface and get-segment-count. Defensive try/catch mirrors
  the existing dispatcher pattern; the cell is module-internal so the
  MokoshTestSurface cross-cast in types.ts requires NO change (decision
  documented inline in offscreen-hooks.ts).

- tests/uat/extension-page-harness.ts: add `assertA23` mirroring `assertA3`
  (bridge query → 2-check AssertionResult: non-null constraints + value).
  Extend the `Window.__mokoshHarness` declaration + runtime export + status
  bar text + console.log to reference A23.

- tests/uat/lib/harness-page-driver.ts: export `driveA23(page)` mirroring
  the `driveA14` page.evaluate wrapper shape. Standard read-only driver.

- tests/uat/harness.test.ts: extend FORBIDDEN_HOOK_STRINGS (line 85) with
  `lastGetDisplayMediaConstraints` and `get-last-getDisplayMedia-constraints`.
  Import driveA23. Append `{ name: 'A23', drive: driveA23 }` to the drivers
  array after the A14 entry. Update header comment + orchestrator stdout
  to reflect A14 + A23 chain. The `Total = drivers.length + 1` arithmetic
  adapts automatically: 14 + 1 = 15 → 15 + 1 = 16.

- tests/background/no-test-hooks-in-prod-bundle.test.ts: lockstep
  extension of FORBIDDEN_HOOK_STRINGS (line 105) with the same 2 strings.
  Header comment updated to "Total: 12 surface strings." (was 10).
  Confirms production `dist/` has ZERO occurrences after `npm run build`
  via the `__MOKOSH_UAT__` dead-branch tree-shake (T-01-14-04 mitigation).

D-01 (whole-desktop only via getDisplayMedia; reject window/tab surfaces) is
the design intent that monitorTypeSurfaces:'include' realizes at the picker-
UI level. D-15 post-grant validation (recorder.ts:294-307) remains the
actual enforcement against managed-policy/DevTools/older-Chrome overrides.

Verification chain (per Plan 01-14 §verify; clean post-commit):
- `npx tsc --noEmit` exit 0
- `npm run build` exit 0; dist/ produced, monitorTypeSurfaces ships in
  the offscreen chunk as the operator-facing picker hint
- `npm run build:test` exit 0; dist-test/ produced with the harness
  hooks intact (gated)
- `npm test` 100/100 GREEN (was 98/98; +2 via the 2 new FORBIDDEN_HOOK_STRINGS
  parametrized tests — both PASS, production bundle hook-free)
- `npm run test:uat` 16/16 GREEN (15 → 16 via A23). A23 reads constraints
  `{video: {...}, monitorTypeSurfaces: 'include', audio: false}` from the
  fakeGetDisplayMedia capture cell — round-trips through the full call site.
- Production bundle spot-check:
    `grep -rc 'lastGetDisplayMediaConstraints\|get-last-getDisplayMedia-constraints' dist/ | grep -v ':0$'`
    → empty (all `:0` filtered) → ZERO leakage.

References:
- W3C Screen Capture §6.1 DisplayMediaStreamOptions:
  https://www.w3.org/TR/screen-capture/#dom-displaymediastreamoptions-monitortypesurfaces
- Chrome screen-sharing-controls (Chrome 119+):
  https://developer.chrome.com/docs/web-platform/screen-sharing-controls
- Plan 01-10 RESEARCH §5 + §Pitfall-5 (recommendation provenance):
  .planning/phases/01-stabilize-video-pipeline/01-10-RESEARCH.md
- Architectural-note (replaces retired AMENDMENT-A.md improvisation per
  01-11-SUMMARY): canonical GSD ceremony — plan → checker (B-01-14-01)
  → executor → SUMMARY (this commit).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 21:37:59 +02:00

332 lines
14 KiB
TypeScript
Raw Permalink 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` (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');
});
});