| 1 | import { |
| 2 | DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, |
| 3 | EnvironmentId, |
| 4 | ThreadId, |
| 5 | } from "@t3tools/contracts"; |
| 6 | import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 7 | |
| 8 | import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; |
| 9 | |
| 10 | const { |
| 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 | |
| 46 | vi.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 | |
| 60 | vi.mock("~/rpc/atomRegistry", () => ({ |
| 61 | appAtomRegistry: { set: registrySet }, |
| 62 | })); |
| 63 | |
| 64 | vi.mock("~/hooks/useSettings", () => ({ |
| 65 | ensureClientSettingsHydrated: vi.fn(async () => undefined), |
| 66 | getClientSettings: () => clientSettings, |
| 67 | })); |
| 68 | |
| 69 | import { |
| 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"; |
| 83 | import { useBrowserSurfaceStore } from "./browserSurfaceStore"; |
| 84 | import { previewRuntimeTabId } from "./previewRuntimeTabId"; |
| 85 | |
| 86 | class 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 | |
| 129 | describe("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 | }); |