feat(01-09): GREEN — displaySurface:'monitor' constraint + post-grant validation

Plan 01-09 Task 2 GREEN — flips Task 1 tests 1, 2, 4 to GREEN:

1. CaptureErrorCode union extended with 'wrong-display-surface'.
2. classifyCaptureError branch matches 'wrong-display-surface' prefix.
3. getDisplayMedia call carries {video:{displaySurface:'monitor',
   cursor:'always'},audio:false} (Plan 01-09 D-15-display-surface +
   Phase 5 cursor:'always' opportunistic lift).
4. Post-grant validation block reads track.getSettings().displaySurface;
   on non-monitor pick: tears down stream, nulls mediaStream, throws
   wrong-display-surface Error which routes through the existing
   classifyCaptureError + RECORDING_ERROR broadcast path.

Type note: lib.dom.d.ts MediaTrackConstraints omits 'cursor' — used
explicit type-widening cast (NOT 'as any') to add the field without
suppressing other type checking.

Tests: 4/4 GREEN; full suite 15 files / 68 tests / GREEN.
tsc --noEmit exit 0. npm run build exit 0.
This commit is contained in:
2026-05-17 15:12:13 +02:00
parent 333e0dcb18
commit de162b4293

View File

@@ -161,10 +161,21 @@ export type CaptureErrorCode =
| 'codec-unsupported' | 'codec-unsupported'
| 'no-source-selected' | 'no-source-selected'
| 'capture-failed' | 'capture-failed'
| 'wrong-display-surface' // Plan 01-09 D-15-display-surface
| 'unknown'; | 'unknown';
export function classifyCaptureError(error: unknown): CaptureErrorCode { export function classifyCaptureError(error: unknown): CaptureErrorCode {
if (error instanceof Error) { if (error instanceof Error) {
// Plan 01-09 D-15-display-surface: post-grant validation throws
// `Error('wrong-display-surface: got "<surface>"…')` when the
// operator overrode the `displaySurface: 'monitor'` HINT (the spec
// does not treat it as a hard constraint — see Task 2 block comment
// near the getDisplayMedia call site for the constraint-hint-vs-
// enforcement rationale). Prefix-matching is safe: we control the
// message string entirely; it never wraps a DOMException.
if (error.message.startsWith('wrong-display-surface')) {
return 'wrong-display-surface';
}
// Our own assertion is the cleanest signal — it never wraps a // Our own assertion is the cleanest signal — it never wraps a
// DOMException, so prefix-matching is safe and stable. // DOMException, so prefix-matching is safe and stable.
if (error.message.startsWith('vp9 unsupported')) { if (error.message.startsWith('vp9 unsupported')) {
@@ -210,11 +221,52 @@ async function startRecording(): Promise<void> {
} }
try { try {
assertCodecSupported(); // throws if vp9 missing — no fallback assertCodecSupported(); // throws if vp9 missing — no fallback
// Plan 01-09 D-15-display-surface (UAT Test 3 "Share this tab"
// footgun retirement, disambiguated from the historical
// "D-15: operator switching tabs" decision per B-02 fix):
// • `displaySurface: 'monitor'` hints Chrome's picker to default
// to entire-screen selection instead of the tab list.
// • `cursor: 'always'` opportunistically lifts the Phase 5
// cursor-visibility refinement (we want the operator's pointer
// captured for debugging context).
// Both constraints are HINTS per the W3C Screen Capture spec — the
// operator can still override via the picker UI. The post-grant
// validation block immediately below is the actual enforcement.
// Type note: lib.dom.d.ts MediaTrackConstraints omits `cursor`
// (the Screen Capture spec defines it but TypeScript's bundled
// types lag — https://developer.mozilla.org/docs/Web/API/MediaTrackConstraints/cursor).
// Use a typed widening through `DisplayMediaStreamOptions &
// {video: Record<...>}` instead of `as any` to stay explicit
// about the precise field we're adding.
const stream = await navigator.mediaDevices.getDisplayMedia({ const stream = await navigator.mediaDevices.getDisplayMedia({
video: true, video: { displaySurface: 'monitor', cursor: 'always' },
audio: false, // SPEC §9 — Phase 2 / CAP-01 territory audio: false, // SPEC §9 — Phase 2 / CAP-01 territory
} as DisplayMediaStreamOptions & {
video: { displaySurface: 'monitor'; cursor: 'always' };
}); });
mediaStream = stream; mediaStream = stream;
// Post-grant validation — the constraint-hint-vs-enforcement gap.
// getDisplayMedia's `displaySurface` is a HINT, not a hard
// constraint: the operator may pick a tab/window from the picker
// UI regardless of what we requested. We enforce monitor-only
// here by inspecting the actual track settings. On mismatch we
// tear down the stream and throw a routable error — the catch
// block below classifies it via classifyCaptureError, broadcasts
// RECORDING_ERROR{code: 'wrong-display-surface'}, and re-throws.
const videoTrack = stream.getVideoTracks()[0];
if (videoTrack !== undefined) {
const observed = videoTrack.getSettings().displaySurface;
if (observed !== 'monitor') {
stream.getTracks().forEach((t) => {
try { t.stop(); } catch (_e) { /* defensive — stop may throw on
already-stopped tracks; ignore. */ }
});
mediaStream = null;
throw new Error(
`wrong-display-surface: got "${observed}", expected "monitor"`,
);
}
}
// Track end detection — RESEARCH.md Example F. Attach to ALL tracks // Track end detection — RESEARCH.md Example F. Attach to ALL tracks
// (Pitfall 6) так, чтобы edge-case-аудио-трек не оставил нас в // (Pitfall 6) так, чтобы edge-case-аудио-трек не оставил нас в
// несинхронизованном состоянии. Регистрация — один раз на поток; // несинхронизованном состоянии. Регистрация — один раз на поток;