1import {
2 DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER,
3 EnvironmentId,
4 ThreadId,
5} from "@t3tools/contracts";
6import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
7
8import { ensureClientSettingsHydrated } from "~/hooks/useSettings";
9
10const {
11 clientSettings,
12 events,
13 getDisplayMedia,
14 registrySet,
15 requestDisplayMediaCapture,
16 save,
17 startScreencast,
18 stopScreencast,
19} = vi.hoisted(() => {
20 const events: string[] = [];
21 return {
22 clientSettings: { browserRecordingFrameRate: 30 as 30 | 60 },
23 events,
24 getDisplayMedia: vi.fn(),
25 requestDisplayMediaCapture: vi.fn((_tabId: string) => undefined),
26 registrySet: vi.fn((_atom: unknown, value: { readonly tabIds: ReadonlySet<string> }) => {
27 events.push(
28 value.tabIds.size === 0 ? "clear" : `publish:${Array.from(value.tabIds).join(",")}`,
29 );
30 }),
31 save: vi.fn(async (tabId: string) => ({
32 id: "recording-test",
33 tabId,
34 path: "/tmp/recording-test.webm",
35 mimeType: "video/webm" as const,
36 sizeBytes: 0,
37 createdAt: "2026-06-26T00:00:00.000Z",
38 })),
39 startScreencast: vi.fn(async (_tabId: string) => {
40 events.push("start-screencast");
41 }),
42 stopScreencast: vi.fn(async () => undefined),
43 };
44});
45
46vi.mock("~/components/preview/previewBridge", () => ({
47 previewBridge: {
48 recording: {
49 onFrame: vi.fn(),
50 save,
51 startScreencast: async (tabId: string) => {
52 await startScreencast(tabId);
53 requestDisplayMediaCapture(tabId);
54 },
55 stopScreencast,
56 },
57 },
58}));
59
60vi.mock("~/rpc/atomRegistry", () => ({
61 appAtomRegistry: { set: registrySet },
62}));
63
64vi.mock("~/hooks/useSettings", () => ({
65 ensureClientSettingsHydrated: vi.fn(async () => undefined),
66 getClientSettings: () => clientSettings,
67}));
68
69import {
70 BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS,
71 BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
72 BrowserRecordingCaptureTimeoutError,
73 BrowserRecordingConflictError,
74 BrowserRecordingFormatUnavailableError,
75 BrowserRecordingStartCancelledError,
76 findActiveBrowserRecordingRuntimeTabId,
77 readActiveBrowserRecordingTabIds,
78 readActiveBrowserRecordingTargets,
79 startBrowserRecording,
80 stopBrowserRecording,
81 stopBrowserRecordingForUpload,
82} from "./browserRecording";
83import { useBrowserSurfaceStore } from "./browserSurfaceStore";
84import { previewRuntimeTabId } from "./previewRuntimeTabId";
85
86class FakeMediaRecorder {
87 static readonly instances: FakeMediaRecorder[] = [];
88 static supportedTypes = new Set(["video/webm;codecs=vp9"]);
89 static outputMimeType: string | undefined;
90 static stopError: unknown;
91 static isTypeSupported(type: string): boolean {
92 return this.supportedTypes.has(type);
93 }
94
95 state: RecordingState = "inactive";
96 readonly mimeType: string;
97 readonly stream: MediaStream;
98 readonly options: MediaRecorderOptions | undefined;
99 private readonly listeners = new Map<string, Set<EventListenerOrEventListenerObject>>();
100
101 constructor(stream: MediaStream, options?: MediaRecorderOptions) {
102 this.stream = stream;
103 this.options = options;
104 this.mimeType =
105 FakeMediaRecorder.outputMimeType ?? options?.mimeType ?? "video/browser-default";
106 FakeMediaRecorder.instances.push(this);
107 }
108
109 addEventListener(type: string, listener: EventListenerOrEventListenerObject): void {
110 const listeners = this.listeners.get(type) ?? new Set();
111 listeners.add(listener);
112 this.listeners.set(type, listeners);
113 }
114
115 start(): void {
116 this.state = "recording";
117 }
118
119 stop(): void {
120 if (FakeMediaRecorder.stopError !== undefined) throw FakeMediaRecorder.stopError;
121 this.state = "inactive";
122 for (const listener of this.listeners.get("stop") ?? []) {
123 if (typeof listener === "function") listener(new Event("stop"));
124 else listener.handleEvent(new Event("stop"));
125 }
126 }
127}
128
129describe("browser recording", () => {
130 let animationFrameCount = 0;
131
132 beforeEach(() => {
133 events.length = 0;
134 vi.clearAllMocks();
135 FakeMediaRecorder.instances.length = 0;
136 FakeMediaRecorder.supportedTypes = new Set(["video/webm;codecs=vp9"]);
137 FakeMediaRecorder.outputMimeType = undefined;
138 FakeMediaRecorder.stopError = undefined;
139 clientSettings.browserRecordingFrameRate = 30;
140 animationFrameCount = 0;
141 vi.stubGlobal("window", globalThis);
142 vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
143 animationFrameCount += 1;
144 callback(animationFrameCount);
145 return animationFrameCount;
146 });
147 vi.stubGlobal("cancelAnimationFrame", vi.fn());
148 vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder);
149 getDisplayMedia.mockResolvedValue({
150 getVideoTracks: () => [],
151 getTracks: () => [{ stop: vi.fn() }],
152 });
153 requestDisplayMediaCapture.mockImplementation((tabId: string) => {
154 const trigger = Reflect.get(globalThis, DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER);
155 if (typeof trigger !== "function" || trigger(tabId) !== true) {
156 throw new Error(`No pending display-media capture for ${tabId}.`);
157 }
158 });
159 vi.stubGlobal("navigator", { mediaDevices: { getDisplayMedia } });
160 useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} });
161 });
162
163 afterEach(() => {
164 vi.useRealTimers();
165 vi.unstubAllGlobals();
166 });
167
168 it("starts recording for a visible tab", async () => {
169 await startBrowserRecording("recording-tab");
170 const startupEvents = [...events];
171
172 await stopBrowserRecording("recording-tab");
173 expect(startupEvents).toEqual(["publish:recording-tab", "start-screencast"]);
174 });
175
176 it("routes gesture-free starts through the desktop capture trigger", async () => {
177 await startBrowserRecording("automation-recording-tab");
178
179 expect(requestDisplayMediaCapture).toHaveBeenCalledWith("automation-recording-tab");
180 expect(getDisplayMedia).toHaveBeenCalledOnce();
181 await stopBrowserRecording("automation-recording-tab");
182 });
183
184 it("saves locally and releases capture before transferring the encoded recording once", async () => {
185 const stopTrack = vi.fn();
186 getDisplayMedia.mockResolvedValue({
187 getVideoTracks: () => [],
188 getTracks: () => [{ stop: stopTrack }],
189 });
190 await startBrowserRecording("transfer-tab");
191 let finishUpload!: () => void;
192 const uploaded = new Promise<void>((resolve) => {
193 finishUpload = resolve;
194 });
195 const transfer = vi.fn(async (artifact, blob: Blob) => {
196 expect(save).toHaveBeenCalledOnce();
197 expect(stopTrack).toHaveBeenCalled();
198 expect(artifact.path).toBe("/tmp/recording-test.webm");
199 expect(blob.type).toBe("video/webm;codecs=vp9");
200 await uploaded;
201 return "uploaded-recording";
202 });
203 const localStop = stopBrowserRecording("transfer-tab");
204 const firstStop = stopBrowserRecordingForUpload("transfer-tab", transfer);
205 const secondStop = stopBrowserRecordingForUpload("transfer-tab", transfer);
206 finishUpload();
207 expect(await firstStop).toEqual(await secondStop);
208 expect((await firstStop)?.uploadedAttachmentId).toBe("uploaded-recording");
209 expect((await localStop)?.path).toBe("/tmp/recording-test.webm");
210 expect(transfer).toHaveBeenCalledOnce();
211 });
212
213 it("keeps the saved desktop file and releases the recording when transfer fails", async () => {
214 await startBrowserRecording("failed-transfer-tab");
215 await expect(
216 stopBrowserRecordingForUpload("failed-transfer-tab", async () => {
217 throw new Error("Connection interrupted");
218 }),
219 ).rejects.toThrow("Connection interrupted");
220 expect(save).toHaveBeenCalledOnce();
221 expect(readActiveBrowserRecordingTabIds().has("failed-transfer-tab")).toBe(false);
222 await startBrowserRecording("failed-transfer-tab");
223 await stopBrowserRecording("failed-transfer-tab");
224 });
225
226 it("paints and holds a hidden browser surface for the recording lifetime", async () => {
227 startScreencast.mockImplementationOnce(async (tabId: string) => {
228 expect(animationFrameCount).toBe(2);
229 expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBe(1);
230 });
231 getDisplayMedia.mockImplementationOnce(async () => {
232 expect(animationFrameCount).toBe(2);
233 expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1);
234 return { getVideoTracks: () => [], getTracks: () => [{ stop: vi.fn() }] };
235 });
236
237 await startBrowserRecording("background-tab");
238 expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1);
239
240 await stopBrowserRecording("background-tab");
241 expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBeUndefined();
242 });
243
244 it("bounds compositor warmup when animation frames are paused", async () => {
245 vi.useFakeTimers();
246 const cancelAnimationFrame = vi.fn();
247 vi.stubGlobal(
248 "requestAnimationFrame",
249 vi.fn(() => 42),
250 );
251 vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrame);
252
253 const startPromise = startBrowserRecording("hidden-window-tab");
254 await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_PAINT_SETTLE_TIMEOUT_MS);
255
256 await startPromise;
257 expect(cancelAnimationFrame).toHaveBeenCalledWith(42);
258 await stopBrowserRecording("hidden-window-tab");
259 });
260
261 it.each([
262 { width: 1280, height: 720, frameRate: 60, bitrate: 2_764_800 },
263 { width: 320, height: 240, frameRate: 30, bitrate: 2_500_000 },
264 { width: 3840, height: 2160, frameRate: 60, bitrate: 24_883_200 },
265 { width: 7680, height: 4320, frameRate: 60, bitrate: 50_000_000 },
266 ])("records the native $width x $height stream at $frameRate fps", async (settings) => {
267 const stopTrack = vi.fn();
268 const stream = {
269 getVideoTracks: () => [{ getSettings: () => settings }],
270 getTracks: () => [{ stop: stopTrack }],
271 } as unknown as MediaStream;
272 getDisplayMedia.mockResolvedValueOnce(stream);
273
274 await startBrowserRecording("recording-tab");
275
276 expect(getDisplayMedia).toHaveBeenCalledWith({
277 audio: false,
278 video: { frameRate: { ideal: 30, max: 30 } },
279 });
280 expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream);
281 expect(FakeMediaRecorder.instances[0]?.options?.videoBitsPerSecond).toBe(settings.bitrate);
282
283 await stopBrowserRecording("recording-tab");
284 expect(stopTrack).toHaveBeenCalledOnce();
285 expect(stopTrack.mock.invocationCallOrder[0]).toBeLessThan(save.mock.invocationCallOrder[0]!);
286 });
287
288 it("uses the configured recording frame rate", async () => {
289 clientSettings.browserRecordingFrameRate = 60;
290
291 await startBrowserRecording("recording-tab");
292
293 expect(getDisplayMedia).toHaveBeenCalledWith({
294 audio: false,
295 video: { frameRate: { ideal: 60, max: 60 } },
296 });
297 await stopBrowserRecording("recording-tab");
298 });
299
300 it("clears a failed settings read before retrying recording", async () => {
301 const tabId = "settings-read-failure-tab";
302 const error = new Error("Settings read failed");
303 vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error);
304
305 await expect(startBrowserRecording(tabId)).rejects.toBe(error);
306
307 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
308 expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined();
309 expect(animationFrameCount).toBe(0);
310 expect(startScreencast).not.toHaveBeenCalled();
311 expect(stopScreencast).not.toHaveBeenCalled();
312 expect(getDisplayMedia).not.toHaveBeenCalled();
313 expect(FakeMediaRecorder.instances).toHaveLength(0);
314
315 clientSettings.browserRecordingFrameRate = 60;
316 await startBrowserRecording(tabId);
317
318 expect(getDisplayMedia).toHaveBeenCalledWith({
319 audio: false,
320 video: { frameRate: { ideal: 60, max: 60 } },
321 });
322 await stopBrowserRecording(tabId);
323
324 expect(startScreencast).toHaveBeenCalledOnce();
325 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
326 expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined();
327 });
328
329 it("stops the native stream when MediaRecorder cleanup fails", async () => {
330 const stopTrack = vi.fn();
331 getDisplayMedia.mockResolvedValueOnce({
332 getVideoTracks: () => [],
333 getTracks: () => [{ stop: stopTrack }],
334 });
335
336 await startBrowserRecording("recording-tab");
337 FakeMediaRecorder.stopError = new Error("stop failed");
338
339 await expect(stopBrowserRecording("recording-tab")).rejects.toMatchObject({
340 operation: "cleanup",
341 tabId: "recording-tab",
342 });
343 expect(stopTrack).toHaveBeenCalledOnce();
344 });
345
346 it("uses the best supported encoder and saves the recorder's actual format", async () => {
347 FakeMediaRecorder.supportedTypes = new Set([
348 "video/mp4;codecs=avc1",
349 "video/mp4;codecs=avc1.42e01e",
350 "video/webm;codecs=vp9",
351 "video/webm;codecs=av1",
352 ]);
353 FakeMediaRecorder.outputMimeType = "video/webm;codecs=av01";
354
355 await startBrowserRecording("recording-tab");
356 await stopBrowserRecording("recording-tab");
357
358 expect(FakeMediaRecorder.instances[0]?.options).toEqual({
359 mimeType: "video/mp4;codecs=avc1",
360 videoBitsPerSecond: 3_110_400,
361 });
362 expect(save).toHaveBeenCalledWith(
363 "recording-tab",
364 "video/webm;codecs=av01",
365 expect.any(Uint8Array),
366 );
367 });
368
369 it("lets the browser select the format when no preferred encoding is supported", async () => {
370 FakeMediaRecorder.supportedTypes = new Set();
371 FakeMediaRecorder.outputMimeType = "video/platform-default";
372
373 await startBrowserRecording("recording-tab");
374 await stopBrowserRecording("recording-tab");
375
376 expect(FakeMediaRecorder.instances[0]?.options).toEqual({ videoBitsPerSecond: 3_110_400 });
377 expect(save).toHaveBeenCalledWith(
378 "recording-tab",
379 "video/platform-default",
380 expect.any(Uint8Array),
381 );
382 });
383
384 it("reports when MediaRecorder provides no output format", async () => {
385 FakeMediaRecorder.supportedTypes = new Set();
386 FakeMediaRecorder.outputMimeType = "";
387
388 await startBrowserRecording("recording-tab");
389
390 await expect(stopBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
391 BrowserRecordingFormatUnavailableError,
392 );
393 expect(save).not.toHaveBeenCalled();
394 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
395 });
396
397 it("releases the native capture lease when stream acquisition fails", async () => {
398 getDisplayMedia.mockRejectedValueOnce(new Error("capture failed"));
399
400 await expect(startBrowserRecording("recording-tab")).rejects.toMatchObject({
401 operation: "capture-media-stream",
402 tabId: "recording-tab",
403 });
404
405 expect(stopScreencast).toHaveBeenCalledWith("recording-tab");
406 expect(events.at(-1)).toBe("clear");
407 });
408
409 it("times out stalled stream acquisition and stops a late stream", async () => {
410 vi.useFakeTimers();
411 let finishCapture!: (stream: MediaStream) => void;
412 const stopTrack = vi.fn();
413 getDisplayMedia.mockImplementationOnce(
414 () =>
415 new Promise<MediaStream>((resolve) => {
416 finishCapture = resolve;
417 }),
418 );
419
420 const startPromise = startBrowserRecording("recording-tab");
421 await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce());
422 const rejection = expect(startPromise).rejects.toMatchObject({
423 _tag: "BrowserRecordingCaptureTimeoutError",
424 tabId: "recording-tab",
425 timeoutMs: BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS,
426 });
427 await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS);
428
429 await rejection;
430 await expect(startPromise).rejects.toBeInstanceOf(BrowserRecordingCaptureTimeoutError);
431 expect(stopScreencast).toHaveBeenCalledWith("recording-tab");
432 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
433 expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined();
434
435 finishCapture({
436 getVideoTracks: () => [],
437 getTracks: () => [{ stop: stopTrack }],
438 } as unknown as MediaStream);
439 await vi.advanceTimersByTimeAsync(0);
440 expect(stopTrack).toHaveBeenCalledOnce();
441 });
442
443 it("records separate tabs concurrently", async () => {
444 const firstThreadRef = {
445 environmentId: EnvironmentId.make("environment-recording"),
446 threadId: ThreadId.make("thread-recording-first"),
447 };
448 const secondThreadRef = {
449 environmentId: EnvironmentId.make("environment-recording"),
450 threadId: ThreadId.make("thread-recording-second"),
451 };
452 await Promise.all([
453 startBrowserRecording("recording-tab", firstThreadRef),
454 startBrowserRecording("recording-tab-2", secondThreadRef),
455 ]);
456
457 expect(startScreencast).toHaveBeenCalledTimes(2);
458 expect(events).toContain("publish:recording-tab,recording-tab-2");
459 expect(readActiveBrowserRecordingTabIds()).toEqual(
460 new Set(["recording-tab", "recording-tab-2"]),
461 );
462 expect(readActiveBrowserRecordingTabIds(firstThreadRef)).toEqual(new Set(["recording-tab"]));
463 expect(readActiveBrowserRecordingTabIds(secondThreadRef)).toEqual(new Set(["recording-tab-2"]));
464
465 await stopBrowserRecording("recording-tab");
466 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set(["recording-tab-2"]));
467 await stopBrowserRecording("recording-tab-2");
468 expect(readActiveBrowserRecordingTabIds()).toEqual(new Set());
469 expect(save).toHaveBeenCalledTimes(2);
470 });
471
472 it("serializes display media grants for concurrent recording starts", async () => {
473 let finishFirstCapture!: (stream: MediaStream) => void;
474 const stream = {
475 getVideoTracks: () => [],
476 getTracks: () => [{ stop: vi.fn() }],
477 } as unknown as MediaStream;
478 getDisplayMedia
479 .mockImplementationOnce(
480 () =>
481 new Promise<MediaStream>((resolve) => {
482 finishFirstCapture = resolve;
483 }),
484 )
485 .mockResolvedValueOnce(stream);
486
487 const firstStart = startBrowserRecording("recording-tab");
488 await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce());
489 const secondStart = startBrowserRecording("recording-tab-2");
490 await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2));
491
492 expect(startScreencast).toHaveBeenCalledTimes(1);
493 finishFirstCapture(stream);
494 await Promise.all([firstStart, secondStart]);
495
496 expect(startScreencast.mock.calls).toEqual([["recording-tab"], ["recording-tab-2"]]);
497 expect(getDisplayMedia).toHaveBeenCalledTimes(2);
498 await Promise.all([
499 stopBrowserRecording("recording-tab"),
500 stopBrowserRecording("recording-tab-2"),
501 ]);
502 });
503
504 it("cancels a queued recording when stopped before its media grant", async () => {
505 let finishFirstCapture!: (stream: MediaStream) => void;
506 const stream = {
507 getVideoTracks: () => [],
508 getTracks: () => [{ stop: vi.fn() }],
509 } as unknown as MediaStream;
510 getDisplayMedia.mockImplementationOnce(
511 () =>
512 new Promise<MediaStream>((resolve) => {
513 finishFirstCapture = resolve;
514 }),
515 );
516
517 const firstStart = startBrowserRecording("recording-tab");
518 await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce());
519 const secondStart = startBrowserRecording("recording-tab-2");
520 await vi.waitFor(() => expect(readActiveBrowserRecordingTabIds().size).toBe(2));
521
522 const secondStop = stopBrowserRecording("recording-tab-2");
523 await expect(secondStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError);
524 await expect(secondStop).resolves.toBeNull();
525 expect(startScreencast).toHaveBeenCalledTimes(1);
526
527 finishFirstCapture(stream);
528 await firstStart;
529 await stopBrowserRecording("recording-tab");
530 expect(getDisplayMedia).toHaveBeenCalledOnce();
531 });
532
533 it("latches a stop that arrives before the start becomes queued", async () => {
534 let releaseDelayedPaint!: (timestamp: number) => void;
535 let frameId = 0;
536 vi.stubGlobal(
537 "requestAnimationFrame",
538 vi.fn((callback: FrameRequestCallback) => {
539 frameId += 1;
540 if (frameId === 1) releaseDelayedPaint = callback;
541 else callback(frameId);
542 return frameId;
543 }),
544 );
545 let finishBlockingCapture!: (stream: MediaStream) => void;
546 const stream = {
547 getVideoTracks: () => [],
548 getTracks: () => [{ stop: vi.fn() }],
549 } as unknown as MediaStream;
550 getDisplayMedia.mockImplementationOnce(
551 () =>
552 new Promise<MediaStream>((resolve) => {
553 finishBlockingCapture = resolve;
554 }),
555 );
556
557 const delayedStart = startBrowserRecording("delayed-tab");
558 await vi.waitFor(() =>
559 expect(readActiveBrowserRecordingTabIds().has("delayed-tab")).toBe(true),
560 );
561 const blockingStart = startBrowserRecording("blocking-tab");
562 await vi.waitFor(() => expect(getDisplayMedia).toHaveBeenCalledOnce());
563
564 const delayedStop = stopBrowserRecording("delayed-tab");
565 releaseDelayedPaint(1);
566
567 await expect(delayedStart).rejects.toBeInstanceOf(BrowserRecordingStartCancelledError);
568 await expect(delayedStop).resolves.toBeNull();
569 expect(startScreencast).toHaveBeenCalledOnce();
570
571 finishBlockingCapture(stream);
572 await blockingStart;
573 await stopBrowserRecording("blocking-tab");
574 });
575
576 it("finishes an uncontended pre-grant start before stopping", async () => {
577 const animationFrames: FrameRequestCallback[] = [];
578 vi.stubGlobal(
579 "requestAnimationFrame",
580 vi.fn((callback: FrameRequestCallback) => {
581 animationFrames.push(callback);
582 return animationFrames.length;
583 }),
584 );
585
586 const startPromise = startBrowserRecording("recording-tab");
587 await vi.waitFor(() =>
588 expect(readActiveBrowserRecordingTabIds().has("recording-tab")).toBe(true),
589 );
590 const stopPromise = stopBrowserRecording("recording-tab");
591 expect(startScreencast).not.toHaveBeenCalled();
592
593 animationFrames.shift()?.(1);
594 animationFrames.shift()?.(2);
595 await startPromise;
596 await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" });
597 });
598
599 it("keeps a recording reachable through its runtime id after a server epoch changes", async () => {
600 const threadRef = {
601 environmentId: EnvironmentId.make("environment-recording"),
602 threadId: ThreadId.make("thread-recording-scoped"),
603 };
604 const runtimeTabId = previewRuntimeTabId(threadRef, "epoch-a", "tab_1");
605 await startBrowserRecording(runtimeTabId, threadRef, "tab_1");
606
607 expect(startScreencast).toHaveBeenCalledWith(runtimeTabId);
608 expect(readActiveBrowserRecordingTabIds(threadRef)).toEqual(new Set([runtimeTabId]));
609 expect(readActiveBrowserRecordingTargets(threadRef)).toEqual([
610 { runtimeTabId, serverTabId: "tab_1" },
611 ]);
612 expect(findActiveBrowserRecordingRuntimeTabId(threadRef, "tab_1")).toBe(runtimeTabId);
613
614 const replacementRuntimeTabId = previewRuntimeTabId(threadRef, "epoch-b", "tab_1");
615 await expect(
616 startBrowserRecording(replacementRuntimeTabId, threadRef, "tab_1"),
617 ).rejects.toBeInstanceOf(BrowserRecordingConflictError);
618 expect(startScreencast).toHaveBeenCalledTimes(1);
619
620 await stopBrowserRecording(runtimeTabId);
621 });
622
623 it("does not report success for a second start while the first is still starting", async () => {
624 let finishStartingScreencast: (() => void) | undefined;
625 startScreencast.mockImplementationOnce(async () => {
626 events.push("start-screencast");
627 await new Promise<void>((resolve) => {
628 finishStartingScreencast = resolve;
629 });
630 });
631
632 const firstStart = startBrowserRecording("recording-tab");
633 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
634
635 await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
636 BrowserRecordingConflictError,
637 );
638
639 finishStartingScreencast?.();
640 await firstStart;
641 await stopBrowserRecording("recording-tab");
642 });
643
644 it("does not report success for a start while the recording is stopping", async () => {
645 let finishStoppingScreencast: (() => void) | undefined;
646 stopScreencast.mockImplementationOnce(async () => {
647 await new Promise<void>((resolve) => {
648 finishStoppingScreencast = resolve;
649 });
650 return undefined;
651 });
652
653 await startBrowserRecording("recording-tab");
654 const stopPromise = stopBrowserRecording("recording-tab");
655 await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce());
656
657 await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
658 BrowserRecordingConflictError,
659 );
660
661 finishStoppingScreencast?.();
662 await stopPromise;
663 });
664
665 it("shares an in-progress stop with duplicate callers", async () => {
666 let finishStoppingScreencast: (() => void) | undefined;
667 stopScreencast.mockImplementationOnce(async () => {
668 await new Promise<void>((resolve) => {
669 finishStoppingScreencast = resolve;
670 });
671 return undefined;
672 });
673
674 await startBrowserRecording("recording-tab");
675 const firstStop = stopBrowserRecording("recording-tab");
676 await vi.waitFor(() => expect(stopScreencast).toHaveBeenCalledOnce());
677 const duplicateStop = stopBrowserRecording("recording-tab");
678
679 finishStoppingScreencast?.();
680 const [firstArtifact, duplicateArtifact] = await Promise.all([firstStop, duplicateStop]);
681
682 expect(duplicateArtifact).toEqual(firstArtifact);
683 expect(stopScreencast).toHaveBeenCalledOnce();
684 expect(save).toHaveBeenCalledOnce();
685 });
686
687 it("finishes startup before stopping so an active recording yields an artifact", async () => {
688 let finishStartingScreencast: (() => void) | undefined;
689 startScreencast.mockImplementationOnce(async () => {
690 events.push("start-screencast");
691 await new Promise<void>((resolve) => {
692 finishStartingScreencast = resolve;
693 });
694 });
695
696 const startPromise = startBrowserRecording("recording-tab");
697 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
698
699 const stopPromise = stopBrowserRecording("recording-tab");
700 expect(stopScreencast).not.toHaveBeenCalled();
701 finishStartingScreencast?.();
702
703 await startPromise;
704 await expect(stopPromise).resolves.toMatchObject({ tabId: "recording-tab" });
705 expect(stopScreencast).toHaveBeenCalledOnce();
706 expect(save).toHaveBeenCalledOnce();
707 expect(events.at(-1)).toBe("clear");
708 });
709
710 it("does not release the recording slot until a cancelled start settles", async () => {
711 let finishStartingScreencast: (() => void) | undefined;
712 startScreencast.mockImplementationOnce(async () => {
713 events.push("start-screencast");
714 await new Promise<void>((resolve) => {
715 finishStartingScreencast = resolve;
716 });
717 });
718
719 const firstStart = startBrowserRecording("recording-tab");
720 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
721
722 const stopPromise = stopBrowserRecording("recording-tab");
723 const restartAfterStop = stopPromise.then(() => startBrowserRecording("recording-tab"));
724 await new Promise((resolve) => setTimeout(resolve, 0));
725 const startCallsBeforeFirstSettled = startScreencast.mock.calls.length;
726
727 finishStartingScreencast?.();
728 await firstStart;
729 await stopPromise;
730 await restartAfterStop;
731 await stopBrowserRecording("recording-tab");
732
733 expect(startCallsBeforeFirstSettled).toBe(1);
734 });
735
736 it("keeps the recording slot while a failed stop waits for startup", async () => {
737 let finishStartingScreencast: (() => void) | undefined;
738 startScreencast.mockImplementationOnce(async () => {
739 events.push("start-screencast");
740 await new Promise<void>((resolve) => {
741 finishStartingScreencast = resolve;
742 });
743 });
744 stopScreencast.mockRejectedValueOnce(new Error("initial stop failed"));
745
746 const firstStart = startBrowserRecording("recording-tab");
747 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
748
749 const stopPromise = stopBrowserRecording("recording-tab");
750 const rejectedStop = expect(stopPromise).rejects.toMatchObject({
751 operation: "stop-screencast",
752 tabId: "recording-tab",
753 });
754 expect(stopScreencast).not.toHaveBeenCalled();
755 await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
756 BrowserRecordingConflictError,
757 );
758
759 finishStartingScreencast?.();
760 await firstStart;
761 await rejectedStop;
762 expect(stopScreencast).toHaveBeenCalledOnce();
763
764 await startBrowserRecording("recording-tab");
765 await stopBrowserRecording("recording-tab");
766 });
767
768 it("fails a stop that waits too long for startup without freeing the recording slot", async () => {
769 vi.useFakeTimers();
770 let finishStartingScreencast: (() => void) | undefined;
771 startScreencast.mockImplementationOnce(async () => {
772 events.push("start-screencast");
773 await new Promise<void>((resolve) => {
774 finishStartingScreencast = resolve;
775 });
776 });
777
778 const startPromise = startBrowserRecording("recording-tab");
779 await vi.waitFor(() => expect(startScreencast).toHaveBeenCalledOnce());
780
781 const stopPromise = stopBrowserRecording("recording-tab");
782 await vi.advanceTimersByTimeAsync(0);
783 expect(stopScreencast).not.toHaveBeenCalled();
784
785 const rejection = expect(stopPromise).rejects.toMatchObject({
786 operation: "wait-startup",
787 tabId: "recording-tab",
788 });
789 await vi.advanceTimersByTimeAsync(BROWSER_RECORDING_STARTUP_SETTLE_TIMEOUT_MS);
790
791 await rejection;
792 expect(save).not.toHaveBeenCalled();
793 await expect(startBrowserRecording("recording-tab")).rejects.toBeInstanceOf(
794 BrowserRecordingConflictError,
795 );
796
797 finishStartingScreencast?.();
798 await vi.advanceTimersByTimeAsync(32);
799 await startPromise;
800 const cleanupResult = await stopBrowserRecording("recording-tab");
801 expect(cleanupResult).toBeNull();
802 expect(stopScreencast).toHaveBeenCalledOnce();
803 expect(save).not.toHaveBeenCalled();
804 expect(events.at(-1)).toBe("clear");
805 });
806});