| ... | ... | @@ -332,8 +332,18 @@ const settle = function* (until: () => boolean) { |
| 332 | 332 | |
| 333 | 333 | const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => { |
| 334 | 334 | const listeners = new Map<string, () => void>(); |
| 335 | const webContentsListeners = new Map<string, () => void>(); |
| 335 | 336 | const send = vi.fn(); |
| 336 | 337 | let destroyed = false; |
| 338 | const webContents = { |
| 339 | on: vi.fn((event: string, listener: () => void) => { |
| 340 | webContentsListeners.set(event, listener); |
| 341 | }), |
| 342 | off: vi.fn((event: string) => { |
| 343 | webContentsListeners.delete(event); |
| 344 | }), |
| 345 | send, |
| 346 | }; |
| 337 | 347 | const pictureInPictureWindow = { |
| 338 | 348 | isDestroyed: vi.fn(() => destroyed), |
| 339 | 349 | once: vi.fn((event: string, listener: () => void) => { |
| ... | ... | @@ -353,11 +363,12 @@ const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () |
| 353 | 363 | destroyed = true; |
| 354 | 364 | listeners.get("closed")?.(); |
| 355 | 365 | }), |
| 356 | | webContents: { |
| 357 | | send, |
| 366 | get webContents() { |
| 367 | if (destroyed) throw new Error("Picture-in-picture window is closed."); |
| 368 | return webContents; |
| 358 | 369 | }, |
| 359 | 370 | }; |
| 360 | | return { pictureInPictureWindow, send }; |
| 371 | return { pictureInPictureWindow, send, webContentsListeners }; |
| 361 | 372 | }; |
| 362 | 373 | |
| 363 | 374 | describe("PreviewManager", () => { |
| ... | ... | @@ -2413,6 +2424,8 @@ describe("PreviewManager", () => { |
| 2413 | 2424 | pictureInPictureListeners.get("closed")?.(); |
| 2414 | 2425 | }), |
| 2415 | 2426 | webContents: { |
| 2427 | on: vi.fn(), |
| 2428 | off: vi.fn(), |
| 2416 | 2429 | send: pictureInPictureSend, |
| 2417 | 2430 | }, |
| 2418 | 2431 | }; |
| ... | ... | @@ -2478,6 +2491,7 @@ describe("PreviewManager", () => { |
| 2478 | 2491 | ); |
| 2479 | 2492 | expect(states.at(-1)?.pictureInPicture).toBe(true); |
| 2480 | 2493 | expect(capturePage).toHaveBeenCalledOnce(); |
| 2494 | const pictureInPictureFramesBeforeRecording = pictureInPictureSend.mock.calls.length; |
| 2481 | 2495 | |
| 2482 | 2496 | yield* manager.startRecording("tab_pip"); |
| 2483 | 2497 | expect(capturePage).toHaveBeenCalledOnce(); |
| ... | ... | @@ -2485,6 +2499,7 @@ describe("PreviewManager", () => { |
| 2485 | 2499 | |
| 2486 | 2500 | yield* TestClock.adjust(100); |
| 2487 | 2501 | expect(capturePage).toHaveBeenCalledTimes(2); |
| 2502 | expect(pictureInPictureSend).toHaveBeenCalledTimes(pictureInPictureFramesBeforeRecording); |
| 2488 | 2503 | expect(recordingFrames).toHaveLength(1); |
| 2489 | 2504 | |
| 2490 | 2505 | yield* manager.stopRecording("tab_pip"); |
| ... | ... | @@ -2492,9 +2507,7 @@ describe("PreviewManager", () => { |
| 2492 | 2507 | const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length; |
| 2493 | 2508 | yield* TestClock.adjust(100); |
| 2494 | 2509 | expect(capturePage).toHaveBeenCalledTimes(3); |
| 2495 | | expect(pictureInPictureSend.mock.calls.length).toBeGreaterThan( |
| 2496 | | framesBeforePictureInPictureOnlyTick, |
| 2497 | | ); |
| 2510 | expect(pictureInPictureSend.mock.calls.length).toBe(framesBeforePictureInPictureOnlyTick); |
| 2498 | 2511 | expect(recordingFrames).toHaveLength(1); |
| 2499 | 2512 | |
| 2500 | 2513 | setBackgroundThrottling.mockImplementationOnce(() => { |
| ... | ... | @@ -2511,6 +2524,213 @@ describe("PreviewManager", () => { |
| 2511 | 2524 | ), |
| 2512 | 2525 | ); |
| 2513 | 2526 | |
| 2527 | effectIt.effect("starts picture-in-picture without an extra recording capture", () => |
| 2528 | withManager((manager) => |
| 2529 | Effect.gen(function* () { |
| 2530 | const jpeg = Buffer.from("shared-preview-frame"); |
| 2531 | const capturePage = vi.fn(async () => ({ |
| 2532 | toJPEG: () => jpeg, |
| 2533 | getSize: () => ({ width: 1280, height: 720 }), |
| 2534 | })); |
| 2535 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); |
| 2536 | const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); |
| 2537 | browserWindowConstructor.mockImplementation(function () { |
| 2538 | return pictureInPictureWindow; |
| 2539 | }); |
| 2540 | const recordingFrames: DesktopPreviewRecordingFrame[] = []; |
| 2541 | |
| 2542 | yield* manager.subscribeRecordingFrames((frame) => |
| 2543 | Effect.sync(() => { |
| 2544 | recordingFrames.push(frame); |
| 2545 | }), |
| 2546 | ); |
| 2547 | yield* manager.createTab("tab_recording_then_pip"); |
| 2548 | yield* manager.registerWebview("tab_recording_then_pip", 42); |
| 2549 | yield* manager.startRecording("tab_recording_then_pip"); |
| 2550 | |
| 2551 | expect(recordingFrames).toHaveLength(1); |
| 2552 | yield* manager.openPictureInPicture("tab_recording_then_pip"); |
| 2553 | expect(capturePage).toHaveBeenCalledOnce(); |
| 2554 | expect(send).not.toHaveBeenCalled(); |
| 2555 | |
| 2556 | yield* TestClock.adjust(100); |
| 2557 | |
| 2558 | expect(capturePage).toHaveBeenCalledTimes(2); |
| 2559 | expect(recordingFrames).toHaveLength(2); |
| 2560 | expect(send).toHaveBeenCalledOnce(); |
| 2561 | yield* manager.closePictureInPicture("tab_recording_then_pip"); |
| 2562 | yield* manager.stopRecording("tab_recording_then_pip"); |
| 2563 | }), |
| 2564 | ), |
| 2565 | ); |
| 2566 | |
| 2567 | effectIt.effect("stops frame capture when the native picture-in-picture window closes", () => |
| 2568 | withManager((manager) => |
| 2569 | Effect.gen(function* () { |
| 2570 | const capturePage = vi.fn(async () => ({ |
| 2571 | toJPEG: () => Buffer.from("native-close-preview-frame"), |
| 2572 | getSize: () => ({ width: 1280, height: 720 }), |
| 2573 | })); |
| 2574 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); |
| 2575 | const { pictureInPictureWindow } = makeTestPictureInPictureWindow(); |
| 2576 | browserWindowConstructor.mockImplementation(function () { |
| 2577 | return pictureInPictureWindow; |
| 2578 | }); |
| 2579 | const states: PreviewManager.PreviewTabState[] = []; |
| 2580 | yield* manager.subscribeStateChanges((_tabId, state) => |
| 2581 | Effect.sync(() => { |
| 2582 | states.push(state); |
| 2583 | }), |
| 2584 | ); |
| 2585 | |
| 2586 | yield* manager.createTab("tab_native_pip_close"); |
| 2587 | yield* manager.registerWebview("tab_native_pip_close", 42); |
| 2588 | yield* manager.openPictureInPicture("tab_native_pip_close"); |
| 2589 | |
| 2590 | pictureInPictureWindow.close(); |
| 2591 | yield* settle(() => states.at(-1)?.pictureInPicture === false); |
| 2592 | |
| 2593 | expect(states.at(-1)?.pictureInPicture).toBe(false); |
| 2594 | const capturesAfterClose = capturePage.mock.calls.length; |
| 2595 | yield* TestClock.adjust(200); |
| 2596 | expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose); |
| 2597 | }), |
| 2598 | ), |
| 2599 | ); |
| 2600 | |
| 2601 | effectIt.effect("retries an unchanged picture-in-picture frame after delivery fails", () => |
| 2602 | withManager((manager) => |
| 2603 | Effect.gen(function* () { |
| 2604 | const jpeg = Buffer.from("retry-preview-frame"); |
| 2605 | const capturePage = vi.fn(async () => ({ |
| 2606 | toJPEG: () => jpeg, |
| 2607 | getSize: () => ({ width: 1280, height: 720 }), |
| 2608 | })); |
| 2609 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); |
| 2610 | const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow(); |
| 2611 | send.mockImplementationOnce(() => { |
| 2612 | throw new Error("picture-in-picture delivery failed"); |
| 2613 | }); |
| 2614 | browserWindowConstructor.mockImplementation(function () { |
| 2615 | return pictureInPictureWindow; |
| 2616 | }); |
| 2617 | |
| 2618 | yield* manager.createTab("tab_pip_delivery_retry"); |
| 2619 | yield* manager.registerWebview("tab_pip_delivery_retry", 42); |
| 2620 | yield* manager.openPictureInPicture("tab_pip_delivery_retry"); |
| 2621 | expect(send).toHaveBeenCalledOnce(); |
| 2622 | |
| 2623 | yield* TestClock.adjust(100); |
| 2624 | |
| 2625 | expect(capturePage).toHaveBeenCalledTimes(2); |
| 2626 | expect(send).toHaveBeenCalledTimes(2); |
| 2627 | yield* manager.closePictureInPicture("tab_pip_delivery_retry"); |
| 2628 | }), |
| 2629 | ), |
| 2630 | ); |
| 2631 | |
| 2632 | effectIt.effect("replays an unchanged picture-in-picture frame after its renderer reloads", () => |
| 2633 | withManager((manager) => |
| 2634 | Effect.gen(function* () { |
| 2635 | const jpeg = Buffer.from("reloaded-preview-frame"); |
| 2636 | const capturePage = vi.fn(async () => ({ |
| 2637 | toJPEG: () => jpeg, |
| 2638 | getSize: () => ({ width: 1280, height: 720 }), |
| 2639 | })); |
| 2640 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); |
| 2641 | const { pictureInPictureWindow, send, webContentsListeners } = |
| 2642 | makeTestPictureInPictureWindow(); |
| 2643 | browserWindowConstructor.mockImplementation(function () { |
| 2644 | return pictureInPictureWindow; |
| 2645 | }); |
| 2646 | |
| 2647 | yield* manager.createTab("tab_pip_reload"); |
| 2648 | yield* manager.registerWebview("tab_pip_reload", 42); |
| 2649 | yield* manager.openPictureInPicture("tab_pip_reload"); |
| 2650 | expect(send).toHaveBeenCalledOnce(); |
| 2651 | |
| 2652 | webContentsListeners.get("did-finish-load")?.(); |
| 2653 | yield* TestClock.adjust(100); |
| 2654 | |
| 2655 | expect(send).toHaveBeenCalledTimes(2); |
| 2656 | yield* manager.closePictureInPicture("tab_pip_reload"); |
| 2657 | }), |
| 2658 | ), |
| 2659 | ); |
| 2660 | |
| 2661 | effectIt.effect("keeps recording cadence when pixels remain unchanged", () => |
| 2662 | withManager((manager) => |
| 2663 | Effect.gen(function* () { |
| 2664 | const unchanged = Buffer.from("unchanged-preview-frame"); |
| 2665 | const capturePage = vi.fn(async () => ({ |
| 2666 | toJPEG: () => unchanged, |
| 2667 | getSize: () => ({ width: 1280, height: 720 }), |
| 2668 | })); |
| 2669 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); |
| 2670 | const frames: DesktopPreviewRecordingFrame[] = []; |
| 2671 | |
| 2672 | yield* manager.subscribeRecordingFrames((frame) => |
| 2673 | Effect.sync(() => { |
| 2674 | frames.push(frame); |
| 2675 | }), |
| 2676 | ); |
| 2677 | yield* manager.createTab("tab_unchanged_frame"); |
| 2678 | yield* manager.registerWebview("tab_unchanged_frame", 42); |
| 2679 | yield* manager.startRecording("tab_unchanged_frame"); |
| 2680 | |
| 2681 | expect(frames.map((frame) => frame.data)).toEqual([unchanged.toString("base64")]); |
| 2682 | |
| 2683 | yield* TestClock.adjust(100); |
| 2684 | |
| 2685 | expect(capturePage).toHaveBeenCalledTimes(2); |
| 2686 | expect(frames.map((frame) => frame.data)).toEqual([ |
| 2687 | unchanged.toString("base64"), |
| 2688 | unchanged.toString("base64"), |
| 2689 | ]); |
| 2690 | |
| 2691 | yield* TestClock.adjust(100); |
| 2692 | |
| 2693 | expect(capturePage).toHaveBeenCalledTimes(3); |
| 2694 | expect(frames.map((frame) => frame.data)).toEqual([ |
| 2695 | unchanged.toString("base64"), |
| 2696 | unchanged.toString("base64"), |
| 2697 | unchanged.toString("base64"), |
| 2698 | ]); |
| 2699 | yield* manager.stopRecording("tab_unchanged_frame"); |
| 2700 | }), |
| 2701 | ), |
| 2702 | ); |
| 2703 | |
| 2704 | effectIt.effect("retries recording delivery when pixels remain unchanged", () => |
| 2705 | withManager((manager) => |
| 2706 | Effect.gen(function* () { |
| 2707 | const jpeg = Buffer.from("retry-recording-frame"); |
| 2708 | const capturePage = vi.fn(async () => ({ |
| 2709 | toJPEG: () => jpeg, |
| 2710 | getSize: () => ({ width: 1280, height: 720 }), |
| 2711 | })); |
| 2712 | fromId.mockReturnValue(makeTestPreviewWebContents(capturePage)); |
| 2713 | let deliveries = 0; |
| 2714 | |
| 2715 | yield* manager.subscribeRecordingFrames(() => |
| 2716 | Effect.sync(() => { |
| 2717 | deliveries += 1; |
| 2718 | if (deliveries === 1) throw new Error("recording delivery failed"); |
| 2719 | }), |
| 2720 | ); |
| 2721 | yield* manager.createTab("tab_recording_delivery_retry"); |
| 2722 | yield* manager.registerWebview("tab_recording_delivery_retry", 42); |
| 2723 | yield* manager.startRecording("tab_recording_delivery_retry"); |
| 2724 | expect(deliveries).toBe(1); |
| 2725 | |
| 2726 | yield* TestClock.adjust(100); |
| 2727 | |
| 2728 | expect(deliveries).toBe(2); |
| 2729 | yield* manager.stopRecording("tab_recording_delivery_retry"); |
| 2730 | }), |
| 2731 | ), |
| 2732 | ); |
| 2733 | |
| 2514 | 2734 | effectIt.effect("retries a cold hidden-tab capture without dropping recording", () => |
| 2515 | 2735 | withManager((manager) => |
| 2516 | 2736 | Effect.gen(function* () { |