| ... | @@ -332,8 +332,18 @@ const settle = function* (until: () => boolean) { | ... | @@ -332,8 +332,18 @@ const settle = function* (until: () => boolean) { |
| 332 | | 332 | |
| 333 | const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => { | 333 | const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => { |
| 334 | const listeners = new Map<string, () => void>(); | 334 | const listeners = new Map<string, () => void>(); |
| | 335 | const webContentsListeners = new Map<string, () => void>(); |
| 335 | const send = vi.fn(); | 336 | const send = vi.fn(); |
| 336 | let destroyed = false; | 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 | const pictureInPictureWindow = { | 347 | const pictureInPictureWindow = { |
| 338 | isDestroyed: vi.fn(() => destroyed), | 348 | isDestroyed: vi.fn(() => destroyed), |
| 339 | once: vi.fn((event: string, listener: () => void) => { | 349 | once: vi.fn((event: string, listener: () => void) => { |
| ... | @@ -353,11 +363,12 @@ const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () | ... | @@ -353,11 +363,12 @@ const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () |
| 353 | destroyed = true; | 363 | destroyed = true; |
| 354 | listeners.get("closed")?.(); | 364 | listeners.get("closed")?.(); |
| 355 | }), | 365 | }), |
| 356 | webContents: { | 366 | get webContents() { |
| 357 | send, | 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 | describe("PreviewManager", () => { | 374 | describe("PreviewManager", () => { |
| ... | @@ -2413,6 +2424,8 @@ describe("PreviewManager", () => { | ... | @@ -2413,6 +2424,8 @@ describe("PreviewManager", () => { |
| 2413 | pictureInPictureListeners.get("closed")?.(); | 2424 | pictureInPictureListeners.get("closed")?.(); |
| 2414 | }), | 2425 | }), |
| 2415 | webContents: { | 2426 | webContents: { |
| | 2427 | on: vi.fn(), |
| | 2428 | off: vi.fn(), |
| 2416 | send: pictureInPictureSend, | 2429 | send: pictureInPictureSend, |
| 2417 | }, | 2430 | }, |
| 2418 | }; | 2431 | }; |
| ... | @@ -2478,6 +2491,7 @@ describe("PreviewManager", () => { | ... | @@ -2478,6 +2491,7 @@ describe("PreviewManager", () => { |
| 2478 | ); | 2491 | ); |
| 2479 | expect(states.at(-1)?.pictureInPicture).toBe(true); | 2492 | expect(states.at(-1)?.pictureInPicture).toBe(true); |
| 2480 | expect(capturePage).toHaveBeenCalledOnce(); | 2493 | expect(capturePage).toHaveBeenCalledOnce(); |
| | 2494 | const pictureInPictureFramesBeforeRecording = pictureInPictureSend.mock.calls.length; |
| 2481 | | 2495 | |
| 2482 | yield* manager.startRecording("tab_pip"); | 2496 | yield* manager.startRecording("tab_pip"); |
| 2483 | expect(capturePage).toHaveBeenCalledOnce(); | 2497 | expect(capturePage).toHaveBeenCalledOnce(); |
| ... | @@ -2485,6 +2499,7 @@ describe("PreviewManager", () => { | ... | @@ -2485,6 +2499,7 @@ describe("PreviewManager", () => { |
| 2485 | | 2499 | |
| 2486 | yield* TestClock.adjust(100); | 2500 | yield* TestClock.adjust(100); |
| 2487 | expect(capturePage).toHaveBeenCalledTimes(2); | 2501 | expect(capturePage).toHaveBeenCalledTimes(2); |
| | 2502 | expect(pictureInPictureSend).toHaveBeenCalledTimes(pictureInPictureFramesBeforeRecording); |
| 2488 | expect(recordingFrames).toHaveLength(1); | 2503 | expect(recordingFrames).toHaveLength(1); |
| 2489 | | 2504 | |
| 2490 | yield* manager.stopRecording("tab_pip"); | 2505 | yield* manager.stopRecording("tab_pip"); |
| ... | @@ -2492,9 +2507,7 @@ describe("PreviewManager", () => { | ... | @@ -2492,9 +2507,7 @@ describe("PreviewManager", () => { |
| 2492 | const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length; | 2507 | const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length; |
| 2493 | yield* TestClock.adjust(100); | 2508 | yield* TestClock.adjust(100); |
| 2494 | expect(capturePage).toHaveBeenCalledTimes(3); | 2509 | expect(capturePage).toHaveBeenCalledTimes(3); |
| 2495 | expect(pictureInPictureSend.mock.calls.length).toBeGreaterThan( | 2510 | expect(pictureInPictureSend.mock.calls.length).toBe(framesBeforePictureInPictureOnlyTick); |
| 2496 | framesBeforePictureInPictureOnlyTick, | | |
| 2497 | ); | | |
| 2498 | expect(recordingFrames).toHaveLength(1); | 2511 | expect(recordingFrames).toHaveLength(1); |
| 2499 | | 2512 | |
| 2500 | setBackgroundThrottling.mockImplementationOnce(() => { | 2513 | setBackgroundThrottling.mockImplementationOnce(() => { |
| ... | @@ -2511,6 +2524,213 @@ describe("PreviewManager", () => { | ... | @@ -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 | effectIt.effect("retries a cold hidden-tab capture without dropping recording", () => | 2734 | effectIt.effect("retries a cold hidden-tab capture without dropping recording", () => |
| 2515 | withManager((manager) => | 2735 | withManager((manager) => |
| 2516 | Effect.gen(function* () { | 2736 | Effect.gen(function* () { |