| ... | ... | @@ -146,6 +146,7 @@ describe("browser recording", () => { |
| 146 | 146 | vi.stubGlobal("cancelAnimationFrame", vi.fn()); |
| 147 | 147 | vi.stubGlobal("MediaRecorder", FakeMediaRecorder as unknown as typeof MediaRecorder); |
| 148 | 148 | getDisplayMedia.mockResolvedValue({ |
| 149 | getVideoTracks: () => [], |
| 149 | 150 | getTracks: () => [{ stop: vi.fn() }], |
| 150 | 151 | }); |
| 151 | 152 | requestDisplayMediaCapture.mockImplementation((tabId: string) => { |
| ... | ... | @@ -187,7 +188,7 @@ describe("browser recording", () => { |
| 187 | 188 | getDisplayMedia.mockImplementationOnce(async () => { |
| 188 | 189 | expect(animationFrameCount).toBe(2); |
| 189 | 190 | expect(useBrowserSurfaceStore.getState().activityByTabId["background-tab"]).toBe(1); |
| 190 | | return { getTracks: () => [{ stop: vi.fn() }] }; |
| 191 | return { getVideoTracks: () => [], getTracks: () => [{ stop: vi.fn() }] }; |
| 191 | 192 | }); |
| 192 | 193 | |
| 193 | 194 | await startBrowserRecording("background-tab"); |
| ... | ... | @@ -214,21 +215,31 @@ describe("browser recording", () => { |
| 214 | 215 | await stopBrowserRecording("hidden-window-tab"); |
| 215 | 216 | }); |
| 216 | 217 | |
| 217 | | it("records the native tab stream armed by the main process", async () => { |
| 218 | it.each([ |
| 219 | { width: 1280, height: 720, frameRate: 60, bitrate: 2_764_800 }, |
| 220 | { width: 320, height: 240, frameRate: 30, bitrate: 2_500_000 }, |
| 221 | { width: 3840, height: 2160, frameRate: 60, bitrate: 24_883_200 }, |
| 222 | { width: 7680, height: 4320, frameRate: 60, bitrate: 50_000_000 }, |
| 223 | ])("records the native $width x $height stream at $frameRate fps", async (settings) => { |
| 218 | 224 | const stopTrack = vi.fn(); |
| 219 | | const stream = { getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream; |
| 225 | const stream = { |
| 226 | getVideoTracks: () => [{ getSettings: () => settings }], |
| 227 | getTracks: () => [{ stop: stopTrack }], |
| 228 | } as unknown as MediaStream; |
| 220 | 229 | getDisplayMedia.mockResolvedValueOnce(stream); |
| 221 | 230 | |
| 222 | 231 | await startBrowserRecording("recording-tab"); |
| 223 | 232 | |
| 224 | 233 | expect(getDisplayMedia).toHaveBeenCalledWith({ |
| 225 | 234 | audio: false, |
| 226 | | video: { frameRate: { max: 30 } }, |
| 235 | video: { frameRate: { ideal: 30, max: 30 } }, |
| 227 | 236 | }); |
| 228 | 237 | expect(FakeMediaRecorder.instances[0]?.stream).toBe(stream); |
| 238 | expect(FakeMediaRecorder.instances[0]?.options?.videoBitsPerSecond).toBe(settings.bitrate); |
| 229 | 239 | |
| 230 | 240 | await stopBrowserRecording("recording-tab"); |
| 231 | 241 | expect(stopTrack).toHaveBeenCalledOnce(); |
| 242 | expect(stopTrack.mock.invocationCallOrder[0]).toBeLessThan(save.mock.invocationCallOrder[0]!); |
| 232 | 243 | }); |
| 233 | 244 | |
| 234 | 245 | it("uses the configured recording frame rate", async () => { |
| ... | ... | @@ -238,7 +249,7 @@ describe("browser recording", () => { |
| 238 | 249 | |
| 239 | 250 | expect(getDisplayMedia).toHaveBeenCalledWith({ |
| 240 | 251 | audio: false, |
| 241 | | video: { frameRate: { max: 60 } }, |
| 252 | video: { frameRate: { ideal: 60, max: 60 } }, |
| 242 | 253 | }); |
| 243 | 254 | await stopBrowserRecording("recording-tab"); |
| 244 | 255 | }); |
| ... | ... | @@ -263,7 +274,7 @@ describe("browser recording", () => { |
| 263 | 274 | |
| 264 | 275 | expect(getDisplayMedia).toHaveBeenCalledWith({ |
| 265 | 276 | audio: false, |
| 266 | | video: { frameRate: { max: 60 } }, |
| 277 | video: { frameRate: { ideal: 60, max: 60 } }, |
| 267 | 278 | }); |
| 268 | 279 | await stopBrowserRecording(tabId); |
| 269 | 280 | |
| ... | ... | @@ -275,6 +286,7 @@ describe("browser recording", () => { |
| 275 | 286 | it("stops the native stream when MediaRecorder cleanup fails", async () => { |
| 276 | 287 | const stopTrack = vi.fn(); |
| 277 | 288 | getDisplayMedia.mockResolvedValueOnce({ |
| 289 | getVideoTracks: () => [], |
| 278 | 290 | getTracks: () => [{ stop: stopTrack }], |
| 279 | 291 | }); |
| 280 | 292 | |
| ... | ... | @@ -290,6 +302,7 @@ describe("browser recording", () => { |
| 290 | 302 | |
| 291 | 303 | it("uses the best supported encoder and saves the recorder's actual format", async () => { |
| 292 | 304 | FakeMediaRecorder.supportedTypes = new Set([ |
| 305 | "video/mp4;codecs=avc1", |
| 293 | 306 | "video/mp4;codecs=avc1.42e01e", |
| 294 | 307 | "video/webm;codecs=vp9", |
| 295 | 308 | "video/webm;codecs=av1", |
| ... | ... | @@ -300,7 +313,8 @@ describe("browser recording", () => { |
| 300 | 313 | await stopBrowserRecording("recording-tab"); |
| 301 | 314 | |
| 302 | 315 | expect(FakeMediaRecorder.instances[0]?.options).toEqual({ |
| 303 | | mimeType: "video/webm;codecs=av1", |
| 316 | mimeType: "video/mp4;codecs=avc1", |
| 317 | videoBitsPerSecond: 3_110_400, |
| 304 | 318 | }); |
| 305 | 319 | expect(save).toHaveBeenCalledWith( |
| 306 | 320 | "recording-tab", |
| ... | ... | @@ -316,7 +330,7 @@ describe("browser recording", () => { |
| 316 | 330 | await startBrowserRecording("recording-tab"); |
| 317 | 331 | await stopBrowserRecording("recording-tab"); |
| 318 | 332 | |
| 319 | | expect(FakeMediaRecorder.instances[0]?.options).toBeUndefined(); |
| 333 | expect(FakeMediaRecorder.instances[0]?.options).toEqual({ videoBitsPerSecond: 3_110_400 }); |
| 320 | 334 | expect(save).toHaveBeenCalledWith( |
| 321 | 335 | "recording-tab", |
| 322 | 336 | "video/platform-default", |
| ... | ... | @@ -375,7 +389,10 @@ describe("browser recording", () => { |
| 375 | 389 | expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); |
| 376 | 390 | expect(useBrowserSurfaceStore.getState().activityByTabId["recording-tab"]).toBeUndefined(); |
| 377 | 391 | |
| 378 | | finishCapture({ getTracks: () => [{ stop: stopTrack }] } as unknown as MediaStream); |
| 392 | finishCapture({ |
| 393 | getVideoTracks: () => [], |
| 394 | getTracks: () => [{ stop: stopTrack }], |
| 395 | } as unknown as MediaStream); |
| 379 | 396 | await vi.advanceTimersByTimeAsync(0); |
| 380 | 397 | expect(stopTrack).toHaveBeenCalledOnce(); |
| 381 | 398 | }); |
| ... | ... | @@ -411,7 +428,10 @@ describe("browser recording", () => { |
| 411 | 428 | |
| 412 | 429 | it("serializes display media grants for concurrent recording starts", async () => { |
| 413 | 430 | let finishFirstCapture!: (stream: MediaStream) => void; |
| 414 | | const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; |
| 431 | const stream = { |
| 432 | getVideoTracks: () => [], |
| 433 | getTracks: () => [{ stop: vi.fn() }], |
| 434 | } as unknown as MediaStream; |
| 415 | 435 | getDisplayMedia |
| 416 | 436 | .mockImplementationOnce( |
| 417 | 437 | () => |
| ... | ... | @@ -440,7 +460,10 @@ describe("browser recording", () => { |
| 440 | 460 | |
| 441 | 461 | it("cancels a queued recording when stopped before its media grant", async () => { |
| 442 | 462 | let finishFirstCapture!: (stream: MediaStream) => void; |
| 443 | | const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; |
| 463 | const stream = { |
| 464 | getVideoTracks: () => [], |
| 465 | getTracks: () => [{ stop: vi.fn() }], |
| 466 | } as unknown as MediaStream; |
| 444 | 467 | getDisplayMedia.mockImplementationOnce( |
| 445 | 468 | () => |
| 446 | 469 | new Promise<MediaStream>((resolve) => { |
| ... | ... | @@ -477,7 +500,10 @@ describe("browser recording", () => { |
| 477 | 500 | }), |
| 478 | 501 | ); |
| 479 | 502 | let finishBlockingCapture!: (stream: MediaStream) => void; |
| 480 | | const stream = { getTracks: () => [{ stop: vi.fn() }] } as unknown as MediaStream; |
| 503 | const stream = { |
| 504 | getVideoTracks: () => [], |
| 505 | getTracks: () => [{ stop: vi.fn() }], |
| 506 | } as unknown as MediaStream; |
| 481 | 507 | getDisplayMedia.mockImplementationOnce( |
| 482 | 508 | () => |
| 483 | 509 | new Promise<MediaStream>((resolve) => { |