1import { it as effectIt } from "@effect/vitest";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER } from "@t3tools/contracts";
4import type {
5 DesktopPreviewRecordingFrame,
6 DesktopPreviewRecordingInputEvent,
7} from "@t3tools/contracts";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9import { parseKeybindingShortcut } from "@t3tools/shared/keybindings";
10import * as Cause from "effect/Cause";
11import * as Deferred from "effect/Deferred";
12import * as Effect from "effect/Effect";
13import * as Exit from "effect/Exit";
14import * as FileSystem from "effect/FileSystem";
15import * as Fiber from "effect/Fiber";
16import * as Queue from "effect/Queue";
17import * as Stream from "effect/Stream";
18import * as Layer from "effect/Layer";
19import * as Logger from "effect/Logger";
20import * as Option from "effect/Option";
21import * as Path from "effect/Path";
22import type * as Scope from "effect/Scope";
23import { TestClock } from "effect/testing";
24import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
25
26import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
27import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
28import * as ElectronWindow from "../electron/ElectronWindow.ts";
29import * as BrowserSession from "./BrowserSession.ts";
30import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
31import * as PreviewManager from "./Manager.ts";
32import * as PreviewPasskeys from "./Passkeys.ts";
33
34describe("fitPictureInPictureContentSize", () => {
35 it("preserves the PiP content area across aspect-ratio changes", () => {
36 expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 16 / 9)).toEqual([523, 294]);
37 expect(PreviewManager.fitPictureInPictureContentSize([480, 320], 9 / 16)).toEqual([294, 523]);
38 });
39
40 it("does not collapse toward the minimum size when orientation changes repeatedly", () => {
41 const portrait = PreviewManager.fitPictureInPictureContentSize([523, 294], 9 / 16);
42 const landscape = PreviewManager.fitPictureInPictureContentSize(portrait, 16 / 9);
43
44 expect(portrait).toEqual([294, 523]);
45 expect(landscape).toEqual([523, 294]);
46 });
47});
48
49describe("recordingFileExtension", () => {
50 it("derives the artifact extension from the recorder's actual mime type", () => {
51 expect(PreviewManager.recordingFileExtension("video/mp4;codecs=avc1.640028")).toBe("mp4");
52 expect(PreviewManager.recordingFileExtension("video/webm;codecs=vp9")).toBe("webm");
53 expect(PreviewManager.recordingFileExtension("video/x-matroska")).toBe("matroska");
54 });
55});
56
57describe("isPreviewRefreshShortcut", () => {
58 const input = (overrides: Partial<Electron.Input> = {}) =>
59 ({
60 type: "keyDown",
61 key: "r",
62 meta: true,
63 control: false,
64 shift: false,
65 alt: false,
66 ...overrides,
67 }) as Electron.Input;
68
69 it("recognizes the platform refresh chord without matching modified variants", () => {
70 expect(PreviewManager.isPreviewRefreshShortcut(input())).toBe(true);
71 expect(PreviewManager.isPreviewRefreshShortcut(input({ meta: false, control: true }))).toBe(
72 true,
73 );
74 expect(PreviewManager.isPreviewRefreshShortcut(input({ shift: true }))).toBe(false);
75 expect(PreviewManager.isPreviewRefreshShortcut(input({ type: "keyUp" }))).toBe(false);
76 });
77});
78
79describe("isPreviewEditingShortcut", () => {
80 const input = (platform: NodeJS.Platform, key: string, overrides: Partial<Electron.Input> = {}) =>
81 ({
82 type: "keyDown",
83 key,
84 meta: platform === "darwin",
85 control: platform !== "darwin",
86 shift: false,
87 alt: false,
88 ...overrides,
89 }) as Electron.Input;
90
91 it.each(["darwin", "linux", "win32"] as const)(
92 "allows native editing chords on %s without allowing host shortcuts",
93 (platform) => {
94 for (const key of ["a", "c", "v", "x", "z", "V"]) {
95 expect(PreviewManager.isPreviewEditingShortcut(input(platform, key), platform)).toBe(true);
96 }
97 const redo =
98 platform === "win32" ? input(platform, "y") : input(platform, "z", { shift: true });
99 expect(PreviewManager.isPreviewEditingShortcut(redo, platform)).toBe(true);
100 expect(
101 PreviewManager.isPreviewEditingShortcut(
102 input(platform, "v", { shift: true, alt: platform === "darwin" }),
103 platform,
104 ),
105 ).toBe(true);
106
107 for (const key of ["k", ",", "w", "j", "q", "+", "=", "-", "0", "r", "F12"]) {
108 expect(PreviewManager.isPreviewEditingShortcut(input(platform, key), platform)).toBe(false);
109 }
110 for (const modifiers of [
111 { meta: false, control: false },
112 { meta: true, control: true },
113 { meta: platform !== "darwin", control: platform === "darwin" },
114 { alt: true },
115 { shift: true, alt: platform !== "darwin" },
116 ]) {
117 expect(
118 PreviewManager.isPreviewEditingShortcut(input(platform, "v", modifiers), platform),
119 ).toBe(false);
120 }
121 expect(
122 PreviewManager.isPreviewEditingShortcut(input(platform, "a", { shift: true }), platform),
123 ).toBe(false);
124 },
125 );
126
127 it("recognizes macOS Paste and Match Style when Option changes the key to a symbol", () => {
128 const pasteAndMatchStyle = input("darwin", "◊", { code: "KeyV", alt: true, shift: true });
129 expect(PreviewManager.isPreviewEditingShortcut(pasteAndMatchStyle, "darwin")).toBe(true);
130 for (const modifiers of [
131 { code: "KeyC" },
132 { alt: false },
133 { shift: false },
134 { control: true },
135 ]) {
136 expect(
137 PreviewManager.isPreviewEditingShortcut({ ...pasteAndMatchStyle, ...modifiers }, "darwin"),
138 ).toBe(false);
139 }
140 });
141});
142
143describe("previewWindowOpenAction", () => {
144 const details = (overrides: {
145 readonly url?: string;
146 readonly disposition?: Electron.HandlerDetails["disposition"];
147 }) => ({
148 url: "https://accounts.google.com/o/oauth2/auth",
149 disposition: "new-window" as Electron.HandlerDetails["disposition"],
150 ...overrides,
151 });
152
153 it("opens a real window for scripted popups so the opener survives", () => {
154 // OAuth SDKs read a null `window.open()` as a blocked popup, and they need
155 // the opener alive to receive the credential back.
156 expect(PreviewManager.previewWindowOpenAction(details({}))).toBe("popup");
157 expect(
158 PreviewManager.previewWindowOpenAction(details({ url: "http://localhost:5173/auth" })),
159 ).toBe("popup");
160 });
161
162 it("opens target=_blank links as a new tab", () => {
163 expect(PreviewManager.previewWindowOpenAction(details({ disposition: "foreground-tab" }))).toBe(
164 "new-tab",
165 );
166 expect(PreviewManager.previewWindowOpenAction(details({ disposition: "background-tab" }))).toBe(
167 "new-tab",
168 );
169 });
170
171 it("keeps a form POST with a body on the in-place path", () => {
172 // A new tab can only reopen the URL as a GET, which would drop the body.
173 const postBody = { data: [], contentType: "application/x-www-form-urlencoded" };
174 expect(
175 PreviewManager.previewWindowOpenAction({
176 ...details({ disposition: "foreground-tab" }),
177 postBody,
178 }),
179 ).toBe("navigate");
180 });
181
182 it("does not hand a window to schemes that cannot be hardened", () => {
183 // A popup skips the `will-attach-webview` hardening, so it only gets a window
184 // when its preferences can be overridden. Chromium copies the guest's
185 // preferences for `about:blank` and forbids overriding them.
186 for (const url of [
187 "javascript:alert(1)",
188 "file:///etc/passwd",
189 "vscode://vscode-remote/ssh-remote+box/tmp",
190 "not a url",
191 ]) {
192 expect(PreviewManager.previewWindowOpenAction(details({ url }))).toBe("navigate");
193 }
194 });
195
196 it("denies a blank window instead of blanking the opener", () => {
197 // MSAL opens `about:blank` first and falls back to a redirect on `null`;
198 // loading the blank page into the tab would destroy that fallback.
199 for (const url of ["about:blank", ""]) {
200 for (const disposition of ["new-window", "foreground-tab"] as const) {
201 expect(PreviewManager.previewWindowOpenAction(details({ url, disposition }))).toBe("deny");
202 }
203 }
204 });
205});
206
207const {
208 browserWindowConstructor,
209 clipboardItemConstructor,
210 createFromPath,
211 fromId,
212 getFocusedWebContents,
213 mkdir,
214 previewSession,
215 showItemInFolder,
216 webviewSend,
217 writeFile,
218 writeClipboard,
219} = vi.hoisted(() => ({
220 browserWindowConstructor: vi.fn(),
221 clipboardItemConstructor: vi.fn(),
222 createFromPath: vi.fn((): { readonly isEmpty: () => boolean; readonly toPNG: () => Buffer } => ({
223 isEmpty: () => false,
224 toPNG: () => Buffer.from("png"),
225 })),
226 fromId: vi.fn<(_id?: number) => Electron.WebContents | null>((_id?: number) => null),
227 getFocusedWebContents: vi.fn(() => null),
228 mkdir: vi.fn((_path: string) => undefined),
229 previewSession: {
230 on: vi.fn(),
231 getUserAgent: vi.fn(() => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36"),
232 webRequest: { onBeforeSendHeaders: vi.fn() },
233 },
234 showItemInFolder: vi.fn(),
235 webviewSend: vi.fn(),
236 writeFile: vi.fn((_path: string, _data: Uint8Array) => undefined),
237 writeClipboard: vi.fn(async () => undefined),
238}));
239
240vi.mock("electron", () => ({
241 BrowserWindow: browserWindowConstructor,
242 ClipboardItem: class {
243 constructor(data: Record<string, unknown>) {
244 clipboardItemConstructor(data);
245 }
246 },
247 clipboard: {
248 write: writeClipboard,
249 },
250 nativeImage: {
251 createFromPath,
252 },
253 shell: {
254 showItemInFolder,
255 },
256 session: {
257 fromPartition: vi.fn(),
258 },
259 webContents: {
260 fromId,
261 getFocusedWebContents,
262 },
263}));
264
265const layerBrowserSession = Layer.succeed(
266 BrowserSession.BrowserSession,
267 BrowserSession.BrowserSession.of({
268 getPartition: () => Effect.succeed("persist:t3code-preview-test"),
269 isPartition: (partition) => partition.startsWith("persist:t3code-preview-"),
270 getSession: () => Effect.succeed(previewSession as unknown as Electron.Session),
271 clearCookies: () => Effect.void,
272 clearCache: () => Effect.void,
273 }),
274);
275
276const layerEnvironment = Layer.succeed(
277 DesktopEnvironment.DesktopEnvironment,
278 DesktopEnvironment.DesktopEnvironment.of({
279 browserArtifactsDir: "/tmp/t3/dev/browser-artifacts",
280 dirname: "/tmp/t3/desktop",
281 path: {
282 join: (...parts: ReadonlyArray<string>) => parts.join("/"),
283 },
284 } as DesktopEnvironment.DesktopEnvironment["Service"]),
285);
286
287const layerFileSystem = FileSystem.layerNoop({
288 makeDirectory: (path) =>
289 Effect.sync(() => {
290 mkdir(path);
291 }),
292 writeFile: (path, data) =>
293 Effect.sync(() => {
294 writeFile(path, data);
295 }),
296});
297
298const managerLayer = (platform: NodeJS.Platform = "darwin") =>
299 PreviewManager.layer.pipe(
300 Layer.provideMerge(
301 Layer.succeed(DesktopRendererHistory.DesktopRendererHistory, {
302 register: () => Effect.void,
303 recordMetrics: () => Effect.void,
304 shutdown: Effect.void,
305 }),
306 ),
307 Layer.provideMerge(DesktopBrowserHost.layer),
308 Layer.provideMerge(layerBrowserSession),
309 Layer.provideMerge(
310 Layer.mock(PreviewPasskeys.PreviewPasskeys)({
311 bridgeEnabled: false,
312 installSessionHandlers: () => {},
313 attachGuest: () => () => {},
314 }),
315 ),
316 Layer.provideMerge(layerEnvironment),
317 Layer.provideMerge(layerFileSystem),
318 Layer.provideMerge(Path.layer),
319 Layer.provideMerge(NodeCrypto.layer),
320 Layer.provideMerge(Layer.succeed(HostProcess.Platform, platform)),
321 );
322
323const withManager = <A>(
324 use: (
325 manager: PreviewManager.PreviewManager["Service"],
326 ) => Effect.Effect<A, PreviewManager.PreviewManagerError, Scope.Scope>,
327 platform: NodeJS.Platform = "darwin",
328) =>
329 Effect.gen(function* () {
330 const manager = yield* PreviewManager.PreviewManager;
331 return yield* use(manager);
332 }).pipe(Effect.provide(managerLayer(platform)), Effect.scoped);
333
334interface TestCapturedPreviewImage {
335 readonly toJPEG: () => Buffer;
336 readonly getSize: () => { readonly width: number; readonly height: number };
337}
338
339type TestDisplayMediaHandler = (
340 request: { readonly frame: { readonly frameTreeNodeId: number } | null },
341 callback: (streams: { video?: unknown }) => void,
342) => void;
343
344interface TestHostWebContents {
345 readonly id: number;
346 readonly mainFrame: { readonly frameTreeNodeId: number };
347 readonly executeJavaScript: ReturnType<typeof vi.fn>;
348 readonly isDestroyed: () => boolean;
349 readonly session: {
350 readonly setDisplayMediaRequestHandler: ReturnType<typeof vi.fn>;
351 };
352 readonly displayMediaHandler: () => TestDisplayMediaHandler | undefined;
353}
354
355type TestPreviewWebContents = Electron.WebContents & {
356 readonly setBackgroundThrottling: ReturnType<typeof vi.fn<(enabled: boolean) => void>>;
357};
358
359const makeTestHostWebContents = (): TestHostWebContents => {
360 let handler: TestDisplayMediaHandler | undefined;
361 return {
362 id: 7,
363 mainFrame: { frameTreeNodeId: 7 },
364 executeJavaScript: vi.fn(async () => true),
365 isDestroyed: () => false,
366 session: {
367 setDisplayMediaRequestHandler: vi.fn((next: TestDisplayMediaHandler) => {
368 handler = next;
369 }),
370 },
371 displayMediaHandler: () => handler,
372 };
373};
374
375const makeTestPreviewWebContents = (
376 capturePage: () => Promise<TestCapturedPreviewImage>,
377 id = 42,
378 hostWebContents: TestHostWebContents = makeTestHostWebContents(),
379) => {
380 const setBackgroundThrottling = vi.fn<(enabled: boolean) => void>();
381 return {
382 id,
383 mainFrame: { routingId: id },
384 hostWebContents,
385 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),
386 isDestroyed: () => false,
387 getType: () => "webview",
388 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
389 getURL: () => "https://example.com",
390 getTitle: () => "Example",
391 isLoadingMainFrame: () => false,
392 getZoomFactor: () => 1,
393 setZoomFactor: vi.fn(),
394 setAudioMuted: vi.fn(),
395 setBackgroundThrottling,
396 isCurrentlyAudible: () => false,
397 on: vi.fn(),
398 off: vi.fn(),
399 ipc: { on: vi.fn(), off: vi.fn() },
400 send: webviewSend,
401 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
402 setIgnoreMenuShortcuts: vi.fn(),
403 setWindowOpenHandler: vi.fn(),
404 debugger: {
405 isAttached: () => false,
406 attach: vi.fn(),
407 sendCommand: vi.fn(async () => undefined),
408 on: vi.fn(),
409 off: vi.fn(),
410 },
411 capturePage,
412 } as unknown as TestPreviewWebContents;
413};
414
415/** Two ready tabs (41, 42) sharing one window, so they contend for the single display-media slot. */
416const setupRecordingRaceTabs = (manager: PreviewManager.PreviewManager["Service"]) =>
417 Effect.gen(function* () {
418 const capturePage = vi.fn(async () => ({
419 toJPEG: () => Buffer.from("unused-recording-frame"),
420 getSize: () => ({ width: 1280, height: 720 }),
421 }));
422 const host = makeTestHostWebContents();
423 const destroyedIds = new Set<number>();
424 const makeWebContents = (id: number) =>
425 Object.assign(makeTestPreviewWebContents(capturePage, id, host), {
426 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),
427 isDestroyed: () => destroyedIds.has(id),
428 });
429 const webContentsById = new Map([
430 [41, makeWebContents(41)],
431 [42, makeWebContents(42)],
432 ]);
433 fromId.mockImplementation((id) =>
434 id === undefined ? null : (webContentsById.get(id) ?? null),
435 );
436 yield* manager.createTab("tab_race_a");
437 yield* manager.createTab("tab_race_b");
438 yield* manager.registerWebview("tab_race_a", 41);
439 yield* manager.registerWebview("tab_race_b", 42);
440 const grants: Array<{ video?: unknown }> = [];
441 return {
442 host,
443 grants,
444 destroy: (id: number) => destroyedIds.add(id),
445 takeGrant: (frame = host.mainFrame) =>
446 host.displayMediaHandler()?.({ frame }, (value) => {
447 grants.push(value);
448 }),
449 };
450 });
451
452const TEST_FAVICON = "data:image/png;base64,cG5n";
453
454const makeSourcePng = (width = 1, height = 1): Buffer => {
455 const buffer = Buffer.alloc(24);
456 Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]).copy(buffer);
457 buffer.writeUInt32BE(width, 16);
458 buffer.writeUInt32BE(height, 20);
459 return buffer;
460};
461
462const makeFaviconWebContents = (options?: {
463 readonly fetch?: (url: string, init?: RequestInit) => Promise<Response>;
464 readonly id?: number;
465 readonly rasterize?: (code: string) => Promise<unknown>;
466 readonly url?: string;
467}) => {
468 const sourcePng = makeSourcePng();
469 const listeners = new Map<string, (...args: never[]) => void>();
470 let currentUrl = options?.url ?? "http://localhost:3200/";
471 let destroyed = false;
472 let loading = false;
473 const fetch = vi.fn(
474 options?.fetch ??
475 (async () =>
476 new Response(new Uint8Array(sourcePng), {
477 headers: { "content-type": "image/png" },
478 })),
479 );
480 const executeJavaScriptInIsolatedWorld = vi.fn(
481 async (_worldId: number, scripts: ReadonlyArray<{ readonly code: string }>) =>
482 options?.rasterize ? options.rasterize(scripts[0]?.code ?? "") : TEST_FAVICON,
483 );
484 const reload = vi.fn();
485 const loadURL = vi.fn(async (url: string) => {
486 currentUrl = url;
487 });
488 const off = vi.fn();
489 const debuggerOff = vi.fn();
490 const webContents = {
491 id: options?.id ?? 42,
492 isDestroyed: () => destroyed,
493 getType: () => "webview",
494 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
495 getURL: () => currentUrl,
496 getTitle: () => "Preview",
497 isLoadingMainFrame: () => loading,
498 isDevToolsOpened: () => false,
499 getZoomFactor: () => 1,
500 setZoomFactor: vi.fn(),
501 setAudioMuted: vi.fn(),
502 isCurrentlyAudible: () => false,
503 reload,
504 reloadIgnoringCache: vi.fn(),
505 loadURL,
506 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
507 listeners.set(event, listener);
508 }),
509 once: vi.fn((event: string, listener: (...args: never[]) => void) => {
510 listeners.set(event, listener);
511 }),
512 off,
513 ipc: { on: vi.fn(), off: vi.fn() },
514 send: webviewSend,
515 session: { fetch },
516 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
517 setIgnoreMenuShortcuts: vi.fn(),
518 setWindowOpenHandler: vi.fn(),
519 executeJavaScriptInIsolatedWorld,
520 debugger: {
521 isAttached: () => false,
522 attach: vi.fn(),
523 sendCommand: vi.fn(async () => undefined),
524 on: vi.fn(),
525 off: debuggerOff,
526 },
527 };
528 return {
529 executeJavaScriptInIsolatedWorld,
530 fetch,
531 debuggerOff,
532 listeners,
533 loadURL,
534 off,
535 reload,
536 setDestroyed: (value: boolean) => {
537 destroyed = value;
538 },
539 setLoading: (value: boolean) => {
540 loading = value;
541 },
542 setUrl: (url: string) => {
543 currentUrl = url;
544 },
545 webContents: webContents as never,
546 };
547};
548
549const settle = function* (until: () => boolean) {
550 for (let attempt = 0; attempt < 30 && !until(); attempt++) {
551 yield* Effect.promise(() => Promise.resolve());
552 }
553};
554
555const makeTestPictureInPictureWindow = (loadURL: () => Promise<void> = async () => undefined) => {
556 const listeners = new Map<string, () => void>();
557 const webContentsListeners = new Map<string, () => void>();
558 const send = vi.fn();
559 let destroyed = false;
560 const webContents = {
561 on: vi.fn((event: string, listener: () => void) => {
562 webContentsListeners.set(event, listener);
563 }),
564 off: vi.fn((event: string) => {
565 webContentsListeners.delete(event);
566 }),
567 send,
568 };
569 const pictureInPictureWindow = {
570 isDestroyed: vi.fn(() => destroyed),
571 once: vi.fn((event: string, listener: () => void) => {
572 listeners.set(event, listener);
573 }),
574 setAlwaysOnTop: vi.fn(),
575 setVisibleOnAllWorkspaces: vi.fn(),
576 setAspectRatio: vi.fn(),
577 getContentSize: vi.fn(() => [480, 320]),
578 setContentSize: vi.fn(),
579 loadURL: vi.fn(loadURL),
580 showInactive: vi.fn(() => {
581 if (destroyed) throw new Error("Picture-in-picture window is closed.");
582 }),
583 close: vi.fn(() => {
584 if (destroyed) return;
585 destroyed = true;
586 listeners.get("closed")?.();
587 }),
588 get webContents() {
589 if (destroyed) throw new Error("Picture-in-picture window is closed.");
590 return webContents;
591 },
592 };
593 return { pictureInPictureWindow, send, webContentsListeners };
594};
595
596describe("PreviewManager", () => {
597 beforeEach(() => {
598 browserWindowConstructor.mockReset();
599 fromId.mockClear();
600 getFocusedWebContents.mockReset();
601 getFocusedWebContents.mockReturnValue(null);
602 mkdir.mockClear();
603 previewSession.on.mockClear();
604 previewSession.webRequest.onBeforeSendHeaders.mockClear();
605 writeFile.mockClear();
606 showItemInFolder.mockClear();
607 clipboardItemConstructor.mockClear();
608 writeClipboard.mockClear();
609 createFromPath.mockClear();
610 webviewSend.mockClear();
611 });
612
613 effectIt.effect("opens a target=_blank link as a new tab without navigating the opener", () =>
614 withManager((manager) =>
615 Effect.gen(function* () {
616 const preview = makeFaviconWebContents();
617 fromId.mockReturnValue(preview.webContents);
618 const opened: Array<{ tabId: string; url: string; background: boolean }> = [];
619 yield* manager.subscribeOpenLinks((event) => Effect.sync(() => void opened.push(event)));
620 yield* manager.createTab("tab_links");
621 yield* manager.registerWebview("tab_links", 42);
622 const openHandler = (preview.webContents as Electron.WebContents).setWindowOpenHandler as
623 | ReturnType<typeof vi.fn>
624 | undefined;
625 const handler = openHandler?.mock.lastCall?.[0] as (
626 details: Partial<Electron.HandlerDetails>,
627 ) => { action: string };
628
629 expect(
630 handler({ url: "https://github.com/issues/1", disposition: "foreground-tab" }),
631 ).toEqual({ action: "deny" });
632 expect(handler({ url: "about:blank", disposition: "new-window" })).toEqual({
633 action: "deny",
634 });
635 handler({ url: "https://github.com/issues/2", disposition: "background-tab" });
636 yield* settle(() => opened.length === 2);
637
638 expect(preview.loadURL).not.toHaveBeenCalled();
639 expect(opened).toEqual([
640 { tabId: "tab_links", url: "https://github.com/issues/1", background: false },
641 { tabId: "tab_links", url: "https://github.com/issues/2", background: true },
642 ]);
643 }),
644 ),
645 );
646
647 effectIt.effect("keeps preview shortcuts out of the host window", () =>
648 withManager((manager) =>
649 Effect.gen(function* () {
650 const preview = makeFaviconWebContents();
651 const sendInputEvent = vi.fn();
652 const hostWebContents = { sendInputEvent, isDestroyed: () => false };
653 Object.assign(preview.webContents, { hostWebContents });
654 fromId.mockReturnValue(preview.webContents);
655 getFocusedWebContents.mockReturnValue(preview.webContents as never);
656 yield* manager.setMainWindow({
657 isDestroyed: () => false,
658 once: vi.fn(),
659 webContents: hostWebContents,
660 } as never);
661 yield* manager.createTab("tab_keys");
662 yield* manager.registerWebview("tab_keys", 42);
663
664 expect(
665 (preview.webContents as Electron.WebContents).setIgnoreMenuShortcuts,
666 ).toHaveBeenCalledWith(true);
667 const beforeInput = preview.listeners.get("before-input-event")!;
668 for (const control of [false, true]) {
669 for (const key of ["k", ",", "w", "j", "q", "+"]) {
670 for (const type of ["keyDown", "keyUp"]) {
671 const preventDefault = vi.fn();
672 beforeInput(
673 { preventDefault } as never,
674 { type, key, meta: !control, control, shift: key === "j", alt: false } as never,
675 );
676 yield* Effect.yieldNow;
677 expect(preventDefault).not.toHaveBeenCalled();
678 expect(
679 (preview.webContents as Electron.WebContents).setIgnoreMenuShortcuts,
680 ).toHaveBeenLastCalledWith(true);
681 }
682 }
683 }
684 expect(sendInputEvent).not.toHaveBeenCalled();
685
686 const preventDefault = vi.fn();
687 beforeInput(
688 { preventDefault } as never,
689 {
690 type: "keyDown",
691 key: "r",
692 meta: true,
693 control: false,
694 shift: false,
695 alt: false,
696 } as never,
697 );
698 yield* Effect.yieldNow;
699 expect(preventDefault).toHaveBeenCalledOnce();
700 expect(preview.reload).toHaveBeenCalledOnce();
701 expect(sendInputEvent).not.toHaveBeenCalled();
702 }),
703 ),
704 );
705
706 effectIt.effect.each([
707 ["mod+shift+t", "view.reopenClosed"],
708 ["ctrl+alt+u", "sidebar.toggle"],
709 ] as const)(
710 "forwards only the configured command from the focused guest: %s",
711 ([chord, command]) =>
712 withManager((manager) =>
713 Effect.gen(function* () {
714 const preview = makeFaviconWebContents();
715 const send = vi.fn();
716 Object.assign(preview.webContents, {
717 hostWebContents: { isDestroyed: () => false, send },
718 });
719 fromId.mockReturnValue(preview.webContents);
720 getFocusedWebContents.mockReturnValue(preview.webContents as never);
721 yield* manager.createTab("tab_reopen");
722 yield* manager.registerWebview("tab_reopen", 42);
723 const shortcut = parseKeybindingShortcut(chord)!;
724 yield* manager.setForwardedShortcuts([{ command, shortcut }]);
725 const beforeInput = preview.listeners.get("before-input-event")!;
726 const input = {
727 type: "keyDown",
728 key: shortcut.key.toUpperCase(),
729 meta: shortcut.metaKey || shortcut.modKey,
730 control: shortcut.ctrlKey,
731 shift: shortcut.shiftKey,
732 alt: shortcut.altKey,
733 };
734 const preventDefault = vi.fn();
735 beforeInput({ preventDefault } as never, input as never);
736 expect(preventDefault).toHaveBeenCalledOnce();
737 expect(send).toHaveBeenCalledExactlyOnceWith("desktop:menu-action", command);
738 for (const overrides of [
739 { isAutoRepeat: true },
740 { type: "keyUp" },
741 { shift: !input.shift },
742 { key: "x" },
743 ]) {
744 preventDefault.mockClear();
745 beforeInput({ preventDefault } as never, { ...input, ...overrides } as never);
746 expect(preventDefault).toHaveBeenCalledTimes("isAutoRepeat" in overrides ? 1 : 0);
747 expect(send).toHaveBeenCalledOnce();
748 }
749 getFocusedWebContents.mockReturnValue(null);
750 preventDefault.mockClear();
751 beforeInput({ preventDefault } as never, input as never);
752 expect(preventDefault).not.toHaveBeenCalled();
753 expect(send).toHaveBeenCalledOnce();
754 getFocusedWebContents.mockReturnValue(preview.webContents as never);
755 yield* manager.setForwardedShortcuts([]);
756 beforeInput({ preventDefault } as never, input as never);
757 expect(preventDefault).not.toHaveBeenCalled();
758 expect(send).toHaveBeenCalledOnce();
759 }),
760 ),
761 );
762
763 effectIt.effect.each([
764 ["linux", "ctrl+alt+[", "[", "BracketLeft", false],
765 ["win32", "ctrl+alt+8", "[", "Digit8", false],
766 ["win32", "ctrl+alt+k", "K", "KeyK", false],
767 ["linux", "ctrl+alt+`", "Dead", "Backquote", false],
768 ["darwin", "ctrl+alt+[", "[", "BracketLeft", true],
769 ["linux", "ctrl+alt+f7", "F7", "F7", true],
770 ] as const)(
771 "handles ambiguous preview chords on %s: %s",
772 ([platform, chord, key, code, expected]) =>
773 withManager(
774 (manager) =>
775 Effect.gen(function* () {
776 const preview = makeFaviconWebContents();
777 const send = vi.fn();
778 Object.assign(preview.webContents, {
779 hostWebContents: { isDestroyed: () => false, send },
780 });
781 fromId.mockReturnValue(preview.webContents);
782 getFocusedWebContents.mockReturnValue(preview.webContents as never);
783 yield* manager.createTab("tab_alt_graph");
784 yield* manager.registerWebview("tab_alt_graph", 42);
785 yield* manager.setForwardedShortcuts([
786 { command: "view.reopenClosed", shortcut: parseKeybindingShortcut(chord)! },
787 ]);
788 const preventDefault = vi.fn();
789 preview.listeners.get("before-input-event")!(
790 { preventDefault } as never,
791 {
792 type: "keyDown",
793 key,
794 code,
795 meta: false,
796 control: true,
797 shift: false,
798 alt: true,
799 } as never,
800 );
801 expect(preventDefault).toHaveBeenCalledTimes(expected ? 1 : 0);
802 expect(send).toHaveBeenCalledTimes(expected ? 1 : 0);
803 }),
804 platform,
805 ),
806 );
807
808 effectIt.effect.each([
809 ["default", true, undefined],
810 ["work", true, "profile"],
811 ["incognito", false, "profile"],
812 ] as const)(
813 "holds the first %s request until its Chrome identity is ready",
814 ([profile, persistent, namespace]) =>
815 withManager((manager) =>
816 Effect.gen(function* () {
817 yield* manager.getBrowserSession(profile, persistent, namespace);
818 yield* manager.getBrowserSession(profile, persistent, namespace);
819 expect(previewSession.webRequest.onBeforeSendHeaders).toHaveBeenCalledTimes(1);
820 const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as (
821 details: Partial<Electron.OnBeforeSendHeadersListenerDetails>,
822 callback: (response: Electron.BeforeSendResponse) => void,
823 ) => void;
824 for (const kind of ["webview", "window"]) {
825 const preview = makeFaviconWebContents({ id: kind === "webview" ? 42 : 43 });
826 Object.assign(preview.webContents, { getType: () => kind });
827 const overrideStarted = Promise.withResolvers<void>();
828 const overrideReady = Promise.withResolvers<void>();
829 const request = Promise.withResolvers<Electron.BeforeSendResponse>();
830 const send = vi.fn(request.resolve);
831 const headers = {
832 "User-Agent": "Electron/44.4.5",
833 Accept: "text/html",
834 Referer: "https://app.example/signin",
835 "Sec-CH-UA-Full-Version-List": '"Electron";v="44.4.5"',
836 };
837 const command = (preview.webContents as Electron.WebContents).debugger
838 .sendCommand as ReturnType<typeof vi.fn>;
839 command.mockImplementation(async (method: string) => {
840 if (method === "Emulation.setUserAgentOverride") {
841 overrideStarted.resolve();
842 await overrideReady.promise;
843 }
844 });
845 intercept(
846 {
847 url: "http://127.0.0.1:3001/first",
848 resourceType: "mainFrame",
849 webContents: preview.webContents,
850 requestHeaders: headers,
851 },
852 send,
853 );
854 yield* Effect.promise(() => overrideStarted.promise);
855 expect(send).not.toHaveBeenCalled();
856 overrideReady.resolve();
857 const first = yield* Effect.promise(() => request.promise);
858 expect(send).toHaveBeenCalledTimes(1);
859 const override = command.mock.calls.find(
860 ([method]) => method === "Emulation.setUserAgentOverride",
861 )![1];
862 expect(first.cancel).not.toBe(true);
863 expect(first.requestHeaders).toEqual(
864 expect.objectContaining({
865 "User-Agent": override.userAgent,
866 "sec-ch-ua": expect.stringContaining('"Google Chrome";v="148"'),
867 "sec-ch-ua-mobile": "?0",
868 "sec-ch-ua-platform": '"macOS"',
869 Accept: headers.Accept,
870 Referer: headers.Referer,
871 "Sec-CH-UA-Full-Version-List": override.userAgentMetadata.fullVersionList
872 .map(
873 ({ brand, version }: { brand: string; version: string }) =>
874 `${JSON.stringify(brand)};v=${JSON.stringify(version)}`,
875 )
876 .join(", "),
877 }),
878 );
879 expect(headers["User-Agent"]).toBe("Electron/44.4.5");
880 yield* manager.prepareWebview(preview.webContents);
881 }
882 }),
883 ),
884 );
885
886 effectIt.effect(
887 "releases a native popup's first request without waiting for its document background",
888 () =>
889 withManager((manager) =>
890 Effect.gen(function* () {
891 yield* manager.getBrowserSession("default");
892 const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as (
893 details: Partial<Electron.OnBeforeSendHeadersListenerDetails>,
894 callback: (response: Electron.BeforeSendResponse) => void,
895 ) => void;
896 const opener = makeFaviconWebContents();
897 fromId.mockReturnValue(opener.webContents);
898 yield* manager.createTab("popup_opener");
899 yield* manager.registerWebview("popup_opener", 42);
900 yield* manager.prepareWebview(opener.webContents);
901 const popup = makeFaviconWebContents({ id: 43 });
902 Object.assign(popup.webContents, { getType: () => "window" });
903 const identityStarted = Promise.withResolvers<void>();
904 const identityReady = Promise.withResolvers<void>();
905 const request = Promise.withResolvers<Electron.BeforeSendResponse>();
906 const send = vi.fn(request.resolve);
907 const command = (popup.webContents as Electron.WebContents).debugger
908 .sendCommand as ReturnType<typeof vi.fn>;
909 command.mockImplementation(async (method: string) => {
910 if (method === "Emulation.setDefaultBackgroundColorOverride") await request.promise;
911 if (method === "Emulation.setUserAgentOverride") {
912 identityStarted.resolve();
913 await identityReady.promise;
914 }
915 });
916 opener.listeners.get("did-create-window")!({ webContents: popup.webContents } as never);
917 intercept(
918 {
919 url: "http://127.0.0.1:3001/popup",
920 resourceType: "mainFrame",
921 webContents: popup.webContents,
922 requestHeaders: { Referer: "https://app.example/signin" },
923 },
924 send,
925 );
926 yield* Effect.promise(() => identityStarted.promise);
927 expect(send).not.toHaveBeenCalled();
928 identityReady.resolve();
929 const released = yield* Effect.promise(() => request.promise);
930 expect(send).toHaveBeenCalledTimes(1);
931 expect(command).not.toHaveBeenCalledWith(
932 "Emulation.setDefaultBackgroundColorOverride",
933 expect.anything(),
934 );
935 expect(released.requestHeaders).toEqual(
936 expect.objectContaining({
937 "User-Agent": "Mozilla/5.0 Chrome/148.0.0.0 Safari/537.36",
938 "sec-ch-ua": expect.stringContaining('"Google Chrome";v="148"'),
939 Referer: "https://app.example/signin",
940 }),
941 );
942 }),
943 ),
944 );
945
946 effectIt.effect("only supplies low entropy Chrome hints on trustworthy destinations", () =>
947 withManager((manager) =>
948 Effect.gen(function* () {
949 yield* manager.getBrowserSession("default");
950 const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as (
951 details: Partial<Electron.OnBeforeSendHeadersListenerDetails>,
952 callback: (response: Electron.BeforeSendResponse) => void,
953 ) => void;
954 for (const [url, trusted] of [
955 ["https://example.com", true],
956 ["http://localhost:3001", true],
957 ["http://app.localhost:3001", true],
958 ["http://[::1]:3001", true],
959 ["http://127.0.0.42:3001", true],
960 ["http://100.65.180.100:3001", false],
961 ["http://example.com", false],
962 ] as const) {
963 const responseReady = Promise.withResolvers<Electron.BeforeSendResponse>();
964 intercept({ url, requestHeaders: { Accept: "text/html" } }, responseReady.resolve);
965 const response = yield* Effect.promise(() => responseReady.promise);
966 expect(response.requestHeaders?.["sec-ch-ua"]).toBe(
967 trusted
968 ? '"Chromium";v="148", "Google Chrome";v="148", "Not A(Brand";v="99"'
969 : undefined,
970 );
971 expect(response.requestHeaders?.["sec-ch-ua-full-version-list"]).toBeUndefined();
972 }
973 }),
974 ),
975 );
976
977 effectIt.effect(
978 "keeps browsing while DevTools owns the debugger and restores identity on close",
979 () =>
980 withManager((manager) =>
981 Effect.gen(function* () {
982 yield* manager.getBrowserSession("default");
983 const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as (
984 details: Partial<Electron.OnBeforeSendHeadersListenerDetails>,
985 callback: (response: Electron.BeforeSendResponse) => void,
986 ) => void;
987 const preview = makeFaviconWebContents();
988 let devToolsOpened = false;
989 Object.assign(preview.webContents, {
990 isDevToolsOpened: () => devToolsOpened,
991 openDevTools: () => {
992 devToolsOpened = true;
993 },
994 });
995 fromId.mockReturnValue(preview.webContents);
996 yield* manager.createTab("tab_devtools");
997 yield* manager.registerWebview("tab_devtools", 42);
998 yield* manager.prepareWebview(preview.webContents);
999 const command = (preview.webContents as Electron.WebContents).debugger
1000 .sendCommand as ReturnType<typeof vi.fn>;
1001 expect(
1002 command.mock.calls.filter(([method]) => method === "Emulation.setUserAgentOverride"),
1003 ).toHaveLength(1);
1004 yield* manager.openDevTools("tab_devtools");
1005 const request = Promise.withResolvers<Electron.BeforeSendResponse>();
1006 intercept(
1007 {
1008 url: "https://example.com",
1009 resourceType: "mainFrame",
1010 webContents: preview.webContents,
1011 requestHeaders: {},
1012 },
1013 request.resolve,
1014 );
1015 expect((yield* Effect.promise(() => request.promise)).cancel).not.toBe(true);
1016 expect(
1017 command.mock.calls.filter(([method]) => method === "Emulation.setUserAgentOverride"),
1018 ).toHaveLength(1);
1019 const restored = Promise.withResolvers<void>();
1020 command.mockImplementation(async (method: string) => {
1021 if (method === "Emulation.setUserAgentOverride") restored.resolve();
1022 });
1023 devToolsOpened = false;
1024 preview.listeners.get("devtools-closed")!();
1025 yield* Effect.promise(() => restored.promise);
1026 expect(
1027 command.mock.calls.filter(([method]) => method === "Emulation.setUserAgentOverride"),
1028 ).toHaveLength(2);
1029 }),
1030 ),
1031 );
1032
1033 effectIt.effect("cancels a first navigation when Chrome identity preparation fails", () =>
1034 withManager((manager) =>
1035 Effect.gen(function* () {
1036 yield* manager.getBrowserSession("default");
1037 const intercept = previewSession.webRequest.onBeforeSendHeaders.mock.calls[0]![1] as (
1038 details: Partial<Electron.OnBeforeSendHeadersListenerDetails>,
1039 callback: (response: Electron.BeforeSendResponse) => void,
1040 ) => void;
1041 const preview = makeFaviconWebContents();
1042 const command = (preview.webContents as Electron.WebContents).debugger
1043 .sendCommand as ReturnType<typeof vi.fn>;
1044 command.mockRejectedValue(new Error("identity override failed"));
1045 const responseReady = Promise.withResolvers<Electron.BeforeSendResponse>();
1046 intercept(
1047 { url: "https://example.com", webContents: preview.webContents, requestHeaders: {} },
1048 responseReady.resolve,
1049 );
1050 expect(yield* Effect.promise(() => responseReady.promise)).toEqual({ cancel: true });
1051 }),
1052 ),
1053 );
1054
1055 effectIt.effect("preserves focused browser editing in tabs and sign-in popups", () =>
1056 withManager((manager) =>
1057 Effect.gen(function* () {
1058 const preview = makeFaviconWebContents();
1059 fromId.mockReturnValue(preview.webContents);
1060 yield* manager.createTab("tab_editing");
1061 yield* manager.registerWebview("tab_editing", 42);
1062
1063 const popup = makeFaviconWebContents({ id: 43 });
1064 const popupReady = Promise.withResolvers<void>();
1065 const popupCommands = (popup.webContents as Electron.WebContents).debugger
1066 .sendCommand as ReturnType<typeof vi.fn>;
1067 popupCommands.mockImplementation(async (method: string) => {
1068 if (method === "Emulation.setUserAgentOverride") popupReady.resolve();
1069 });
1070 preview.listeners.get("did-create-window")!({ webContents: popup.webContents } as never);
1071 yield* Effect.promise(() => popupReady.promise);
1072 expect(popupCommands).toHaveBeenCalledWith(
1073 "Emulation.setUserAgentOverride",
1074 expect.objectContaining({
1075 userAgent: "Mozilla/5.0 Chrome/148.0.0.0 Safari/537.36",
1076 userAgentMetadata: expect.objectContaining({
1077 brands: expect.arrayContaining([{ brand: "Google Chrome", version: "148" }]),
1078 }),
1079 }),
1080 );
1081 expect(
1082 (popup.webContents as Electron.WebContents).setIgnoreMenuShortcuts,
1083 ).toHaveBeenCalledWith(true);
1084
1085 for (const browser of [preview, popup]) {
1086 const contents = browser.webContents as Electron.WebContents;
1087 getFocusedWebContents.mockReturnValue(browser.webContents as never);
1088 const beforeInput = browser.listeners.get("before-input-event")!;
1089 const preventDefault = vi.fn();
1090 const input = {
1091 type: "keyDown",
1092 key: "v",
1093 meta: true,
1094 control: false,
1095 shift: false,
1096 alt: false,
1097 };
1098 beforeInput({ preventDefault } as never, input as never);
1099 expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false);
1100 // Releasing Command must not disable native fallback for the pending paste.
1101 beforeInput(
1102 { preventDefault } as never,
1103 { ...input, type: "keyUp", key: "Meta", meta: false } as never,
1104 );
1105 expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(false);
1106
1107 beforeInput({ preventDefault } as never, { ...input, key: "w" } as never);
1108 expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true);
1109
1110 // An injected paste in an unfocused guest cannot edit the active renderer.
1111 getFocusedWebContents.mockReturnValue(null);
1112 beforeInput({ preventDefault } as never, input as never);
1113 expect(contents.setIgnoreMenuShortcuts).toHaveBeenLastCalledWith(true);
1114 expect(preventDefault).not.toHaveBeenCalled();
1115 }
1116 }),
1117 ),
1118 );
1119
1120 effectIt.effect("rejects a destroyed webview during registration", () =>
1121 withManager((manager) =>
1122 Effect.gen(function* () {
1123 const getType = vi.fn(() => "webview" as const);
1124 fromId.mockReturnValue({
1125 id: 42,
1126 isDestroyed: () => true,
1127 getType,
1128 } as never);
1129 yield* manager.createTab("tab_destroyed_registration");
1130
1131 const exit = yield* Effect.exit(manager.registerWebview("tab_destroyed_registration", 42));
1132
1133 expect(Exit.isFailure(exit)).toBe(true);
1134 if (Exit.isFailure(exit)) {
1135 expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({
1136 _tag: "PreviewWebContentsNotFoundError",
1137 tabId: "tab_destroyed_registration",
1138 webContentsId: 42,
1139 });
1140 }
1141 expect(getType).not.toHaveBeenCalled();
1142 }),
1143 ),
1144 );
1145
1146 effectIt.effect("isolates failed state listeners and continues delivery", () => {
1147 const loggedErrors: Array<unknown> = [];
1148 const logger = Logger.make(({ message }) => {
1149 for (const value of Array.isArray(message) ? message : [message]) {
1150 if (typeof value === "object" && value !== null && "cause" in value) {
1151 loggedErrors.push(Cause.squash(value.cause as Cause.Cause<never>));
1152 }
1153 }
1154 });
1155 const deliveryError = new ElectronWindow.ElectronWindowOperationError({
1156 operation: "send-window-message",
1157 platform: "darwin",
1158 windowId: 42,
1159 channel: "preview:state-change",
1160 cause: new Error("renderer unavailable"),
1161 });
1162 const delivered = vi.fn();
1163
1164 return withManager((manager) =>
1165 Effect.gen(function* () {
1166 yield* manager.subscribeStateChanges(() => Effect.die(deliveryError));
1167 yield* manager.subscribeStateChanges((tabId, state) =>
1168 Effect.sync(() => {
1169 delivered(tabId, state);
1170 }),
1171 );
1172
1173 const state = yield* manager.createTab("tab_listener_failure");
1174
1175 expect(delivered).toHaveBeenCalledOnce();
1176 expect(delivered).toHaveBeenCalledWith("tab_listener_failure", state);
1177 expect(loggedErrors).toHaveLength(1);
1178 expect(loggedErrors[0]).toBeInstanceOf(ElectronWindow.ElectronWindowOperationError);
1179 expect(loggedErrors[0]).toMatchObject({
1180 operation: "send-window-message",
1181 windowId: 42,
1182 channel: "preview:state-change",
1183 });
1184 }),
1185 ).pipe(
1186 Effect.provide(
1187 Logger.layer([logger], {
1188 mergeWithExisting: false,
1189 }),
1190 ),
1191 );
1192 });
1193
1194 effectIt.effect("does not swallow state listener interruption", () =>
1195 withManager((manager) =>
1196 Effect.gen(function* () {
1197 const exit = yield* Effect.scoped(
1198 Effect.gen(function* () {
1199 yield* manager.subscribeStateChanges(() => Effect.interrupt);
1200 return yield* Effect.exit(manager.createTab("tab_interrupted_listener"));
1201 }),
1202 );
1203
1204 expect(Exit.isFailure(exit)).toBe(true);
1205 if (Exit.isFailure(exit)) {
1206 expect(Cause.hasInterrupts(exit.cause)).toBe(true);
1207 }
1208 }),
1209 ),
1210 );
1211
1212 effectIt.effect("queues navigation until the webview registers", () =>
1213 withManager((manager) =>
1214 Effect.gen(function* () {
1215 const loadURL = vi.fn(async () => undefined);
1216 const listeners = new Map<string, (...args: never[]) => void>();
1217 fromId.mockReturnValue({
1218 id: 42,
1219 isDestroyed: () => false,
1220 getType: () => "webview",
1221 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1222 getURL: () => "about:blank",
1223 getTitle: () => "",
1224 isLoadingMainFrame: () => false,
1225 getZoomFactor: () => 1,
1226 setZoomFactor: vi.fn(),
1227 setAudioMuted: vi.fn(),
1228 isCurrentlyAudible: () => false,
1229 loadURL,
1230 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
1231 listeners.set(event, listener);
1232 }),
1233 off: vi.fn(),
1234 ipc: { on: vi.fn(), off: vi.fn() },
1235 send: webviewSend,
1236 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
1237 setIgnoreMenuShortcuts: vi.fn(),
1238 setWindowOpenHandler: vi.fn(),
1239 debugger: {
1240 isAttached: () => false,
1241 attach: vi.fn(),
1242 sendCommand: vi.fn(async () => undefined),
1243 on: vi.fn(),
1244 off: vi.fn(),
1245 },
1246 } as never);
1247
1248 yield* manager.navigate("tab_pending", "localhost:3200");
1249
1250 yield* manager.registerWebview("tab_pending", 42);
1251 yield* Effect.yieldNow;
1252
1253 expect(loadURL).toHaveBeenCalledOnce();
1254 expect(loadURL).toHaveBeenCalledWith("http://localhost:3200/");
1255 }),
1256 ),
1257 );
1258
1259 effectIt.effect("detaches a destroyed webview instead of navigating it", () =>
1260 withManager((manager) =>
1261 Effect.gen(function* () {
1262 const preview = makeFaviconWebContents();
1263 fromId.mockReturnValue(preview.webContents);
1264 const states: PreviewManager.PreviewTabState[] = [];
1265 yield* manager.subscribeStateChanges((_tabId, state) =>
1266 Effect.sync(() => {
1267 states.push(state);
1268 }),
1269 );
1270 yield* manager.createTab("tab_destroyed_navigation");
1271 yield* manager.registerWebview("tab_destroyed_navigation", 42);
1272 yield* manager.setColorScheme("tab_destroyed_navigation", "dark");
1273 preview.setDestroyed(true);
1274
1275 yield* manager.navigate("tab_destroyed_navigation", "https://example.com/");
1276
1277 expect(preview.loadURL).not.toHaveBeenCalled();
1278 expect(preview.reload).not.toHaveBeenCalled();
1279 expect(preview.off).toHaveBeenCalled();
1280 expect(preview.debuggerOff).toHaveBeenCalled();
1281 expect(states.at(-1)).toMatchObject({
1282 webContentsId: null,
1283 navStatus: { kind: "Loading", url: "https://example.com/" },
1284 });
1285 }),
1286 ),
1287 );
1288
1289 effectIt.effect("detaches through the pinned debugger after the webview is destroyed", () =>
1290 withManager((manager) =>
1291 Effect.gen(function* () {
1292 // Real Electron throws on any `wc.debugger` access once the
1293 // WebContents is destroyed, so cleanup must go through the debugger
1294 // reference captured at attach time (electron/electron#53376).
1295 let destroyed = false;
1296 let attached = false;
1297 const debuggerOff = vi.fn();
1298 const debuggerDetach = vi.fn(() => {
1299 attached = false;
1300 });
1301 const wcDebugger = {
1302 isAttached: () => attached,
1303 attach: vi.fn(() => {
1304 attached = true;
1305 }),
1306 detach: debuggerDetach,
1307 sendCommand: vi.fn(async () => undefined),
1308 on: vi.fn(),
1309 off: debuggerOff,
1310 };
1311 fromId.mockReturnValue({
1312 id: 42,
1313 isDestroyed: () => destroyed,
1314 getType: () => "webview",
1315 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1316 getURL: () => "http://localhost:3200/",
1317 getTitle: () => "Preview",
1318 isLoadingMainFrame: () => false,
1319 isDevToolsOpened: () => false,
1320 getZoomFactor: () => 1,
1321 setZoomFactor: vi.fn(),
1322 setAudioMuted: vi.fn(),
1323 isCurrentlyAudible: () => false,
1324 reload: vi.fn(),
1325 loadURL: vi.fn(async () => undefined),
1326 on: vi.fn(),
1327 off: vi.fn(),
1328 ipc: { on: vi.fn(), off: vi.fn() },
1329 send: webviewSend,
1330 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
1331 setIgnoreMenuShortcuts: vi.fn(),
1332 setWindowOpenHandler: vi.fn(),
1333 get debugger() {
1334 if (destroyed) throw new Error("Object has been destroyed");
1335 return wcDebugger;
1336 },
1337 } as never);
1338 yield* manager.createTab("tab_pinned_debugger");
1339 yield* manager.registerWebview("tab_pinned_debugger", 42);
1340 yield* manager.setColorScheme("tab_pinned_debugger", "dark");
1341 expect(attached).toBe(true);
1342 destroyed = true;
1343
1344 yield* manager.navigate("tab_pinned_debugger", "https://example.com/");
1345
1346 expect(debuggerOff).toHaveBeenCalledWith("message", expect.any(Function));
1347 expect(debuggerDetach).toHaveBeenCalledOnce();
1348 }),
1349 ),
1350 );
1351
1352 effectIt.effect("does not let destroyed-webview cleanup detach a same-id replacement", () =>
1353 withManager((manager) =>
1354 Effect.gen(function* () {
1355 const previous = makeFaviconWebContents();
1356 const replacement = makeFaviconWebContents({ url: "https://example.com/" });
1357 let current = previous.webContents;
1358 let startReplacementRegistration: () => void = () => void 0;
1359 const replacementReady = new Promise<void>((resolve) => {
1360 startReplacementRegistration = resolve;
1361 });
1362 fromId.mockImplementation(() => current);
1363 yield* manager.createTab("tab_destroyed_replacement_race");
1364 yield* manager.registerWebview("tab_destroyed_replacement_race", 42);
1365 yield* manager.setColorScheme("tab_destroyed_replacement_race", "dark");
1366 const replacementRegistration = yield* Effect.promise(() => replacementReady).pipe(
1367 Effect.flatMap(() => manager.registerWebview("tab_destroyed_replacement_race", 42)),
1368 Effect.forkChild({ startImmediately: true }),
1369 );
1370 previous.setDestroyed(true);
1371 previous.debuggerOff.mockImplementationOnce(() => {
1372 current = replacement.webContents;
1373 startReplacementRegistration();
1374 });
1375 const states: PreviewManager.PreviewTabState[] = [];
1376 yield* manager.subscribeStateChanges((_tabId, state) =>
1377 Effect.sync(() => {
1378 states.push(state);
1379 }),
1380 );
1381
1382 yield* manager.navigate("tab_destroyed_replacement_race", "https://example.com/");
1383 const registrationExit = yield* Fiber.await(replacementRegistration);
1384
1385 expect(Exit.isSuccess(registrationExit)).toBe(true);
1386 expect(previous.off).toHaveBeenCalled();
1387 expect(replacement.off).not.toHaveBeenCalled();
1388 expect(states.at(-1)).toMatchObject({
1389 webContentsId: 42,
1390 navStatus: { kind: "Loading", url: "https://example.com/" },
1391 });
1392 }),
1393 ),
1394 );
1395
1396 effectIt.effect("publishes a canonical favicon origin while the page is loading", () =>
1397 withManager((manager) =>
1398 Effect.gen(function* () {
1399 const preview = makeFaviconWebContents({
1400 url: `http://localhost:3200/${"x".repeat(3_000)}`,
1401 });
1402 preview.setLoading(true);
1403 fromId.mockReturnValue(preview.webContents);
1404 const states: PreviewManager.PreviewTabState[] = [];
1405 yield* manager.subscribeStateChanges((_tabId, state) =>
1406 Effect.sync(() => {
1407 states.push(state);
1408 }),
1409 );
1410 yield* manager.createTab("tab_favicon_loading");
1411 yield* manager.registerWebview("tab_favicon_loading", 42);
1412
1413 preview.listeners.get("page-favicon-updated")?.(
1414 {} as never,
1415 ["http://localhost:3200/favicon.png"] as never,
1416 );
1417 yield* settle(() => states.at(-1)?.favicon !== undefined);
1418
1419 expect(states.at(-1)?.favicon).toMatchObject({
1420 dataUrl: TEST_FAVICON,
1421 pageUrl: "http://localhost:3200",
1422 });
1423 expect(states.at(-1)?.favicon?.capturedAt).toEqual(expect.any(Number));
1424 }),
1425 ),
1426 );
1427
1428 effectIt.effect("shares an identical in-flight event and lets a changed event win", () =>
1429 withManager((manager) =>
1430 Effect.gen(function* () {
1431 let resolveFirst!: (response: Response) => void;
1432 const firstResponse = new Promise<Response>((resolve) => {
1433 resolveFirst = resolve;
1434 });
1435 const preview = makeFaviconWebContents({
1436 fetch: (url) =>
1437 url.endsWith("first.png")
1438 ? firstResponse
1439 : Promise.resolve(
1440 new Response(new Uint8Array(makeSourcePng()), {
1441 headers: { "content-type": "image/png" },
1442 }),
1443 ),
1444 });
1445 fromId.mockReturnValue(preview.webContents);
1446 const states: PreviewManager.PreviewTabState[] = [];
1447 yield* manager.subscribeStateChanges((_tabId, state) =>
1448 Effect.sync(() => {
1449 states.push(state);
1450 }),
1451 );
1452 yield* manager.createTab("tab_favicon_latest");
1453 yield* manager.registerWebview("tab_favicon_latest", 42);
1454
1455 const faviconUpdated = preview.listeners.get("page-favicon-updated")!;
1456 faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never);
1457 faviconUpdated({} as never, ["http://localhost:3200/first.png"] as never);
1458 yield* settle(() => preview.fetch.mock.calls.length === 1);
1459 faviconUpdated({} as never, ["http://localhost:3200/second.png"] as never);
1460 yield* settle(() => states.at(-1)?.favicon !== undefined);
1461 resolveFirst(
1462 new Response(new Uint8Array(makeSourcePng()), {
1463 headers: { "content-type": "image/png" },
1464 }),
1465 );
1466 yield* settle(() => false);
1467
1468 expect(preview.fetch).toHaveBeenCalledTimes(2);
1469 expect(states.filter((state) => state.favicon !== undefined)).toHaveLength(1);
1470 }),
1471 ),
1472 );
1473
1474 effectIt.effect("allows an identical retry after an undecodable capture", () =>
1475 withManager((manager) =>
1476 Effect.gen(function* () {
1477 let rasterizations = 0;
1478 const preview = makeFaviconWebContents({
1479 rasterize: async () => (++rasterizations === 1 ? null : TEST_FAVICON),
1480 });
1481 fromId.mockReturnValue(preview.webContents);
1482 const states: PreviewManager.PreviewTabState[] = [];
1483 yield* manager.subscribeStateChanges((_tabId, state) =>
1484 Effect.sync(() => {
1485 states.push(state);
1486 }),
1487 );
1488 yield* manager.createTab("tab_favicon_retry");
1489 yield* manager.registerWebview("tab_favicon_retry", 42);
1490 const faviconUpdated = preview.listeners.get("page-favicon-updated")!;
1491
1492 faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never);
1493 yield* settle(() => rasterizations === 1);
1494 yield* settle(() => false);
1495 faviconUpdated({} as never, ["http://localhost:3200/favicon.png"] as never);
1496 yield* settle(() => states.at(-1)?.favicon !== undefined);
1497
1498 expect(rasterizations).toBe(2);
1499 expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
1500 }),
1501 ),
1502 );
1503
1504 effectIt.effect("does not publish a capture invalidated by navigation", () =>
1505 withManager((manager) =>
1506 Effect.gen(function* () {
1507 let resolveFetch!: (response: Response) => void;
1508 const preview = makeFaviconWebContents({
1509 fetch: () =>
1510 new Promise<Response>((resolve) => {
1511 resolveFetch = resolve;
1512 }),
1513 });
1514 fromId.mockReturnValue(preview.webContents);
1515 const states: PreviewManager.PreviewTabState[] = [];
1516 yield* manager.subscribeStateChanges((_tabId, state) =>
1517 Effect.sync(() => {
1518 states.push(state);
1519 }),
1520 );
1521 yield* manager.createTab("tab_favicon_navigation");
1522 yield* manager.registerWebview("tab_favicon_navigation", 42);
1523 preview.listeners.get("page-favicon-updated")?.(
1524 {} as never,
1525 ["http://localhost:3200/favicon.png"] as never,
1526 );
1527 yield* settle(() => preview.fetch.mock.calls.length === 1);
1528 preview.listeners.get("did-start-navigation")?.({
1529 isMainFrame: true,
1530 isSameDocument: false,
1531 } as never);
1532 preview.setUrl("https://example.com/");
1533 resolveFetch(
1534 new Response(new Uint8Array(makeSourcePng()), {
1535 headers: { "content-type": "image/png" },
1536 }),
1537 );
1538 yield* settle(() => false);
1539
1540 expect(states.some((state) => state.favicon !== undefined)).toBe(false);
1541 }),
1542 ),
1543 );
1544
1545 effectIt.effect("retains a favicon when reloading the current URL without a new event", () =>
1546 withManager((manager) =>
1547 Effect.gen(function* () {
1548 const preview = makeFaviconWebContents();
1549 fromId.mockReturnValue(preview.webContents);
1550 const states: PreviewManager.PreviewTabState[] = [];
1551 yield* manager.subscribeStateChanges((_tabId, state) =>
1552 Effect.sync(() => {
1553 states.push(state);
1554 }),
1555 );
1556 yield* manager.createTab("tab_favicon_reload");
1557 yield* manager.registerWebview("tab_favicon_reload", 42);
1558 preview.listeners.get("page-favicon-updated")?.(
1559 {} as never,
1560 ["http://localhost:3200/favicon.png"] as never,
1561 );
1562 yield* settle(() => states.at(-1)?.favicon !== undefined);
1563
1564 yield* manager.navigate("tab_favicon_reload", "http://localhost:3200/");
1565
1566 expect(preview.reload).toHaveBeenCalledOnce();
1567 expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
1568 }),
1569 ),
1570 );
1571
1572 effectIt.effect("clears a published favicon after a confirmed cross-origin navigation", () =>
1573 withManager((manager) =>
1574 Effect.gen(function* () {
1575 const preview = makeFaviconWebContents();
1576 fromId.mockReturnValue(preview.webContents);
1577 const states: PreviewManager.PreviewTabState[] = [];
1578 yield* manager.subscribeStateChanges((_tabId, state) =>
1579 Effect.sync(() => {
1580 states.push(state);
1581 }),
1582 );
1583 yield* manager.createTab("tab_favicon_origin");
1584 yield* manager.registerWebview("tab_favicon_origin", 42);
1585 preview.listeners.get("page-favicon-updated")?.(
1586 {} as never,
1587 ["http://localhost:3200/favicon.png"] as never,
1588 );
1589 yield* settle(() => states.at(-1)?.favicon !== undefined);
1590
1591 preview.setUrl("https://example.com/");
1592 preview.listeners.get("did-navigate")?.({} as never);
1593 yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
1594
1595 expect(states.at(-1)?.favicon).toBeUndefined();
1596 }),
1597 ),
1598 );
1599
1600 effectIt.effect(
1601 "retains the previous document icon across a failed cross-origin navigation",
1602 () =>
1603 withManager((manager) =>
1604 Effect.gen(function* () {
1605 const preview = makeFaviconWebContents();
1606 fromId.mockReturnValue(preview.webContents);
1607 const states: PreviewManager.PreviewTabState[] = [];
1608 yield* manager.subscribeStateChanges((_tabId, state) =>
1609 Effect.sync(() => {
1610 states.push(state);
1611 }),
1612 );
1613 yield* manager.createTab("tab_favicon_failed_origin");
1614 yield* manager.registerWebview("tab_favicon_failed_origin", 42);
1615 preview.listeners.get("page-favicon-updated")?.(
1616 {} as never,
1617 ["http://localhost:3200/favicon.png"] as never,
1618 );
1619 yield* settle(() => states.at(-1)?.favicon !== undefined);
1620
1621 preview.listeners.get("did-fail-load")?.(
1622 {} as never,
1623 -105 as never,
1624 "Name not resolved" as never,
1625 "https://unreachable.example/" as never,
1626 true as never,
1627 );
1628 yield* settle(() => states.at(-1)?.navStatus.kind === "LoadFailed");
1629 expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
1630
1631 preview.listeners.get("did-navigate")?.({} as never);
1632 yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
1633 expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
1634 }),
1635 ),
1636 );
1637
1638 effectIt.effect("does not resurrect an icon after a confirmed about:blank document", () =>
1639 withManager((manager) =>
1640 Effect.gen(function* () {
1641 const preview = makeFaviconWebContents();
1642 fromId.mockReturnValue(preview.webContents);
1643 const states: PreviewManager.PreviewTabState[] = [];
1644 yield* manager.subscribeStateChanges((_tabId, state) =>
1645 Effect.sync(() => {
1646 states.push(state);
1647 }),
1648 );
1649 yield* manager.createTab("tab_favicon_blank");
1650 yield* manager.registerWebview("tab_favicon_blank", 42);
1651 preview.listeners.get("page-favicon-updated")?.(
1652 {} as never,
1653 ["http://localhost:3200/favicon.png"] as never,
1654 );
1655 yield* settle(() => states.at(-1)?.favicon !== undefined);
1656
1657 preview.setUrl("about:blank");
1658 preview.listeners.get("did-navigate")?.({} as never);
1659 yield* settle(() => states.at(-1)?.navStatus.kind === "Idle");
1660 expect(states.at(-1)?.favicon).toBeUndefined();
1661
1662 preview.setUrl("http://localhost:3200/");
1663 preview.listeners.get("did-navigate")?.({} as never);
1664 yield* settle(() => states.at(-1)?.navStatus.kind === "Success");
1665 expect(states.at(-1)?.favicon).toBeUndefined();
1666 }),
1667 ),
1668 );
1669
1670 effectIt.effect("clears a published favicon when a replacement webview attaches", () =>
1671 withManager((manager) =>
1672 Effect.gen(function* () {
1673 const initial = makeFaviconWebContents({ id: 42 });
1674 const replacement = makeFaviconWebContents({ id: 43 });
1675 fromId.mockImplementation((id?: number) => {
1676 if (id === 42) return initial.webContents;
1677 if (id === 43) return replacement.webContents;
1678 return null;
1679 });
1680 const states: PreviewManager.PreviewTabState[] = [];
1681 yield* manager.subscribeStateChanges((_tabId, state) =>
1682 Effect.sync(() => {
1683 states.push(state);
1684 }),
1685 );
1686 yield* manager.createTab("tab_favicon_replace");
1687 yield* manager.registerWebview("tab_favicon_replace", 42);
1688 initial.listeners.get("page-favicon-updated")?.(
1689 {} as never,
1690 ["http://localhost:3200/favicon.png"] as never,
1691 );
1692 yield* settle(() => states.at(-1)?.favicon !== undefined);
1693
1694 yield* manager.registerWebview("tab_favicon_replace", 43);
1695
1696 expect(states.at(-1)?.webContentsId).toBe(43);
1697 expect(states.at(-1)?.favicon).toBeUndefined();
1698 }),
1699 ),
1700 );
1701
1702 effectIt.effect("ignores an old capture that completes after webview replacement", () =>
1703 withManager((manager) =>
1704 Effect.gen(function* () {
1705 let resolveFetch!: (response: Response) => void;
1706 const initial = makeFaviconWebContents({
1707 id: 42,
1708 fetch: () =>
1709 new Promise<Response>((resolve) => {
1710 resolveFetch = resolve;
1711 }),
1712 });
1713 const replacement = makeFaviconWebContents({ id: 43 });
1714 fromId.mockImplementation((id?: number) =>
1715 id === 42 ? initial.webContents : id === 43 ? replacement.webContents : null,
1716 );
1717 const states: PreviewManager.PreviewTabState[] = [];
1718 yield* manager.subscribeStateChanges((_tabId, state) =>
1719 Effect.sync(() => {
1720 states.push(state);
1721 }),
1722 );
1723 yield* manager.createTab("tab_favicon_late_replace");
1724 yield* manager.registerWebview("tab_favicon_late_replace", 42);
1725 initial.listeners.get("page-favicon-updated")?.(
1726 {} as never,
1727 ["http://localhost:3200/favicon.png"] as never,
1728 );
1729 yield* settle(() => initial.fetch.mock.calls.length === 1);
1730
1731 yield* manager.registerWebview("tab_favicon_late_replace", 43);
1732 resolveFetch(
1733 new Response(new Uint8Array(makeSourcePng()), {
1734 headers: { "content-type": "image/png" },
1735 }),
1736 );
1737 yield* settle(() => false);
1738
1739 expect(states.at(-1)?.webContentsId).toBe(43);
1740 expect(
1741 states.some((state) => state.webContentsId === 43 && state.favicon !== undefined),
1742 ).toBe(false);
1743 }),
1744 ),
1745 );
1746
1747 effectIt.effect("treats a reused WebContents id as a new attachment", () =>
1748 withManager((manager) =>
1749 Effect.gen(function* () {
1750 const initial = makeFaviconWebContents({ id: 42 });
1751 const replacement = makeFaviconWebContents({ id: 42 });
1752 let active = initial.webContents;
1753 fromId.mockImplementation(() => active);
1754 const states: PreviewManager.PreviewTabState[] = [];
1755 yield* manager.subscribeStateChanges((_tabId, state) =>
1756 Effect.sync(() => {
1757 states.push(state);
1758 }),
1759 );
1760 yield* manager.createTab("tab_favicon_reused_id");
1761 yield* manager.registerWebview("tab_favicon_reused_id", 42);
1762 initial.listeners.get("page-favicon-updated")?.(
1763 {} as never,
1764 ["http://localhost:3200/favicon.png"] as never,
1765 );
1766 yield* settle(() => states.at(-1)?.favicon !== undefined);
1767
1768 active = replacement.webContents;
1769 yield* manager.registerWebview("tab_favicon_reused_id", 42);
1770
1771 expect(states.at(-1)?.favicon).toBeUndefined();
1772 expect(initial.off).toHaveBeenCalled();
1773 expect(replacement.listeners.has("page-favicon-updated")).toBe(true);
1774 }),
1775 ),
1776 );
1777
1778 effectIt.effect("preserves a favicon when the active attachment registers again", () =>
1779 withManager((manager) =>
1780 Effect.gen(function* () {
1781 const preview = makeFaviconWebContents();
1782 fromId.mockReturnValue(preview.webContents);
1783 const states: PreviewManager.PreviewTabState[] = [];
1784 yield* manager.subscribeStateChanges((_tabId, state) =>
1785 Effect.sync(() => {
1786 states.push(state);
1787 }),
1788 );
1789 yield* manager.createTab("tab_favicon_reregister");
1790 yield* manager.registerWebview("tab_favicon_reregister", 42);
1791 preview.listeners.get("page-favicon-updated")?.(
1792 {} as never,
1793 ["http://localhost:3200/favicon.png"] as never,
1794 );
1795 yield* settle(() => states.at(-1)?.favicon !== undefined);
1796
1797 yield* manager.registerWebview("tab_favicon_reregister", 42);
1798
1799 expect(states.at(-1)?.favicon?.dataUrl).toBe(TEST_FAVICON);
1800 }),
1801 ),
1802 );
1803
1804 // The guest reports whatever zoom level Chromium handed it from the app
1805 // window, so the tab's own zoom is the source of truth in both directions:
1806 // asserted onto every guest, never read back off one.
1807 effectIt.effect("keeps the tab's own zoom instead of the guest's reported zoom", () =>
1808 withManager((manager) =>
1809 Effect.gen(function* () {
1810 let effectiveZoom = 0.9;
1811 let zoomReadable = true;
1812 let url = "https://example.com";
1813 const listeners = new Map<string, (...args: unknown[]) => void>();
1814 const setZoomFactor = vi.fn();
1815 fromId.mockReturnValue({
1816 id: 42,
1817 isDestroyed: () => false,
1818 getType: () => "webview",
1819 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1820 getURL: () => url,
1821 getTitle: () => "Example",
1822 isLoadingMainFrame: () => false,
1823 getZoomFactor: () => {
1824 if (!zoomReadable) throw new Error("zoom unavailable");
1825 return effectiveZoom;
1826 },
1827 setZoomFactor,
1828 setAudioMuted: vi.fn(),
1829 isCurrentlyAudible: () => false,
1830 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
1831 listeners.set(event, listener);
1832 }),
1833 off: vi.fn(),
1834 ipc: { on: vi.fn(), off: vi.fn() },
1835 send: webviewSend,
1836 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
1837 setIgnoreMenuShortcuts: vi.fn(),
1838 setWindowOpenHandler: vi.fn(),
1839 debugger: {
1840 isAttached: () => false,
1841 attach: vi.fn(),
1842 sendCommand: vi.fn(async () => undefined),
1843 on: vi.fn(),
1844 off: vi.fn(),
1845 },
1846 } as never);
1847 const states: PreviewManager.PreviewTabState[] = [];
1848
1849 yield* manager.subscribeStateChanges((_tabId, state) =>
1850 Effect.sync(() => {
1851 states.push(state);
1852 }),
1853 );
1854 yield* manager.createTab("tab_zoom");
1855 yield* manager.registerWebview("tab_zoom", 42);
1856
1857 expect(states.at(-1)?.zoomFactor).toBe(1);
1858 expect(setZoomFactor).toHaveBeenCalledWith(1);
1859
1860 // An app zoom leaves the guest reporting the inherited level. Navigating
1861 // must not adopt it as the preview's zoom.
1862 effectiveZoom = 0.8;
1863 url = "https://example.com/after-app-zoom";
1864 listeners.get("did-navigate")?.();
1865 yield* Effect.yieldNow;
1866
1867 expect(states.at(-1)?.navStatus).toEqual({
1868 kind: "Success",
1869 url,
1870 title: "Example",
1871 });
1872 expect(states.at(-1)?.zoomFactor).toBe(1);
1873
1874 // Only the preview's own zoom controls move it.
1875 yield* manager.zoomIn("tab_zoom");
1876 expect(setZoomFactor).toHaveBeenCalledWith(1.1);
1877 expect(states.at(-1)?.zoomFactor).toBe(1.1);
1878
1879 zoomReadable = false;
1880 listeners.get("did-navigate")?.();
1881 yield* Effect.yieldNow;
1882
1883 expect(states.at(-1)?.zoomFactor).toBe(1.1);
1884
1885 const replacementSetZoomFactor = vi.fn();
1886 fromId.mockReturnValue({
1887 id: 43,
1888 isDestroyed: () => false,
1889 getType: () => "webview",
1890 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1891 getURL: () => url,
1892 getTitle: () => "Example",
1893 isLoadingMainFrame: () => false,
1894 getZoomFactor: () => 1,
1895 setZoomFactor: replacementSetZoomFactor,
1896 setAudioMuted: vi.fn(),
1897 isCurrentlyAudible: () => false,
1898 on: vi.fn(),
1899 off: vi.fn(),
1900 ipc: { on: vi.fn(), off: vi.fn() },
1901 send: webviewSend,
1902 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
1903 setIgnoreMenuShortcuts: vi.fn(),
1904 setWindowOpenHandler: vi.fn(),
1905 debugger: {
1906 isAttached: () => false,
1907 attach: vi.fn(),
1908 sendCommand: vi.fn(async () => undefined),
1909 on: vi.fn(),
1910 off: vi.fn(),
1911 },
1912 } as never);
1913
1914 yield* manager.registerWebview("tab_zoom", 43);
1915
1916 expect(replacementSetZoomFactor).toHaveBeenCalledWith(1.1);
1917 expect(states.at(-1)?.zoomFactor).toBe(1.1);
1918 }),
1919 ),
1920 );
1921
1922 // Zooming the app UI pushes the window's zoom level onto every guest, so the
1923 // preview has to be put back at the zoom the user gave it.
1924 effectIt.effect("re-applies each tab's own zoom when the app window zooms", () =>
1925 withManager((manager) =>
1926 Effect.gen(function* () {
1927 const setZoomFactor = vi.fn();
1928 fromId.mockReturnValue({
1929 id: 42,
1930 isDestroyed: () => false,
1931 getType: () => "webview",
1932 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1933 getURL: () => "https://example.com",
1934 getTitle: () => "Example",
1935 isLoadingMainFrame: () => false,
1936 getZoomFactor: () => 1,
1937 setZoomFactor,
1938 setAudioMuted: vi.fn(),
1939 isCurrentlyAudible: () => false,
1940 on: vi.fn(),
1941 off: vi.fn(),
1942 ipc: { on: vi.fn(), off: vi.fn() },
1943 send: webviewSend,
1944 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
1945 setIgnoreMenuShortcuts: vi.fn(),
1946 setWindowOpenHandler: vi.fn(),
1947 debugger: {
1948 isAttached: () => false,
1949 attach: vi.fn(),
1950 sendCommand: vi.fn(async () => undefined),
1951 on: vi.fn(),
1952 off: vi.fn(),
1953 },
1954 } as never);
1955
1956 yield* manager.createTab("tab_reapply");
1957 yield* manager.registerWebview("tab_reapply", 42);
1958 yield* manager.zoomIn("tab_reapply");
1959 setZoomFactor.mockClear();
1960
1961 yield* manager.reapplyZoom();
1962
1963 expect(setZoomFactor).toHaveBeenCalledTimes(1);
1964 expect(setZoomFactor).toHaveBeenCalledWith(1.1);
1965 }),
1966 ),
1967 );
1968
1969 // did-attach and dom-ready both re-register the guest that is already
1970 // attached, and a guest that just inherited the app window's zoom needs its
1971 // own back — without that round trip republishing tab state.
1972 effectIt.effect("re-asserts the tab's zoom when the active guest registers again", () =>
1973 withManager((manager) =>
1974 Effect.gen(function* () {
1975 const setZoomFactor = vi.fn();
1976 fromId.mockReturnValue({
1977 id: 42,
1978 isDestroyed: () => false,
1979 getType: () => "webview",
1980 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
1981 getURL: () => "https://example.com",
1982 getTitle: () => "Example",
1983 isLoadingMainFrame: () => false,
1984 getZoomFactor: () => 1,
1985 setZoomFactor,
1986 setAudioMuted: vi.fn(),
1987 isCurrentlyAudible: () => false,
1988 on: vi.fn(),
1989 off: vi.fn(),
1990 ipc: { on: vi.fn(), off: vi.fn() },
1991 send: webviewSend,
1992 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
1993 setIgnoreMenuShortcuts: vi.fn(),
1994 setWindowOpenHandler: vi.fn(),
1995 debugger: {
1996 isAttached: () => false,
1997 attach: vi.fn(),
1998 sendCommand: vi.fn(async () => undefined),
1999 on: vi.fn(),
2000 off: vi.fn(),
2001 },
2002 } as never);
2003 const states: PreviewManager.PreviewTabState[] = [];
2004 yield* manager.subscribeStateChanges((_tabId, state) =>
2005 Effect.sync(() => {
2006 states.push(state);
2007 }),
2008 );
2009
2010 yield* manager.createTab("tab_reregister_zoom");
2011 yield* manager.registerWebview("tab_reregister_zoom", 42);
2012 yield* manager.zoomIn("tab_reregister_zoom");
2013 setZoomFactor.mockClear();
2014 const publishedBefore = states.length;
2015
2016 yield* manager.registerWebview("tab_reregister_zoom", 42);
2017
2018 expect(setZoomFactor).toHaveBeenCalledWith(1.1);
2019 expect(states.length).toBe(publishedBefore);
2020 expect(states.at(-1)?.zoomFactor).toBe(1.1);
2021 }),
2022 ),
2023 );
2024
2025 effectIt.effect("re-applies opaque base and color scheme across webview swaps", () =>
2026 withManager((manager) =>
2027 Effect.gen(function* () {
2028 const makeWebContents = (id: number) => {
2029 const sendCommand = vi.fn(async () => undefined);
2030 return {
2031 sendCommand,
2032 wc: {
2033 id,
2034 isDestroyed: () => false,
2035 isDevToolsOpened: () => false,
2036 getType: () => "webview",
2037 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
2038 getURL: () => "https://example.com",
2039 getTitle: () => "Example",
2040 isLoadingMainFrame: () => false,
2041 getZoomFactor: () => 1,
2042 setZoomFactor: vi.fn(),
2043 setAudioMuted: vi.fn(),
2044 isCurrentlyAudible: () => false,
2045 on: vi.fn(),
2046 off: vi.fn(),
2047 ipc: { on: vi.fn(), off: vi.fn() },
2048 send: webviewSend,
2049 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
2050 setIgnoreMenuShortcuts: vi.fn(),
2051 setWindowOpenHandler: vi.fn(),
2052 debugger: {
2053 isAttached: () => false,
2054 attach: vi.fn(),
2055 sendCommand,
2056 on: vi.fn(),
2057 off: vi.fn(),
2058 },
2059 } as never,
2060 };
2061 };
2062 const first = makeWebContents(42);
2063 fromId.mockReturnValue(first.wc);
2064 const states: PreviewManager.PreviewTabState[] = [];
2065
2066 yield* manager.subscribeStateChanges((_tabId, state) =>
2067 Effect.sync(() => {
2068 states.push(state);
2069 }),
2070 );
2071 yield* manager.createTab("tab_scheme");
2072 yield* manager.registerWebview("tab_scheme", 42);
2073 yield* Effect.yieldNow;
2074
2075 // Guests start with a transparent base; dark-scheme pages need an opaque
2076 // one or Chromium skips their dark canvas.
2077 const opaqueBase = {
2078 color: { r: 255, g: 255, b: 255, a: 1 },
2079 };
2080 expect(first.sendCommand).toHaveBeenCalledWith(
2081 "Emulation.setDefaultBackgroundColorOverride",
2082 opaqueBase,
2083 );
2084
2085 yield* manager.setColorScheme("tab_scheme", "dark");
2086
2087 expect(first.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", {
2088 features: [{ name: "prefers-color-scheme", value: "dark" }],
2089 });
2090 expect(states.at(-1)?.colorScheme).toBe("dark");
2091
2092 const replacement = makeWebContents(43);
2093 fromId.mockReturnValue(replacement.wc);
2094 yield* manager.registerWebview("tab_scheme", 43);
2095 yield* Effect.yieldNow;
2096
2097 expect(replacement.sendCommand).toHaveBeenCalledWith(
2098 "Emulation.setDefaultBackgroundColorOverride",
2099 opaqueBase,
2100 );
2101 expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", {
2102 features: [{ name: "prefers-color-scheme", value: "dark" }],
2103 });
2104 expect(states.at(-1)?.colorScheme).toBe("dark");
2105
2106 yield* manager.setColorScheme("tab_scheme", "system");
2107
2108 expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", {
2109 features: [{ name: "prefers-color-scheme", value: "" }],
2110 });
2111 expect(states.at(-1)?.colorScheme).toBe("system");
2112 }),
2113 ),
2114 );
2115
2116 const makeAttachingGuest = (id: number) => {
2117 const listeners = new Map<string, () => void>();
2118 const attach = vi.fn();
2119 const detach = vi.fn();
2120 const sendCommand = vi.fn<(method: string, params?: unknown) => Promise<unknown>>(
2121 async () => undefined,
2122 );
2123 let destroyed = false;
2124 const wc = {
2125 id,
2126 isDestroyed: () => destroyed,
2127 isDevToolsOpened: () => {
2128 // Electron throws from native methods once a WebContents is destroyed.
2129 if (destroyed) throw new TypeError("Object has been destroyed");
2130 return false;
2131 },
2132 getType: () => "webview",
2133 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
2134 getURL: () => "http://localhost:5173/README.md",
2135 getTitle: () => "README.md",
2136 isLoadingMainFrame: () => true,
2137 getZoomFactor: () => 1,
2138 setZoomFactor: vi.fn(),
2139 setAudioMuted: vi.fn(),
2140 isCurrentlyAudible: () => false,
2141 on: vi.fn(),
2142 off: vi.fn(),
2143 once: (event: string, listener: () => void) => {
2144 listeners.set(event, listener);
2145 },
2146 ipc: { on: vi.fn(), off: vi.fn() },
2147 send: webviewSend,
2148 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
2149 setIgnoreMenuShortcuts: vi.fn(),
2150 setWindowOpenHandler: vi.fn(),
2151 debugger: {
2152 isAttached: () => attach.mock.calls.length > detach.mock.calls.length,
2153 attach,
2154 detach,
2155 sendCommand,
2156 on: vi.fn(),
2157 off: vi.fn(),
2158 },
2159 };
2160 return {
2161 wc: wc as unknown as Electron.WebContents,
2162 attach,
2163 detach,
2164 sendCommand,
2165 destroy: () => {
2166 destroyed = true;
2167 listeners.get("destroyed")?.();
2168 },
2169 };
2170 };
2171
2172 effectIt.effect("sets the opaque base as soon as a guest attaches", () =>
2173 withManager((manager) =>
2174 Effect.gen(function* () {
2175 // The tab's first document can paint before the renderer registers the
2176 // guest, so the opaque base has to be the first command on attach.
2177 const claimed = makeAttachingGuest(44);
2178 fromId.mockReturnValue(claimed.wc);
2179 yield* manager.prepareWebview(claimed.wc);
2180 expect(claimed.attach).toHaveBeenCalledTimes(1);
2181 expect(claimed.sendCommand.mock.calls[0]).toEqual([
2182 "Emulation.setDefaultBackgroundColorOverride",
2183 { color: { r: 255, g: 255, b: 255, a: 1 } },
2184 ]);
2185
2186 yield* manager.createTab("tab_early");
2187 yield* manager.registerWebview("tab_early", 44);
2188 yield* manager.setColorScheme("tab_early", "dark");
2189 expect(claimed.attach).toHaveBeenCalledTimes(1);
2190 expect(claimed.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", {
2191 features: [{ name: "prefers-color-scheme", value: "dark" }],
2192 });
2193
2194 // A guest destroyed before any tab claims it releases its session.
2195 const unclaimed = makeAttachingGuest(45);
2196 yield* manager.prepareWebview(unclaimed.wc);
2197 expect(unclaimed.attach).toHaveBeenCalledTimes(1);
2198 unclaimed.destroy();
2199 yield* Effect.yieldNow;
2200 expect(unclaimed.detach).toHaveBeenCalledTimes(1);
2201 expect(claimed.detach).not.toHaveBeenCalled();
2202 }),
2203 ),
2204 );
2205
2206 effectIt.effect("skips a guest destroyed while another guest's session opens", () =>
2207 withManager((manager) =>
2208 Effect.gen(function* () {
2209 const slow = makeAttachingGuest(46);
2210 let releaseSlow = () => {};
2211 const slowCommand = new Promise<void>((resolve) => {
2212 releaseSlow = resolve;
2213 });
2214 slow.sendCommand.mockImplementation(() => slowCommand);
2215 const queued = makeAttachingGuest(47);
2216
2217 const slowFiber = yield* manager
2218 .prepareWebview(slow.wc)
2219 .pipe(Effect.forkChild({ startImmediately: true }));
2220 const queuedFiber = yield* manager
2221 .prepareWebview(queued.wc)
2222 .pipe(Effect.forkChild({ startImmediately: true }));
2223 queued.destroy();
2224 releaseSlow();
2225
2226 expect(Exit.isSuccess(yield* Fiber.await(slowFiber))).toBe(true);
2227 expect(Exit.isSuccess(yield* Fiber.await(queuedFiber))).toBe(true);
2228 expect(slow.attach).toHaveBeenCalledTimes(1);
2229 expect(queued.attach).not.toHaveBeenCalled();
2230 }),
2231 ),
2232 );
2233
2234 const makeAudioWebContents = (id: number) => {
2235 const listeners = new Map<string, (...args: never[]) => void>();
2236 const setAudioMuted = vi.fn();
2237 let audible = false;
2238 let audibleAfterFirstRead = false;
2239 let audibleReads = 0;
2240 return {
2241 setAudioMuted,
2242 emitAudioState: (next: boolean) => {
2243 audible = next;
2244 listeners.get("audio-state-changed")?.({ audible: next } as never);
2245 },
2246 /**
2247 * Starts playing between the attach-time read and the post-attach
2248 * reconcile, without a delivered event — the window in which
2249 * audio-state-changed fires against a guest the tab does not own yet.
2250 */
2251 startPlayingAfterFirstRead: () => {
2252 audibleAfterFirstRead = true;
2253 },
2254 wc: {
2255 id,
2256 isDestroyed: () => false,
2257 isDevToolsOpened: () => false,
2258 getType: () => "webview",
2259 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
2260 getURL: () => "https://example.com",
2261 getTitle: () => "Example",
2262 isLoadingMainFrame: () => false,
2263 getZoomFactor: () => 1,
2264 setZoomFactor: vi.fn(),
2265 setAudioMuted,
2266 isCurrentlyAudible: () => {
2267 audibleReads += 1;
2268 if (audibleAfterFirstRead && audibleReads > 1) return true;
2269 return audible;
2270 },
2271 loadURL: vi.fn(async () => undefined),
2272 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
2273 listeners.set(event, listener);
2274 }),
2275 off: vi.fn((event: string) => {
2276 listeners.delete(event);
2277 }),
2278 ipc: { on: vi.fn(), off: vi.fn() },
2279 send: webviewSend,
2280 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
2281 setIgnoreMenuShortcuts: vi.fn(),
2282 setWindowOpenHandler: vi.fn(),
2283 debugger: {
2284 isAttached: () => false,
2285 attach: vi.fn(),
2286 sendCommand: vi.fn(async () => undefined),
2287 on: vi.fn(),
2288 off: vi.fn(),
2289 },
2290 } as never,
2291 };
2292 };
2293
2294 effectIt.effect("mutes the guest and re-applies the mute across webview swaps", () =>
2295 withManager((manager) =>
2296 Effect.gen(function* () {
2297 const first = makeAudioWebContents(42);
2298 fromId.mockReturnValue(first.wc);
2299 const states: PreviewManager.PreviewTabState[] = [];
2300
2301 yield* manager.subscribeStateChanges((_tabId, state) =>
2302 Effect.sync(() => {
2303 states.push(state);
2304 }),
2305 );
2306 yield* manager.createTab("tab_audio");
2307 yield* manager.registerWebview("tab_audio", 42);
2308 yield* Effect.yieldNow;
2309
2310 expect(states.at(-1)?.audioMuted).toBe(false);
2311
2312 yield* manager.setAudioMuted("tab_audio", true);
2313
2314 expect(first.setAudioMuted).toHaveBeenCalledWith(true);
2315 expect(states.at(-1)?.audioMuted).toBe(true);
2316
2317 const replacement = makeAudioWebContents(43);
2318 fromId.mockReturnValue(replacement.wc);
2319 yield* manager.registerWebview("tab_audio", 43);
2320 yield* Effect.yieldNow;
2321
2322 expect(replacement.setAudioMuted).toHaveBeenCalledWith(true);
2323 expect(states.at(-1)?.audioMuted).toBe(true);
2324
2325 yield* manager.setAudioMuted("tab_audio", false);
2326
2327 expect(replacement.setAudioMuted).toHaveBeenLastCalledWith(false);
2328 expect(states.at(-1)?.audioMuted).toBe(false);
2329 }),
2330 ),
2331 );
2332
2333 effectIt.effect("fails and rolls back when the guest refuses a mute", () =>
2334 withManager((manager) =>
2335 Effect.gen(function* () {
2336 const guest = makeAudioWebContents(42);
2337 fromId.mockReturnValue(guest.wc);
2338 const states: PreviewManager.PreviewTabState[] = [];
2339
2340 yield* manager.subscribeStateChanges((_tabId, state) =>
2341 Effect.sync(() => {
2342 states.push(state);
2343 }),
2344 );
2345 yield* manager.createTab("tab_audio_fail");
2346 yield* manager.registerWebview("tab_audio_fail", 42);
2347 yield* Effect.yieldNow;
2348
2349 guest.setAudioMuted.mockImplementationOnce(() => {
2350 throw new Error("guest refused");
2351 });
2352 const exit = yield* manager.setAudioMuted("tab_audio_fail", true).pipe(Effect.exit);
2353
2354 // Reporting success would draw the tab as muted while it keeps playing.
2355 expect(Exit.isFailure(exit)).toBe(true);
2356 expect(states.at(-1)?.audioMuted).toBe(false);
2357 }),
2358 ),
2359 );
2360
2361 effectIt.effect("still registers a guest that refuses the mute reassert", () =>
2362 withManager((manager) =>
2363 Effect.gen(function* () {
2364 const first = makeAudioWebContents(42);
2365 fromId.mockReturnValue(first.wc);
2366 yield* manager.createTab("tab_audio_attach_fail");
2367 yield* manager.registerWebview("tab_audio_attach_fail", 42);
2368 yield* Effect.yieldNow;
2369 yield* manager.setAudioMuted("tab_audio_attach_fail", true);
2370
2371 const replacement = makeAudioWebContents(43);
2372 // Fails the post-attach settle, not the pre-publish apply.
2373 replacement.setAudioMuted.mockImplementationOnce(() => undefined);
2374 replacement.setAudioMuted.mockImplementationOnce(() => {
2375 throw new Error("guest went away");
2376 });
2377 fromId.mockReturnValue(replacement.wc);
2378
2379 // Reconciliation is best-effort: a guest dying mid-attach must not fail
2380 // the registration it was attaching for.
2381 const exit = yield* manager.registerWebview("tab_audio_attach_fail", 43).pipe(Effect.exit);
2382 expect(Exit.isSuccess(exit)).toBe(true);
2383 }),
2384 ),
2385 );
2386
2387 effectIt.effect("reconciles audibility that changed while the guest attached", () =>
2388 withManager((manager) =>
2389 Effect.gen(function* () {
2390 const guest = makeAudioWebContents(42);
2391 guest.startPlayingAfterFirstRead();
2392 fromId.mockReturnValue(guest.wc);
2393 const states: PreviewManager.PreviewTabState[] = [];
2394
2395 yield* manager.subscribeStateChanges((_tabId, state) =>
2396 Effect.sync(() => {
2397 states.push(state);
2398 }),
2399 );
2400 yield* manager.createTab("tab_audio_window");
2401 yield* manager.registerWebview("tab_audio_window", 42);
2402 yield* Effect.yieldNow;
2403
2404 // audio-state-changed for this transition was dropped: it fired before
2405 // the tab owned the guest. Without a post-attach reconcile the icon
2406 // stays wrong until the next real transition, which may never come.
2407 expect(states.at(-1)?.audible).toBe(true);
2408 }),
2409 ),
2410 );
2411
2412 effectIt.effect("publishes audibility transitions and drops repeats", () =>
2413 withManager((manager) =>
2414 Effect.gen(function* () {
2415 const guest = makeAudioWebContents(42);
2416 fromId.mockReturnValue(guest.wc);
2417 const states: PreviewManager.PreviewTabState[] = [];
2418
2419 yield* manager.subscribeStateChanges((_tabId, state) =>
2420 Effect.sync(() => {
2421 states.push(state);
2422 }),
2423 );
2424 yield* manager.createTab("tab_audible");
2425 yield* manager.registerWebview("tab_audible", 42);
2426 yield* Effect.yieldNow;
2427
2428 expect(states.at(-1)?.audible).toBe(false);
2429
2430 guest.emitAudioState(true);
2431 yield* Effect.yieldNow;
2432 expect(states.at(-1)?.audible).toBe(true);
2433
2434 // Chromium re-emits per media element; only real transitions publish.
2435 const publishedAfterFirst = states.length;
2436 guest.emitAudioState(true);
2437 yield* Effect.yieldNow;
2438 expect(states.length).toBe(publishedAfterFirst);
2439
2440 guest.emitAudioState(false);
2441 yield* Effect.yieldNow;
2442 expect(states.at(-1)?.audible).toBe(false);
2443 expect(states.length).toBeGreaterThan(publishedAfterFirst);
2444 }),
2445 ),
2446 );
2447
2448 effectIt.effect("ignores audio state from a replaced guest", () =>
2449 withManager((manager) =>
2450 Effect.gen(function* () {
2451 const first = makeAudioWebContents(42);
2452 fromId.mockReturnValue(first.wc);
2453 const states: PreviewManager.PreviewTabState[] = [];
2454
2455 yield* manager.subscribeStateChanges((_tabId, state) =>
2456 Effect.sync(() => {
2457 states.push(state);
2458 }),
2459 );
2460 yield* manager.createTab("tab_audio_stale");
2461 yield* manager.registerWebview("tab_audio_stale", 42);
2462 yield* Effect.yieldNow;
2463
2464 const replacement = makeAudioWebContents(43);
2465 fromId.mockReturnValue(replacement.wc);
2466 yield* manager.registerWebview("tab_audio_stale", 43);
2467 yield* Effect.yieldNow;
2468
2469 const publishedBefore = states.length;
2470 first.emitAudioState(true);
2471 yield* Effect.yieldNow;
2472
2473 expect(states.length).toBe(publishedBefore);
2474 expect(states.at(-1)?.audible).toBe(false);
2475 }),
2476 ),
2477 );
2478
2479 effectIt.effect("carries mute and audibility across navigation", () =>
2480 withManager((manager) =>
2481 Effect.gen(function* () {
2482 const guest = makeAudioWebContents(42);
2483 fromId.mockReturnValue(guest.wc);
2484 const states: PreviewManager.PreviewTabState[] = [];
2485
2486 yield* manager.subscribeStateChanges((_tabId, state) =>
2487 Effect.sync(() => {
2488 states.push(state);
2489 }),
2490 );
2491 yield* manager.createTab("tab_audio_nav");
2492 yield* manager.registerWebview("tab_audio_nav", 42);
2493 yield* Effect.yieldNow;
2494
2495 yield* manager.setAudioMuted("tab_audio_nav", true);
2496 guest.emitAudioState(true);
2497 yield* Effect.yieldNow;
2498 expect(states.at(-1)?.audible).toBe(true);
2499
2500 yield* manager.navigate("tab_audio_nav", "https://example.com/next");
2501 yield* Effect.yieldNow;
2502
2503 // navigate runs before loadURL swaps the document, so the old page can
2504 // still be playing. Dropping audibility here would lose the speaker
2505 // with no transition left to bring it back.
2506 expect(states.at(-1)?.audioMuted).toBe(true);
2507 expect(states.at(-1)?.audible).toBe(true);
2508
2509 // Chromium reports the real stop once the new document takes over.
2510 guest.emitAudioState(false);
2511 yield* Effect.yieldNow;
2512 expect(states.at(-1)?.audible).toBe(false);
2513 }),
2514 ),
2515 );
2516
2517 effectIt.effect("blocks late webview and capture starts during tab close", () =>
2518 withManager((manager) =>
2519 Effect.gen(function* () {
2520 const capturePage = vi.fn(async () => ({
2521 toJPEG: () => Buffer.from("close-race-frame"),
2522 getSize: () => ({ width: 1280, height: 720 }),
2523 }));
2524 const firstWebContents = makeTestPreviewWebContents(capturePage, 42);
2525 const replacementWebContents = makeTestPreviewWebContents(capturePage, 43);
2526 const replacementListenerSpies = replacementWebContents as unknown as {
2527 readonly on: ReturnType<typeof vi.fn>;
2528 readonly off: ReturnType<typeof vi.fn>;
2529 readonly ipc: { readonly off: ReturnType<typeof vi.fn> };
2530 };
2531 fromId.mockImplementation((id) => {
2532 if (id === 42) return firstWebContents;
2533 if (id === 43) return replacementWebContents;
2534 return null;
2535 });
2536 const { pictureInPictureWindow } = makeTestPictureInPictureWindow();
2537 browserWindowConstructor.mockImplementation(function () {
2538 return pictureInPictureWindow;
2539 });
2540
2541 yield* manager.createTab("tab_close_register_race");
2542 yield* manager.registerWebview("tab_close_register_race", 42);
2543 yield* manager.openPictureInPicture("tab_close_register_race");
2544
2545 const closeCleanupPaused = yield* Deferred.make<void>();
2546 const continueCloseCleanup = yield* Deferred.make<void>();
2547 yield* manager.subscribeStateChanges((_tabId, state) =>
2548 !state.pictureInPicture && state.webContentsId === 42
2549 ? Deferred.succeed(closeCleanupPaused, undefined).pipe(
2550 Effect.andThen(Deferred.await(continueCloseCleanup)),
2551 )
2552 : Effect.void,
2553 );
2554
2555 const closeFiber = yield* manager
2556 .closeTab("tab_close_register_race")
2557 .pipe(Effect.forkChild({ startImmediately: true }));
2558 yield* Deferred.await(closeCleanupPaused);
2559 const recreateFiber = yield* manager
2560 .createTab("tab_close_register_race")
2561 .pipe(Effect.forkChild({ startImmediately: true }));
2562 const registrationFiber = yield* manager
2563 .registerWebview("tab_close_register_race", 43)
2564 .pipe(Effect.forkChild({ startImmediately: true }));
2565 yield* Effect.yieldNow;
2566 expect(replacementListenerSpies.on).not.toHaveBeenCalled();
2567 yield* manager.closeTab("tab_close_register_race");
2568 const recordingExit = yield* Effect.exit(manager.startRecording("tab_close_register_race"));
2569 yield* Deferred.succeed(continueCloseCleanup, undefined);
2570 yield* Fiber.join(closeFiber);
2571 const recreated = yield* Fiber.join(recreateFiber);
2572 const registrationExit = yield* Fiber.await(registrationFiber);
2573
2574 for (const exit of [registrationExit, recordingExit] as ReadonlyArray<
2575 Exit.Exit<unknown, PreviewManager.PreviewManagerError>
2576 >) {
2577 expect(Exit.isFailure(exit)).toBe(true);
2578 if (Exit.isSuccess(exit)) continue;
2579 expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({
2580 _tag: "PreviewTabNotFoundError",
2581 tabId: "tab_close_register_race",
2582 });
2583 }
2584 expect(replacementListenerSpies.on).not.toHaveBeenCalled();
2585 expect(replacementListenerSpies.off).not.toHaveBeenCalled();
2586 expect(replacementListenerSpies.ipc.off).not.toHaveBeenCalled();
2587 expect(capturePage).toHaveBeenCalledOnce();
2588 expect(recreated.webContentsId).toBeNull();
2589 }),
2590 ),
2591 );
2592
2593 effectIt.effect("keeps a main-frame load failure visible until a retry starts", () =>
2594 withManager((manager) =>
2595 Effect.gen(function* () {
2596 const url = "http://localhost:5733/";
2597 let loading = false;
2598 const listeners = new Map<string, (...args: unknown[]) => void>();
2599 fromId.mockReturnValue({
2600 id: 42,
2601 isDestroyed: () => false,
2602 getType: () => "webview",
2603 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
2604 getURL: () => url,
2605 getTitle: () => "localhost:5733",
2606 isLoadingMainFrame: () => loading,
2607 getZoomFactor: () => 1,
2608 setZoomFactor: vi.fn(),
2609 setAudioMuted: vi.fn(),
2610 isCurrentlyAudible: () => false,
2611 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
2612 listeners.set(event, listener);
2613 }),
2614 off: vi.fn(),
2615 ipc: { on: vi.fn(), off: vi.fn() },
2616 send: webviewSend,
2617 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
2618 setIgnoreMenuShortcuts: vi.fn(),
2619 setWindowOpenHandler: vi.fn(),
2620 debugger: {
2621 isAttached: () => false,
2622 attach: vi.fn(),
2623 sendCommand: vi.fn(async () => undefined),
2624 on: vi.fn(),
2625 off: vi.fn(),
2626 },
2627 } as never);
2628 const statuses: PreviewManager.PreviewNavStatus[] = [];
2629
2630 yield* manager.subscribeStateChanges((_tabId, state) =>
2631 Effect.sync(() => {
2632 statuses.push(state.navStatus);
2633 }),
2634 );
2635 yield* manager.createTab("tab_failed");
2636 yield* manager.registerWebview("tab_failed", 42);
2637
2638 listeners.get("did-fail-load")?.(
2639 {},
2640 -105,
2641 "ERR_NAME_NOT_RESOLVED",
2642 "https://missing-frame.example/",
2643 false,
2644 );
2645 yield* Effect.yieldNow;
2646 expect(statuses.at(-1)?.kind).toBe("Success");
2647
2648 loading = true;
2649 listeners.get("did-start-loading")?.();
2650 yield* Effect.yieldNow;
2651 expect(statuses.at(-1)?.kind).toBe("Loading");
2652
2653 loading = false;
2654 listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true);
2655 listeners.get("did-stop-loading")?.();
2656 listeners.get("page-title-updated")?.();
2657 yield* Effect.yieldNow;
2658 expect(statuses.at(-1)).toEqual({
2659 kind: "LoadFailed",
2660 url,
2661 title: "localhost:5733",
2662 code: -102,
2663 description: "ERR_CONNECTION_REFUSED",
2664 });
2665
2666 loading = true;
2667 listeners.get("did-start-loading")?.();
2668 yield* Effect.yieldNow;
2669 expect(statuses.at(-1)?.kind).toBe("Loading");
2670
2671 loading = false;
2672 listeners.get("did-stop-loading")?.();
2673 yield* Effect.yieldNow;
2674 expect(statuses.at(-1)?.kind).toBe("Success");
2675
2676 listeners.get("did-fail-load")?.({}, -102, "ERR_CONNECTION_REFUSED", url, true);
2677 yield* Effect.yieldNow;
2678 expect(statuses.at(-1)?.kind).toBe("LoadFailed");
2679
2680 listeners.get("did-navigate")?.();
2681 yield* Effect.yieldNow;
2682 expect(statuses.at(-1)?.kind).toBe("Success");
2683 }),
2684 ),
2685 );
2686
2687 effectIt.effect("ignores a late cross-origin iframe load once the page has loaded", () =>
2688 withManager((manager) =>
2689 Effect.gen(function* () {
2690 const preview = makeFaviconWebContents();
2691 // The frame tree is still loading the iframe; the main frame is done.
2692 Object.assign(preview.webContents, { isLoading: () => true });
2693 fromId.mockReturnValue(preview.webContents);
2694 const states: PreviewManager.PreviewTabState[] = [];
2695 yield* manager.subscribeStateChanges((_tabId, state) =>
2696 Effect.sync(() => void states.push(state)),
2697 );
2698 yield* manager.createTab("tab_iframe");
2699 yield* manager.registerWebview("tab_iframe", 42);
2700
2701 preview.listeners.get("did-start-loading")?.();
2702 preview.listeners.get("did-stop-loading")?.();
2703 yield* settle(() => states.length > 2);
2704 expect(states.at(-1)?.navStatus.kind).toBe("Success");
2705 }),
2706 ),
2707 );
2708
2709 effectIt.effect("captures a PNG screenshot into browser artifacts", () =>
2710 withManager((manager) =>
2711 Effect.gen(function* () {
2712 const png = Buffer.from("preview-png");
2713 const capturePage = vi.fn(async () => ({ toPNG: () => png }));
2714 const listeners = new Map<string, (...args: never[]) => void>();
2715 fromId.mockReturnValue({
2716 id: 42,
2717 isDestroyed: () => false,
2718 getType: () => "webview",
2719 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
2720 getURL: () => "https://example.com:8443/path?query=value",
2721 getTitle: () => "Example",
2722 isLoadingMainFrame: () => false,
2723 getZoomFactor: () => 1,
2724 setZoomFactor: vi.fn(),
2725 setAudioMuted: vi.fn(),
2726 isCurrentlyAudible: () => false,
2727 on: vi.fn((event: string, listener: (...args: never[]) => void) => {
2728 listeners.set(event, listener);
2729 }),
2730 off: vi.fn(),
2731 ipc: { on: vi.fn(), off: vi.fn() },
2732 send: webviewSend,
2733 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
2734 setIgnoreMenuShortcuts: vi.fn(),
2735 setWindowOpenHandler: vi.fn(),
2736 debugger: {
2737 isAttached: () => false,
2738 attach: vi.fn(),
2739 sendCommand: vi.fn(async () => undefined),
2740 on: vi.fn(),
2741 off: vi.fn(),
2742 },
2743 capturePage,
2744 } as never);
2745
2746 yield* manager.createTab("tab_1");
2747 yield* manager.registerWebview("tab_1", 42);
2748
2749 expect(webviewSend).toHaveBeenCalledWith(
2750 "preview:annotation-theme",
2751 expect.objectContaining({
2752 colorScheme: "light",
2753 primary: "oklch(0.488 0.217 264)",
2754 }),
2755 );
2756
2757 const artifact = yield* manager.captureScreenshot("tab_1");
2758
2759 expect(capturePage).toHaveBeenCalledOnce();
2760 expect(mkdir).toHaveBeenCalledWith("/tmp/t3/dev/browser-artifacts");
2761 expect(writeFile).toHaveBeenCalledWith(artifact.path, png);
2762 expect(artifact).toMatchObject({
2763 tabId: "tab_1",
2764 mimeType: "image/png",
2765 sizeBytes: png.byteLength,
2766 });
2767 expect(artifact.path).toMatch(
2768 /\/browser-artifacts\/browser-screenshot-example-com-[^.]+\.png$/,
2769 );
2770
2771 // Chromium reports UnknownVizError while a hidden guest warms its
2772 // first compositor frame, so transient failures are retried.
2773 capturePage.mockClear();
2774 capturePage.mockRejectedValueOnce(new Error("UnknownVizError"));
2775 capturePage.mockRejectedValueOnce(new Error("UnknownVizError"));
2776 const retriedFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe(
2777 Effect.forkChild({ startImmediately: true }),
2778 );
2779 yield* TestClock.adjust(1_000);
2780 const retriedExit = yield* Fiber.join(retriedFiber);
2781 expect(Exit.isSuccess(retriedExit)).toBe(true);
2782 expect(capturePage).toHaveBeenCalledTimes(3);
2783
2784 // A persistent failure still surfaces once the retries are spent.
2785 capturePage.mockClear();
2786 const captureCause = new Error("capture failed");
2787 capturePage.mockRejectedValue(captureCause);
2788 const failingFiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe(
2789 Effect.forkChild({ startImmediately: true }),
2790 );
2791 yield* TestClock.adjust(1_000);
2792 const exit = yield* Fiber.join(failingFiber);
2793 expect(Exit.isFailure(exit)).toBe(true);
2794 expect(capturePage).toHaveBeenCalledTimes(3);
2795 if (Exit.isSuccess(exit)) return;
2796 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
2797 expect(error).toMatchObject({
2798 _tag: "PreviewOperationError",
2799 operation: "captureScreenshot.capturePage",
2800 tabId: "tab_1",
2801 webContentsId: 42,
2802 cause: captureCause,
2803 });
2804 }),
2805 ),
2806 );
2807
2808 effectIt.effect("preserves both screenshots of the same site in the same millisecond", () =>
2809 withManager((manager) =>
2810 Effect.gen(function* () {
2811 const firstPng = Buffer.from("first-preview-png");
2812 const secondPng = Buffer.from("second-preview-png");
2813 const pngImage = (png: Buffer) => ({
2814 toPNG: () => png,
2815 toJPEG: () => png,
2816 getSize: () => ({ width: 1280, height: 720 }),
2817 });
2818 const capturePage = vi
2819 .fn<() => Promise<ReturnType<typeof pngImage>>>()
2820 .mockResolvedValueOnce(pngImage(firstPng))
2821 .mockResolvedValueOnce(pngImage(secondPng));
2822 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2823 yield* manager.createTab("tab_1");
2824 yield* manager.registerWebview("tab_1", 42);
2825 // it.effect keeps TestClock fixed until explicitly advanced.
2826 const first = yield* manager.captureScreenshot("tab_1");
2827 const second = yield* manager.captureScreenshot("tab_1");
2828
2829 expect(first.createdAt).toBe(second.createdAt);
2830 expect(first.id).not.toBe(second.id);
2831 expect(first.path).not.toBe(second.path);
2832 expect(first.id).toMatch(/^browser-screenshot-example-com-[a-z0-9]+-[0-9a-f]{8}$/);
2833 expect(second.id).toMatch(/^browser-screenshot-example-com-[a-z0-9]+-[0-9a-f]{8}$/);
2834 expect(writeFile.mock.calls).toEqual([
2835 [first.path, firstPng],
2836 [second.path, secondPng],
2837 ]);
2838 }),
2839 ),
2840 );
2841
2842 effectIt.effect("keeps every recorded guest unthrottled until its frame capture stops", () =>
2843 withManager((manager) =>
2844 Effect.gen(function* () {
2845 const setBackgroundThrottling = vi.fn();
2846 const capturePage = vi.fn(async () => ({
2847 toJPEG: () => Buffer.from("recording-frame"),
2848 getSize: () => ({ width: 1280, height: 720 }),
2849 }));
2850 const host = makeTestHostWebContents();
2851 const firstWebContents = makeTestPreviewWebContents(capturePage, 41, host);
2852 const secondWebContents = makeTestPreviewWebContents(capturePage, 42, host);
2853 const webContentsById = new Map([
2854 [41, firstWebContents],
2855 [42, secondWebContents],
2856 ]);
2857 fromId.mockImplementation((id) =>
2858 id === undefined ? null : (webContentsById.get(id) ?? null),
2859 );
2860
2861 yield* manager.createTab("tab_capture_throttling_1");
2862 yield* manager.createTab("tab_capture_throttling_2");
2863 yield* manager.registerWebview("tab_capture_throttling_1", 41);
2864 yield* manager.registerWebview("tab_capture_throttling_2", 42);
2865 yield* manager.setMainWindow({
2866 isDestroyed: () => false,
2867 once: vi.fn(),
2868 webContents: { setBackgroundThrottling },
2869 } as never);
2870
2871 yield* manager.startRecording("tab_capture_throttling_1");
2872 // The first renderer takes its grant, freeing the arm slot for the second tab.
2873 host.displayMediaHandler()?.({ frame: host.mainFrame }, () => {});
2874 yield* manager.startRecording("tab_capture_throttling_2");
2875 expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
2876 expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]);
2877 expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]);
2878
2879 yield* manager.stopRecording("tab_capture_throttling_1");
2880 expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
2881 expect(firstWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]);
2882 expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]);
2883
2884 yield* manager.stopRecording("tab_capture_throttling_2");
2885 expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true]]);
2886 expect(secondWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]);
2887 }),
2888 ),
2889 );
2890
2891 effectIt.effect("does not commit failed starts and retries throttle restoration", () =>
2892 withManager((manager) =>
2893 Effect.gen(function* () {
2894 const setBackgroundThrottling = vi.fn<(enabled: boolean) => void>();
2895 const capturePage = vi.fn(async () => ({
2896 toJPEG: () => Buffer.from("recording-frame"),
2897 getSize: () => ({ width: 1280, height: 720 }),
2898 }));
2899 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2900
2901 yield* manager.createTab("tab_capture_throttling_failure");
2902 yield* manager.registerWebview("tab_capture_throttling_failure", 42);
2903 yield* manager.setMainWindow({
2904 isDestroyed: () => false,
2905 once: vi.fn(),
2906 webContents: { setBackgroundThrottling },
2907 } as never);
2908
2909 setBackgroundThrottling.mockImplementationOnce(() => {
2910 throw new Error("start throttling update failed");
2911 });
2912 const failedStart = yield* Effect.exit(
2913 manager.startRecording("tab_capture_throttling_failure"),
2914 );
2915 expect(Exit.isFailure(failedStart)).toBe(true);
2916
2917 yield* manager.startRecording("tab_capture_throttling_failure");
2918 expect(setBackgroundThrottling.mock.calls).toEqual([[false], [false]]);
2919
2920 setBackgroundThrottling.mockImplementationOnce(() => {
2921 throw new Error("stop throttling update failed");
2922 });
2923 yield* manager.stopRecording("tab_capture_throttling_failure");
2924 expect(setBackgroundThrottling.mock.calls).toEqual([[false], [false], [true], [true]]);
2925
2926 yield* manager.startRecording("tab_capture_throttling_failure");
2927 yield* manager.stopRecording("tab_capture_throttling_failure");
2928 expect(setBackgroundThrottling.mock.calls).toEqual([
2929 [false],
2930 [false],
2931 [true],
2932 [true],
2933 [false],
2934 [true],
2935 ]);
2936 }),
2937 ),
2938 );
2939
2940 effectIt.effect("rolls back window throttling when a recorded guest cannot be unthrottled", () =>
2941 withManager((manager) =>
2942 Effect.gen(function* () {
2943 const setWindowBackgroundThrottling = vi.fn();
2944 const capturePage = vi.fn(async () => ({
2945 toJPEG: () => Buffer.from("recording-frame"),
2946 getSize: () => ({ width: 1280, height: 720 }),
2947 }));
2948 const wc = makeTestPreviewWebContents(capturePage);
2949 fromId.mockReturnValue(wc);
2950
2951 yield* manager.createTab("tab_guest_throttling_failure");
2952 yield* manager.registerWebview("tab_guest_throttling_failure", 42);
2953 yield* manager.setMainWindow({
2954 isDestroyed: () => false,
2955 once: vi.fn(),
2956 webContents: { setBackgroundThrottling: setWindowBackgroundThrottling },
2957 } as never);
2958
2959 wc.setBackgroundThrottling.mockImplementationOnce(() => {
2960 throw new Error("guest throttling update failed");
2961 });
2962 const failedStart = yield* Effect.exit(
2963 manager.startRecording("tab_guest_throttling_failure"),
2964 );
2965 expect(Exit.isFailure(failedStart)).toBe(true);
2966 expect(setWindowBackgroundThrottling.mock.calls).toEqual([[false], [true]]);
2967 expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false]]);
2968
2969 yield* manager.startRecording("tab_guest_throttling_failure");
2970 yield* manager.stopRecording("tab_guest_throttling_failure");
2971 expect(setWindowBackgroundThrottling.mock.calls).toEqual([
2972 [false],
2973 [true],
2974 [false],
2975 [true],
2976 ]);
2977 expect(wc.setBackgroundThrottling.mock.calls).toEqual([[false], [false], [true]]);
2978 }),
2979 ),
2980 );
2981
2982 effectIt.effect("does not publish a replacement window when capture reconciliation fails", () =>
2983 withManager((manager) =>
2984 Effect.gen(function* () {
2985 const setBackgroundThrottling = vi.fn(() => {
2986 throw new Error("replacement throttling update failed");
2987 });
2988 const capturePage = vi.fn(async () => ({
2989 toJPEG: () => Buffer.from("recording-frame"),
2990 getSize: () => ({ width: 1280, height: 720 }),
2991 }));
2992 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
2993
2994 yield* manager.createTab("tab_capture_replacement_failure");
2995 yield* manager.registerWebview("tab_capture_replacement_failure", 42);
2996 yield* manager.startRecording("tab_capture_replacement_failure");
2997
2998 const failedReplacement = yield* Effect.exit(
2999 manager.setMainWindow({
3000 isDestroyed: () => false,
3001 once: vi.fn(),
3002 webContents: { setBackgroundThrottling },
3003 } as never),
3004 );
3005 expect(Exit.isFailure(failedReplacement)).toBe(true);
3006
3007 yield* manager.stopRecording("tab_capture_replacement_failure");
3008 expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
3009 }),
3010 ),
3011 );
3012
3013 effectIt.effect("ignores close events from replaced main windows", () =>
3014 withManager((manager) =>
3015 Effect.gen(function* () {
3016 let closeFirstWindow: (() => void) | undefined;
3017 const firstWindowThrottling = vi.fn();
3018 const replacementWindowThrottling = vi.fn();
3019 const capturePage = vi.fn(async () => ({
3020 toJPEG: () => Buffer.from("recording-frame"),
3021 getSize: () => ({ width: 1280, height: 720 }),
3022 }));
3023 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
3024
3025 yield* manager.createTab("tab_replaced_window_close");
3026 yield* manager.registerWebview("tab_replaced_window_close", 42);
3027 yield* manager.setMainWindow({
3028 isDestroyed: () => false,
3029 once: vi.fn((event: string, listener: () => void) => {
3030 if (event === "closed") closeFirstWindow = listener;
3031 }),
3032 webContents: { setBackgroundThrottling: firstWindowThrottling },
3033 } as never);
3034 yield* manager.setMainWindow({
3035 isDestroyed: () => false,
3036 once: vi.fn(),
3037 webContents: { setBackgroundThrottling: replacementWindowThrottling },
3038 } as never);
3039
3040 closeFirstWindow?.();
3041 yield* manager.startRecording("tab_replaced_window_close");
3042 expect(firstWindowThrottling).not.toHaveBeenCalled();
3043 expect(replacementWindowThrottling.mock.calls).toEqual([[false]]);
3044 yield* manager.stopRecording("tab_replaced_window_close");
3045 expect(replacementWindowThrottling.mock.calls).toEqual([[false], [true]]);
3046 }),
3047 ),
3048 );
3049
3050 effectIt.effect("releases frame capture when the main window closes", () =>
3051 withManager((manager) =>
3052 Effect.gen(function* () {
3053 let closeMainWindow: (() => void) | undefined;
3054 const firstWindowThrottling = vi.fn();
3055 const replacementWindowThrottling = vi.fn();
3056 const capturePage = vi.fn(async () => ({
3057 toJPEG: () => Buffer.from("recording-frame"),
3058 getSize: () => ({ width: 1280, height: 720 }),
3059 }));
3060 const host = makeTestHostWebContents();
3061 const webContentsById = new Map([
3062 [42, makeTestPreviewWebContents(capturePage, 42, host)],
3063 [43, makeTestPreviewWebContents(capturePage, 43, host)],
3064 ]);
3065 fromId.mockImplementation((id) =>
3066 id === undefined ? null : (webContentsById.get(id) ?? null),
3067 );
3068
3069 yield* manager.createTab("tab_window_close_recording");
3070 yield* manager.createTab("tab_window_close_race");
3071 yield* manager.registerWebview("tab_window_close_recording", 42);
3072 yield* manager.registerWebview("tab_window_close_race", 43);
3073 yield* manager.setMainWindow({
3074 isDestroyed: () => false,
3075 once: vi.fn((event: string, listener: () => void) => {
3076 if (event === "closed") closeMainWindow = listener;
3077 }),
3078 webContents: { setBackgroundThrottling: firstWindowThrottling },
3079 } as never);
3080 yield* manager.startRecording("tab_window_close_recording");
3081 expect(firstWindowThrottling.mock.calls).toEqual([[false]]);
3082
3083 closeMainWindow?.();
3084 const racedStart = yield* Effect.exit(manager.startRecording("tab_window_close_race"));
3085 expect(Exit.isFailure(racedStart)).toBe(true);
3086 if (Exit.isFailure(racedStart)) {
3087 expect(Option.getOrThrow(Cause.findErrorOption(racedStart.cause))).toMatchObject({
3088 _tag: "PreviewMainWindowClosedError",
3089 tabId: "tab_window_close_race",
3090 });
3091 }
3092 yield* Effect.yieldNow;
3093 yield* Effect.yieldNow;
3094
3095 const grants: Array<{ video?: unknown }> = [];
3096 host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => grants.push(value));
3097 expect(grants).toEqual([{}]);
3098
3099 yield* manager.setMainWindow({
3100 isDestroyed: () => false,
3101 once: vi.fn(),
3102 webContents: { setBackgroundThrottling: replacementWindowThrottling },
3103 } as never);
3104 expect(replacementWindowThrottling).not.toHaveBeenCalled();
3105 }),
3106 ),
3107 );
3108
3109 effectIt.effect("does not arm recording after the main window closes during warmup", () =>
3110 withManager((manager) =>
3111 Effect.gen(function* () {
3112 let closeMainWindow: (() => void) | undefined;
3113 let finishWarmup!: (image: TestCapturedPreviewImage) => void;
3114 let markWarmupStarted!: () => void;
3115 const warmupStarted = new Promise<void>((resolve) => {
3116 markWarmupStarted = resolve;
3117 });
3118 const capturedImage = {
3119 toJPEG: () => Buffer.from("recording-frame"),
3120 getSize: () => ({ width: 1280, height: 720 }),
3121 };
3122 const capturePage = vi.fn(
3123 () =>
3124 new Promise<TestCapturedPreviewImage>((resolve) => {
3125 markWarmupStarted();
3126 finishWarmup = resolve;
3127 }),
3128 );
3129 const host = makeTestHostWebContents();
3130 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42, host));
3131
3132 yield* manager.createTab("tab_window_close_warmup");
3133 yield* manager.registerWebview("tab_window_close_warmup", 42);
3134 yield* manager.setMainWindow({
3135 isDestroyed: () => false,
3136 once: vi.fn((event: string, listener: () => void) => {
3137 if (event === "closed") closeMainWindow = listener;
3138 }),
3139 webContents: { setBackgroundThrottling: vi.fn() },
3140 } as never);
3141
3142 const start = yield* manager
3143 .startRecording("tab_window_close_warmup")
3144 .pipe(Effect.forkChild({ startImmediately: true }));
3145 yield* Effect.promise(() => warmupStarted);
3146 closeMainWindow?.();
3147 finishWarmup(capturedImage);
3148
3149 const exit = yield* Fiber.await(start);
3150 expect(Exit.isFailure(exit)).toBe(true);
3151 if (Exit.isFailure(exit)) {
3152 expect(Option.getOrThrow(Cause.findErrorOption(exit.cause))).toMatchObject({
3153 _tag: "PreviewMainWindowClosedError",
3154 tabId: "tab_window_close_warmup",
3155 });
3156 }
3157 expect(host.session.setDisplayMediaRequestHandler).not.toHaveBeenCalled();
3158 }),
3159 ),
3160 );
3161
3162 effectIt.effect("tells the server when a tab it renders natively closes", () =>
3163 Effect.gen(function* () {
3164 const host = yield* DesktopBrowserHost.DesktopBrowserHost;
3165 const serverTab = { threadId: "thread-1", tabId: "server-tab-1" };
3166 const lines = yield* Queue.unbounded<string>();
3167 yield* host.events.pipe(
3168 Stream.runForEach((line) => Queue.offer(lines, new TextDecoder().decode(line))),
3169 Effect.forkScoped,
3170 );
3171 const nextType = Queue.take(lines).pipe(
3172 Effect.map((line) => /"type":"(\w+)"/.exec(line)?.[1]),
3173 );
3174 const capturePage = vi.fn(async () => ({
3175 toPNG: () => Buffer.from("png"),
3176 toJPEG: () => Buffer.from("jpeg"),
3177 getSize: () => ({ width: 100, height: 80 }),
3178 }));
3179 fromId.mockReturnValue(
3180 Object.assign(makeTestPreviewWebContents(capturePage, 42), {
3181 isDevToolsOpened: () => false,
3182 getUserAgent: () => "Electron",
3183 }),
3184 );
3185 const manager = yield* PreviewManager.PreviewManager;
3186 yield* manager.createTab("tab_1", { serverTab });
3187 yield* manager.registerWebview("tab_1", 42);
3188 // The debugger attaches in the background; the server hears it here.
3189 expect(yield* nextType).toBe("attached");
3190 yield* manager.closeTab("tab_1");
3191 expect(yield* nextType).toBe("detached");
3192 }).pipe(Effect.provide(managerLayer()), Effect.scoped),
3193 );
3194
3195 effectIt.effect("stops capture retries when the tab swaps during the retry delay", () =>
3196 withManager((manager) =>
3197 Effect.gen(function* () {
3198 const capturePage = vi.fn(async () => ({
3199 toPNG: () => Buffer.from("png"),
3200 toJPEG: () => Buffer.from("jpeg"),
3201 getSize: () => ({ width: 100, height: 80 }),
3202 }));
3203 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42));
3204 yield* manager.createTab("tab_1");
3205 yield* manager.registerWebview("tab_1", 42);
3206
3207 capturePage.mockRejectedValueOnce(new Error("UnknownVizError"));
3208 const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe(
3209 Effect.forkChild({ startImmediately: true }),
3210 );
3211 // Let the rejection schedule its retry before replacing the guest.
3212 yield* TestClock.adjust(60);
3213 expect(capturePage).toHaveBeenCalledTimes(1);
3214 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43));
3215 yield* manager.registerWebview("tab_1", 43);
3216 yield* TestClock.adjust(1_000);
3217 const exit = yield* Fiber.join(fiber);
3218
3219 expect(Exit.isFailure(exit)).toBe(true);
3220 expect(capturePage).toHaveBeenCalledTimes(1);
3221 expect(writeFile).not.toHaveBeenCalled();
3222 }),
3223 ),
3224 );
3225
3226 effectIt.effect("discards a screenshot that resolves after its guest is replaced", () =>
3227 withManager((manager) =>
3228 Effect.gen(function* () {
3229 const image = {
3230 toPNG: () => Buffer.from("stale-png"),
3231 toJPEG: () => Buffer.from("stale-jpeg"),
3232 getSize: () => ({ width: 100, height: 80 }),
3233 };
3234 const pending = Promise.withResolvers<typeof image>();
3235 const capturePage = vi.fn(() => pending.promise);
3236 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 42));
3237 yield* manager.createTab("tab_1");
3238 yield* manager.registerWebview("tab_1", 42);
3239
3240 const fiber = yield* Effect.exit(manager.captureScreenshot("tab_1")).pipe(
3241 Effect.forkChild({ startImmediately: true }),
3242 );
3243 yield* TestClock.adjust(0);
3244 expect(capturePage).toHaveBeenCalledOnce();
3245 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage, 43));
3246 yield* manager.registerWebview("tab_1", 43);
3247 pending.resolve(image);
3248 const exit = yield* Fiber.join(fiber);
3249
3250 expect(Exit.isFailure(exit)).toBe(true);
3251 expect(capturePage).toHaveBeenCalledOnce();
3252 expect(writeFile).not.toHaveBeenCalled();
3253 }),
3254 ),
3255 );
3256
3257 effectIt.effect("grants each concurrent preview recording its own tab frame", () =>
3258 withManager((manager) =>
3259 Effect.gen(function* () {
3260 const firstJpeg = Buffer.from("first-recording-frame");
3261 const secondJpeg = Buffer.from("second-recording-frame");
3262 const firstCapturePage = vi.fn(async () => ({
3263 toJPEG: () => firstJpeg,
3264 getSize: () => ({ width: 800, height: 600 }),
3265 }));
3266 const secondCapturePage = vi.fn(async () => ({
3267 toJPEG: () => secondJpeg,
3268 getSize: () => ({ width: 390, height: 844 }),
3269 }));
3270 const firstSendCommand = vi.fn(async () => undefined);
3271 const secondSendCommand = vi.fn(async () => undefined);
3272 // Both webviews live in the same window, so they share one display-media handler.
3273 const host = makeTestHostWebContents();
3274 const makeWebContents = (
3275 id: number,
3276 capturePage: typeof firstCapturePage,
3277 sendCommand: typeof firstSendCommand,
3278 ) =>
3279 ({
3280 id,
3281 mainFrame: { routingId: id },
3282 hostWebContents: host,
3283 executeJavaScript: vi.fn(async () =>
3284 id === 41 ? { width: 800, height: 600 } : { width: 390, height: 844 },
3285 ),
3286 isDestroyed: () => false,
3287 getType: () => "webview",
3288 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
3289 getURL: () => `https://example.com/${id}`,
3290 getTitle: () => `Example ${id}`,
3291 isLoadingMainFrame: () => false,
3292 getZoomFactor: () => 1,
3293 setZoomFactor: vi.fn(),
3294 setAudioMuted: vi.fn(),
3295 setBackgroundThrottling: vi.fn(),
3296 isCurrentlyAudible: () => false,
3297 on: vi.fn(),
3298 off: vi.fn(),
3299 ipc: { on: vi.fn(), off: vi.fn() },
3300 send: webviewSend,
3301 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
3302 setIgnoreMenuShortcuts: vi.fn(),
3303 setWindowOpenHandler: vi.fn(),
3304 debugger: {
3305 isAttached: () => false,
3306 attach: vi.fn(),
3307 sendCommand,
3308 on: vi.fn(),
3309 off: vi.fn(),
3310 },
3311 capturePage,
3312 }) as never;
3313 const webContentsById = new Map([
3314 [41, makeWebContents(41, firstCapturePage, firstSendCommand)],
3315 [42, makeWebContents(42, secondCapturePage, secondSendCommand)],
3316 ]);
3317 fromId.mockImplementation((id) =>
3318 id === undefined ? null : (webContentsById.get(id) ?? null),
3319 );
3320 yield* manager.createTab("tab_1");
3321 yield* manager.createTab("tab_2");
3322 yield* manager.registerWebview("tab_1", 41);
3323 yield* manager.registerWebview("tab_2", 42);
3324
3325 const grants: Array<{ video?: unknown }> = [];
3326 const takeGrant = () =>
3327 host.displayMediaHandler()?.({ frame: host.mainFrame }, (value) => {
3328 grants.push(value);
3329 });
3330
3331 yield* manager.startRecording("tab_1");
3332 takeGrant();
3333 yield* manager.startRecording("tab_2");
3334 takeGrant();
3335 expect(grants).toEqual([{ video: { routingId: 41 } }, { video: { routingId: 42 } }]);
3336
3337 expect(firstCapturePage).toHaveBeenCalledOnce();
3338 expect(secondCapturePage).toHaveBeenCalledOnce();
3339 expect(firstSendCommand).not.toHaveBeenCalledWith(
3340 "Page.startScreencast",
3341 expect.anything(),
3342 );
3343 expect(secondSendCommand).not.toHaveBeenCalledWith(
3344 "Page.startScreencast",
3345 expect.anything(),
3346 );
3347
3348 yield* Effect.all([manager.stopRecording("tab_1"), manager.stopRecording("tab_2")], {
3349 concurrency: 2,
3350 discard: true,
3351 });
3352 }),
3353 ),
3354 );
3355
3356 effectIt.effect("requests display media with a fresh renderer gesture", () =>
3357 withManager((manager) =>
3358 Effect.gen(function* () {
3359 const { host, takeGrant } = yield* setupRecordingRaceTabs(manager);
3360
3361 yield* manager.startRecording("tab_race_a");
3362
3363 expect(host.executeJavaScript).toHaveBeenCalledWith(
3364 expect.stringContaining(DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER),
3365 true,
3366 );
3367 expect(host.executeJavaScript).toHaveBeenCalledWith(
3368 expect.stringContaining("tab_race_a"),
3369 true,
3370 );
3371 takeGrant();
3372 yield* manager.stopRecording("tab_race_a");
3373 }),
3374 ),
3375 );
3376
3377 // Runs on the real clock: an earlier queueing design only settled under TestClock and stalled the
3378 // losing start forever in the desktop app.
3379 effectIt.live("settles both starts when two tabs race for the capture stream", () =>
3380 withManager((manager) =>
3381 Effect.gen(function* () {
3382 const { host, grants, takeGrant } = yield* setupRecordingRaceTabs(manager);
3383
3384 const exits = yield* Effect.all(
3385 [
3386 Effect.exit(manager.startRecording("tab_race_a")),
3387 Effect.exit(manager.startRecording("tab_race_b")),
3388 ],
3389 { concurrency: 2 },
3390 );
3391
3392 const [exitA, exitB] = exits;
3393 // Exactly one start owns the stream; the other fails fast instead of hanging.
3394 expect(exits.filter(Exit.isSuccess)).toHaveLength(1);
3395 const loserExit = Exit.isSuccess(exitA) ? exitB : exitA;
3396 if (Exit.isSuccess(loserExit)) return;
3397 expect(Option.getOrThrow(Cause.findErrorOption(loserExit.cause))).toMatchObject({
3398 _tag: "PreviewRecordingArmConflictError",
3399 });
3400
3401 // The single grant goes to the tab that actually won the slot, never the other one.
3402 takeGrant();
3403 expect(grants).toEqual([{ video: { routingId: Exit.isSuccess(exitA) ? 41 : 42 } }]);
3404 expect(host.session.setDisplayMediaRequestHandler).toHaveBeenCalledOnce();
3405 }),
3406 ),
3407 );
3408
3409 effectIt.effect("releases an armed slot that the renderer never redeemed", () =>
3410 withManager((manager) =>
3411 Effect.gen(function* () {
3412 const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager);
3413
3414 yield* manager.startRecording("tab_race_a");
3415 const blocked = yield* Effect.exit(manager.startRecording("tab_race_b"));
3416 if (Exit.isSuccess(blocked)) throw new Error("expected the second tab to be refused");
3417 expect(Option.getOrThrow(Cause.findErrorOption(blocked.cause))).toMatchObject({
3418 _tag: "PreviewRecordingArmConflictError",
3419 tabId: "tab_race_b",
3420 armedTabId: "tab_race_a",
3421 });
3422
3423 // Nothing ever captured the armed tab, so the slot goes stale and stops blocking starts.
3424 yield* TestClock.adjust(10_000);
3425 yield* manager.startRecording("tab_race_b");
3426 takeGrant();
3427 expect(grants).toEqual([{ video: { routingId: 42 } }]);
3428
3429 yield* manager.stopRecording("tab_race_a");
3430 yield* manager.stopRecording("tab_race_b");
3431 }),
3432 ),
3433 );
3434
3435 effectIt.effect("denies a display-media request that arrives after the arm went stale", () =>
3436 withManager((manager) =>
3437 Effect.gen(function* () {
3438 const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager);
3439
3440 yield* manager.startRecording("tab_race_a");
3441 yield* TestClock.adjust(10_000);
3442 // The handler cannot read a clock, so the expiry fiber must have dropped the frame.
3443 takeGrant();
3444 expect(grants).toEqual([{}]);
3445
3446 yield* manager.stopRecording("tab_race_a");
3447 }),
3448 ),
3449 );
3450
3451 effectIt.effect("only lets the host frame that armed a recording claim its stream", () =>
3452 withManager((manager) =>
3453 Effect.gen(function* () {
3454 const { grants, takeGrant } = yield* setupRecordingRaceTabs(manager);
3455
3456 yield* manager.startRecording("tab_race_a");
3457 takeGrant({ frameTreeNodeId: 999 });
3458 takeGrant();
3459
3460 expect(grants).toEqual([{}, { video: { routingId: 41 } }]);
3461 yield* manager.stopRecording("tab_race_a");
3462 }),
3463 ),
3464 );
3465
3466 effectIt.effect("reclaims the arm slot from a destroyed webContents", () =>
3467 withManager((manager) =>
3468 Effect.gen(function* () {
3469 const { grants, takeGrant, destroy } = yield* setupRecordingRaceTabs(manager);
3470
3471 yield* manager.startRecording("tab_race_a");
3472 destroy(41);
3473 yield* manager.startRecording("tab_race_b");
3474 takeGrant();
3475 expect(grants).toEqual([{ video: { routingId: 42 } }]);
3476
3477 yield* manager.stopRecording("tab_race_b");
3478 }),
3479 ),
3480 );
3481
3482 effectIt.effect(
3483 "restores the native cursor when recording startup fails, then allows a retry",
3484 () =>
3485 withManager((manager) =>
3486 Effect.gen(function* () {
3487 const host = makeTestHostWebContents();
3488 host.executeJavaScript.mockResolvedValueOnce(false);
3489 let cursorActive = false;
3490 const cursorAtCapture: boolean[] = [];
3491 const contents = Object.assign(
3492 makeTestPreviewWebContents(
3493 async () => {
3494 cursorAtCapture.push(cursorActive);
3495 return {
3496 toJPEG: () => Buffer.from("frame"),
3497 getSize: () => ({ width: 800, height: 600 }),
3498 };
3499 },
3500 42,
3501 host,
3502 ),
3503 {
3504 send: (channel: string, active: unknown) => {
3505 if (channel === "preview:recording-cursor") cursorActive = active === true;
3506 },
3507 },
3508 );
3509 fromId.mockReturnValue(contents as never);
3510 yield* manager.createTab("tab_cursor");
3511 yield* manager.registerWebview("tab_cursor", 42);
3512 const failed = yield* Effect.exit(manager.startRecording("tab_cursor"));
3513 expect(Exit.isFailure(failed)).toBe(true);
3514 expect(cursorAtCapture).toEqual([true]);
3515 expect(cursorActive).toBe(false);
3516
3517 yield* manager.startRecording("tab_cursor");
3518 expect(cursorAtCapture).toEqual([true, true]);
3519 expect(cursorActive).toBe(true);
3520 yield* manager.stopRecording("tab_cursor");
3521 expect(cursorActive).toBe(false);
3522 }),
3523 ),
3524 );
3525
3526 effectIt.effect("restores the recording cursor after navigation only while recording", () =>
3527 withManager((manager) =>
3528 Effect.gen(function* () {
3529 const listeners = new Map<string, () => void>();
3530 let cursorActive = false;
3531 let cursorUpdated: (() => void) | undefined;
3532 let inputOptions: unknown;
3533 const options = { showKeyPresses: true, showMousePresses: false };
3534 const contents = Object.assign(
3535 makeTestPreviewWebContents(async () => ({
3536 toJPEG: () => Buffer.from("frame"),
3537 getSize: () => ({ width: 800, height: 600 }),
3538 })),
3539 {
3540 on: (event: string, listener: () => void) => listeners.set(event, listener),
3541 send: (channel: string, active: unknown, recordingOptions: unknown) => {
3542 if (channel !== "preview:recording-cursor") return;
3543 cursorActive = active === true;
3544 inputOptions = recordingOptions;
3545 cursorUpdated?.();
3546 },
3547 },
3548 );
3549 fromId.mockReturnValue(contents as never);
3550 yield* manager.createTab("tab_cursor_reload");
3551 yield* manager.registerWebview("tab_cursor_reload", 42);
3552 yield* manager.startRecording("tab_cursor_reload", options);
3553 for (const recording of [true, false]) {
3554 if (!recording) yield* manager.stopRecording("tab_cursor_reload");
3555 // A new document has lost the previous preload's cursor overlay.
3556 cursorActive = false;
3557 const restored = new Promise<void>((resolve) => {
3558 cursorUpdated = resolve;
3559 });
3560 listeners.get("dom-ready")?.();
3561 yield* Effect.promise(() => restored);
3562 cursorUpdated = undefined;
3563 expect(cursorActive).toBe(recording);
3564 expect(inputOptions).toEqual(recording ? options : undefined);
3565 }
3566 }),
3567 ),
3568 );
3569
3570 effectIt.effect("gates recording decorations and isolates failed subscribers", () =>
3571 withManager((manager) =>
3572 Effect.gen(function* () {
3573 const callbacks = new Map<
3574 string,
3575 (event: unknown, input: unknown) => Fiber.Fiber<void, never> | undefined
3576 >();
3577 const contents = Object.assign(
3578 makeTestPreviewWebContents(async () => ({
3579 toJPEG: () => Buffer.from("frame"),
3580 getSize: () => ({ width: 800, height: 600 }),
3581 })),
3582 {
3583 ipc: {
3584 on: (
3585 channel: string,
3586 callback: (event: unknown, input: unknown) => Fiber.Fiber<void, never> | undefined,
3587 ) => callbacks.set(channel, callback),
3588 off: vi.fn(),
3589 },
3590 },
3591 );
3592 fromId.mockReturnValue(contents as never);
3593 yield* manager.createTab("tab_recording_input");
3594 yield* manager.registerWebview("tab_recording_input", 42);
3595 const received: DesktopPreviewRecordingInputEvent[] = [];
3596 yield* manager.subscribeRecordingInputs(() => Effect.die("renderer unavailable"));
3597 yield* manager.subscribeRecordingInputs((event) =>
3598 Effect.sync(() => {
3599 received.push(event);
3600 }),
3601 );
3602 const send = (input: unknown) =>
3603 Effect.gen(function* () {
3604 const fiber = callbacks.get("preview:recording-input")?.(null, input);
3605 if (fiber) yield* Fiber.join(fiber);
3606 });
3607 const key = { type: "key", label: "⌘C", held: true, width: 800 };
3608 const pointer = {
3609 type: "pointer",
3610 phase: "down",
3611 x: 120,
3612 y: 80,
3613 width: 800,
3614 height: 600,
3615 };
3616 yield* send(key);
3617 expect(received).toEqual([]);
3618 yield* manager.startRecording("tab_recording_input", {
3619 showKeyPresses: true,
3620 showMousePresses: false,
3621 });
3622 yield* send(key);
3623 yield* send(pointer);
3624 yield* send({ ...key, width: 0 });
3625 expect(received).toEqual([{ tabId: "tab_recording_input", input: key }]);
3626 yield* manager.stopRecording("tab_recording_input");
3627 yield* send(key);
3628 expect(received).toHaveLength(1);
3629 yield* manager.startRecording("tab_recording_input", {
3630 showKeyPresses: false,
3631 showMousePresses: true,
3632 });
3633 yield* send(key);
3634 yield* send(pointer);
3635 expect(received.at(-1)).toEqual({ tabId: "tab_recording_input", input: pointer });
3636 expect(received).toHaveLength(2);
3637 }),
3638 ),
3639 );
3640
3641 effectIt.effect("continues native recording when the source warmup fails", () =>
3642 withManager((manager) =>
3643 Effect.gen(function* () {
3644 const capturePage = vi.fn(async () => {
3645 throw new Error("source is not ready");
3646 });
3647 const host = makeTestHostWebContents();
3648 const webContents = Object.assign(makeTestPreviewWebContents(capturePage, 42, host), {
3649 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),
3650 });
3651 fromId.mockReturnValue(webContents);
3652
3653 yield* manager.createTab("tab_recording_warmup_failure");
3654 yield* manager.registerWebview("tab_recording_warmup_failure", 42);
3655
3656 yield* manager.startRecording("tab_recording_warmup_failure");
3657 expect(capturePage).toHaveBeenCalledTimes(2);
3658
3659 // The armed tab answers exactly one display-media request, then further requests are denied.
3660 const handler = host.displayMediaHandler();
3661 const streams: Array<{ video?: unknown }> = [];
3662 handler?.({ frame: host.mainFrame }, (value) => streams.push(value));
3663 handler?.({ frame: host.mainFrame }, (value) => streams.push(value));
3664 expect(streams).toEqual([{ video: { routingId: 42 } }, {}]);
3665
3666 yield* manager.stopRecording("tab_recording_warmup_failure");
3667 }),
3668 ),
3669 );
3670
3671 effectIt.effect("serializes recording source acquisition with webview replacement", () =>
3672 withManager((manager) =>
3673 Effect.gen(function* () {
3674 const capturedImage = {
3675 toJPEG: () => Buffer.from("unused-recording-frame"),
3676 getSize: () => ({ width: 1280, height: 720 }),
3677 };
3678 let markWarmupStarted!: () => void;
3679 const warmupStarted = new Promise<void>((resolve) => {
3680 markWarmupStarted = resolve;
3681 });
3682 let finishWarmup!: (image: TestCapturedPreviewImage) => void;
3683 const capturePage = vi
3684 .fn<() => Promise<TestCapturedPreviewImage>>()
3685 .mockImplementationOnce(
3686 () =>
3687 new Promise<TestCapturedPreviewImage>((resolve) => {
3688 markWarmupStarted();
3689 finishWarmup = resolve;
3690 }),
3691 )
3692 .mockResolvedValue(capturedImage);
3693 const initialWebContents = makeTestPreviewWebContents(capturePage, 42);
3694 const replacementOn = vi.fn();
3695 const replacementWebContents = Object.assign(makeTestPreviewWebContents(capturePage, 43), {
3696 on: replacementOn,
3697 });
3698 fromId.mockImplementation((id) => {
3699 if (id === 42) return initialWebContents;
3700 if (id === 43) return replacementWebContents;
3701 return null;
3702 });
3703
3704 yield* manager.createTab("tab_recording_replacement_race");
3705 yield* manager.registerWebview("tab_recording_replacement_race", 42);
3706 const start = yield* manager
3707 .startRecording("tab_recording_replacement_race")
3708 .pipe(Effect.forkChild({ startImmediately: true }));
3709 yield* Effect.promise(() => warmupStarted);
3710 const replacement = yield* manager
3711 .registerWebview("tab_recording_replacement_race", 43)
3712 .pipe(Effect.forkChild({ startImmediately: true }));
3713 yield* Effect.yieldNow;
3714 expect(replacementOn).not.toHaveBeenCalled();
3715
3716 finishWarmup(capturedImage);
3717 yield* Fiber.join(start);
3718 yield* Fiber.join(replacement);
3719 expect(replacementOn).toHaveBeenCalled();
3720 expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]);
3721 expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([[false]]);
3722 yield* manager.stopRecording("tab_recording_replacement_race");
3723 expect(initialWebContents.setBackgroundThrottling.mock.calls).toEqual([[false], [true]]);
3724 expect(replacementWebContents.setBackgroundThrottling.mock.calls).toEqual([
3725 [false],
3726 [true],
3727 ]);
3728 }),
3729 ),
3730 );
3731
3732 effectIt.effect("shares background frame capture between recording and picture-in-picture", () =>
3733 withManager((manager) =>
3734 Effect.gen(function* () {
3735 const setBackgroundThrottling = vi.fn();
3736 const mainWindowWebContents = Object.assign(makeTestHostWebContents(), {
3737 setBackgroundThrottling,
3738 });
3739 const jpeg = Buffer.from("shared-preview-frame");
3740 const capturePage = vi.fn(async () => ({
3741 toJPEG: () => jpeg,
3742 getSize: () => ({ width: 1280, height: 720 }),
3743 }));
3744 fromId.mockReturnValue({
3745 id: 42,
3746 mainFrame: { routingId: 42 },
3747 hostWebContents: mainWindowWebContents,
3748 executeJavaScript: vi.fn(async () => ({ width: 1280, height: 720 })),
3749 isDestroyed: () => false,
3750 getType: () => "webview",
3751 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
3752 getURL: () => "https://example.com",
3753 getTitle: () => "Example",
3754 isLoadingMainFrame: () => false,
3755 getZoomFactor: () => 1,
3756 setZoomFactor: vi.fn(),
3757 setAudioMuted: vi.fn(),
3758 setBackgroundThrottling: vi.fn(),
3759 isCurrentlyAudible: () => false,
3760 on: vi.fn(),
3761 off: vi.fn(),
3762 ipc: { on: vi.fn(), off: vi.fn() },
3763 send: webviewSend,
3764 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
3765 setIgnoreMenuShortcuts: vi.fn(),
3766 setWindowOpenHandler: vi.fn(),
3767 debugger: {
3768 isAttached: () => false,
3769 attach: vi.fn(),
3770 sendCommand: vi.fn(async () => undefined),
3771 on: vi.fn(),
3772 off: vi.fn(),
3773 },
3774 capturePage,
3775 } as never);
3776
3777 const pictureInPictureListeners = new Map<string, () => void>();
3778 const pictureInPictureSend = vi.fn();
3779 const pictureInPictureWindow = {
3780 isDestroyed: vi.fn(() => false),
3781 once: vi.fn((event: string, listener: () => void) => {
3782 pictureInPictureListeners.set(event, listener);
3783 }),
3784 setAlwaysOnTop: vi.fn(),
3785 setVisibleOnAllWorkspaces: vi.fn(),
3786 setAspectRatio: vi.fn(),
3787 getContentSize: vi.fn(() => [480, 320] as [number, number]),
3788 setContentSize: vi.fn(),
3789 loadURL: vi.fn(async () => undefined),
3790 showInactive: vi.fn(),
3791 close: vi.fn(() => {
3792 pictureInPictureListeners.get("closed")?.();
3793 }),
3794 webContents: {
3795 on: vi.fn(),
3796 off: vi.fn(),
3797 send: pictureInPictureSend,
3798 },
3799 };
3800 browserWindowConstructor.mockImplementation(function () {
3801 return pictureInPictureWindow;
3802 });
3803 const states: PreviewManager.PreviewTabState[] = [];
3804 const recordingFrames: DesktopPreviewRecordingFrame[] = [];
3805
3806 yield* manager.setMainWindow({
3807 isDestroyed: () => false,
3808 once: vi.fn(),
3809 webContents: mainWindowWebContents,
3810 } as never);
3811
3812 yield* manager.subscribeStateChanges((_tabId, state) =>
3813 Effect.sync(() => {
3814 states.push(state);
3815 }),
3816 );
3817 yield* manager.subscribeRecordingFrames((frame) =>
3818 Effect.sync(() => {
3819 recordingFrames.push(frame);
3820 }),
3821 );
3822 yield* manager.createTab("tab_pip");
3823 yield* manager.registerWebview("tab_pip", 42);
3824 yield* manager.openPictureInPicture("tab_pip");
3825
3826 expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
3827 expect(browserWindowConstructor).toHaveBeenCalledWith(
3828 expect.objectContaining({
3829 alwaysOnTop: true,
3830 show: false,
3831 skipTaskbar: true,
3832 webPreferences: expect.objectContaining({
3833 preload: "/tmp/t3/desktop/preview-pip-preload.cjs",
3834 backgroundThrottling: false,
3835 }),
3836 }),
3837 );
3838 expect(pictureInPictureWindow.showInactive).toHaveBeenCalledOnce();
3839 expect(pictureInPictureWindow.setVisibleOnAllWorkspaces).toHaveBeenCalledWith(true, {
3840 visibleOnFullScreen: true,
3841 skipTransformProcessType: true,
3842 });
3843 expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]);
3844 expect(pictureInPictureWindow.setContentSize).toHaveBeenCalledWith(523, 294, false);
3845 expect(pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[0]).toBeLessThan(
3846 pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0] ?? 0,
3847 );
3848 expect(pictureInPictureWindow.setContentSize.mock.invocationCallOrder[0]).toBeLessThan(
3849 pictureInPictureWindow.setAspectRatio.mock.invocationCallOrder[1] ?? 0,
3850 );
3851 expect(pictureInPictureSend).toHaveBeenCalledWith(
3852 "desktop:preview-pip-frame",
3853 expect.objectContaining({
3854 tabId: "tab_pip",
3855 data: jpeg.toString("base64"),
3856 width: 1280,
3857 height: 720,
3858 }),
3859 );
3860 expect(states.at(-1)?.pictureInPicture).toBe(true);
3861 expect(capturePage).toHaveBeenCalledOnce();
3862 const pictureInPictureFramesBeforeRecording = pictureInPictureSend.mock.calls.length;
3863
3864 yield* manager.startRecording("tab_pip");
3865 expect(capturePage).toHaveBeenCalledTimes(2);
3866 expect(recordingFrames).toHaveLength(0);
3867
3868 yield* TestClock.adjust(100);
3869 expect(capturePage).toHaveBeenCalledTimes(3);
3870 expect(pictureInPictureSend).toHaveBeenCalledTimes(pictureInPictureFramesBeforeRecording);
3871 expect(recordingFrames).toHaveLength(0);
3872
3873 yield* manager.stopRecording("tab_pip");
3874 expect(setBackgroundThrottling.mock.calls).toEqual([[false]]);
3875 const framesBeforePictureInPictureOnlyTick = pictureInPictureSend.mock.calls.length;
3876 yield* TestClock.adjust(100);
3877 expect(capturePage).toHaveBeenCalledTimes(4);
3878 expect(pictureInPictureSend.mock.calls.length).toBe(framesBeforePictureInPictureOnlyTick);
3879 expect(recordingFrames).toHaveLength(0);
3880
3881 setBackgroundThrottling.mockImplementationOnce(() => {
3882 throw new Error("picture-in-picture throttling restore failed");
3883 });
3884 yield* manager.closePictureInPicture("tab_pip");
3885 expect(setBackgroundThrottling.mock.calls).toEqual([[false], [true], [true]]);
3886 expect(pictureInPictureWindow.close).toHaveBeenCalledOnce();
3887 expect(states.at(-1)?.pictureInPicture).toBe(false);
3888 const capturesAfterClose = capturePage.mock.calls.length;
3889 yield* TestClock.adjust(200);
3890 expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose);
3891 }),
3892 ),
3893 );
3894
3895 effectIt.effect("keeps picture-in-picture capture separate from recording warmup", () =>
3896 withManager((manager) =>
3897 Effect.gen(function* () {
3898 const jpeg = Buffer.from("shared-preview-frame");
3899 const capturePage = vi.fn(async () => ({
3900 toJPEG: () => jpeg,
3901 getSize: () => ({ width: 1280, height: 720 }),
3902 }));
3903 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
3904 const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow();
3905 browserWindowConstructor.mockImplementation(function () {
3906 return pictureInPictureWindow;
3907 });
3908 const recordingFrames: DesktopPreviewRecordingFrame[] = [];
3909
3910 yield* manager.subscribeRecordingFrames((frame) =>
3911 Effect.sync(() => {
3912 recordingFrames.push(frame);
3913 }),
3914 );
3915 yield* manager.createTab("tab_recording_then_pip");
3916 yield* manager.registerWebview("tab_recording_then_pip", 42);
3917 yield* manager.startRecording("tab_recording_then_pip");
3918
3919 expect(recordingFrames).toHaveLength(0);
3920 expect(capturePage).toHaveBeenCalledOnce();
3921 yield* manager.openPictureInPicture("tab_recording_then_pip");
3922 expect(capturePage).toHaveBeenCalledTimes(2);
3923 expect(send).toHaveBeenCalledOnce();
3924
3925 yield* TestClock.adjust(100);
3926
3927 expect(capturePage).toHaveBeenCalledTimes(3);
3928 expect(recordingFrames).toHaveLength(0);
3929 expect(send).toHaveBeenCalledOnce();
3930 yield* manager.closePictureInPicture("tab_recording_then_pip");
3931 yield* manager.stopRecording("tab_recording_then_pip");
3932 }),
3933 ),
3934 );
3935
3936 effectIt.effect("stops frame capture when the native picture-in-picture window closes", () =>
3937 withManager((manager) =>
3938 Effect.gen(function* () {
3939 const capturePage = vi.fn(async () => ({
3940 toJPEG: () => Buffer.from("native-close-preview-frame"),
3941 getSize: () => ({ width: 1280, height: 720 }),
3942 }));
3943 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
3944 const { pictureInPictureWindow } = makeTestPictureInPictureWindow();
3945 browserWindowConstructor.mockImplementation(function () {
3946 return pictureInPictureWindow;
3947 });
3948 const states: PreviewManager.PreviewTabState[] = [];
3949 yield* manager.subscribeStateChanges((_tabId, state) =>
3950 Effect.sync(() => {
3951 states.push(state);
3952 }),
3953 );
3954
3955 yield* manager.createTab("tab_native_pip_close");
3956 yield* manager.registerWebview("tab_native_pip_close", 42);
3957 yield* manager.openPictureInPicture("tab_native_pip_close");
3958
3959 pictureInPictureWindow.close();
3960 yield* settle(() => states.at(-1)?.pictureInPicture === false);
3961
3962 expect(states.at(-1)?.pictureInPicture).toBe(false);
3963 const capturesAfterClose = capturePage.mock.calls.length;
3964 yield* TestClock.adjust(200);
3965 expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose);
3966 }),
3967 ),
3968 );
3969
3970 effectIt.effect("retries an unchanged picture-in-picture frame after delivery fails", () =>
3971 withManager((manager) =>
3972 Effect.gen(function* () {
3973 const jpeg = Buffer.from("retry-preview-frame");
3974 const capturePage = vi.fn(async () => ({
3975 toJPEG: () => jpeg,
3976 getSize: () => ({ width: 1280, height: 720 }),
3977 }));
3978 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
3979 const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow();
3980 send.mockImplementationOnce(() => {
3981 throw new Error("picture-in-picture delivery failed");
3982 });
3983 browserWindowConstructor.mockImplementation(function () {
3984 return pictureInPictureWindow;
3985 });
3986
3987 yield* manager.createTab("tab_pip_delivery_retry");
3988 yield* manager.registerWebview("tab_pip_delivery_retry", 42);
3989 yield* manager.openPictureInPicture("tab_pip_delivery_retry");
3990 expect(send).toHaveBeenCalledOnce();
3991
3992 yield* TestClock.adjust(100);
3993
3994 expect(capturePage).toHaveBeenCalledTimes(2);
3995 expect(send).toHaveBeenCalledTimes(2);
3996 yield* manager.closePictureInPicture("tab_pip_delivery_retry");
3997 }),
3998 ),
3999 );
4000
4001 effectIt.effect("replays an unchanged picture-in-picture frame after its renderer reloads", () =>
4002 withManager((manager) =>
4003 Effect.gen(function* () {
4004 const jpeg = Buffer.from("reloaded-preview-frame");
4005 const capturePage = vi.fn(async () => ({
4006 toJPEG: () => jpeg,
4007 getSize: () => ({ width: 1280, height: 720 }),
4008 }));
4009 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
4010 const { pictureInPictureWindow, send, webContentsListeners } =
4011 makeTestPictureInPictureWindow();
4012 browserWindowConstructor.mockImplementation(function () {
4013 return pictureInPictureWindow;
4014 });
4015
4016 yield* manager.createTab("tab_pip_reload");
4017 yield* manager.registerWebview("tab_pip_reload", 42);
4018 yield* manager.openPictureInPicture("tab_pip_reload");
4019 expect(send).toHaveBeenCalledOnce();
4020
4021 webContentsListeners.get("did-finish-load")?.();
4022 yield* TestClock.adjust(100);
4023
4024 expect(send).toHaveBeenCalledTimes(2);
4025 yield* manager.closePictureInPicture("tab_pip_reload");
4026 }),
4027 ),
4028 );
4029
4030 effectIt.effect("drops empty frames before picture-in-picture delivery", () =>
4031 withManager((manager) =>
4032 Effect.gen(function* () {
4033 const validImage: TestCapturedPreviewImage = {
4034 toJPEG: () => Buffer.from("valid-preview-frame"),
4035 getSize: () => ({ width: 1280, height: 720 }),
4036 };
4037 const capturePage = vi.fn(async () => validImage);
4038 capturePage.mockResolvedValueOnce({
4039 toJPEG: () => Buffer.from("empty-preview-frame"),
4040 getSize: () => ({ width: 0, height: 0 }),
4041 });
4042 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
4043 const { pictureInPictureWindow, send } = makeTestPictureInPictureWindow();
4044 browserWindowConstructor.mockImplementation(function () {
4045 return pictureInPictureWindow;
4046 });
4047
4048 yield* manager.createTab("tab_empty_frame");
4049 yield* manager.registerWebview("tab_empty_frame", 42);
4050 yield* manager.openPictureInPicture("tab_empty_frame");
4051
4052 expect(capturePage).toHaveBeenCalledOnce();
4053 expect(pictureInPictureWindow.setAspectRatio).not.toHaveBeenCalled();
4054 expect(send).not.toHaveBeenCalled();
4055
4056 yield* TestClock.adjust(100);
4057
4058 expect(pictureInPictureWindow.setAspectRatio.mock.calls).toEqual([[0], [1280 / 720]]);
4059 expect(send).toHaveBeenCalledOnce();
4060 yield* manager.closePictureInPicture("tab_empty_frame");
4061 }),
4062 ),
4063 );
4064
4065 effectIt.effect("does not publish picture-in-picture readiness after window teardown", () =>
4066 withManager((manager) =>
4067 Effect.gen(function* () {
4068 const capturePage = vi.fn(async () => ({
4069 toJPEG: () => Buffer.from("closing-preview-frame"),
4070 getSize: () => ({ width: 1280, height: 720 }),
4071 }));
4072 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
4073 const { pictureInPictureWindow } = makeTestPictureInPictureWindow();
4074 pictureInPictureWindow.showInactive.mockImplementationOnce(() => {
4075 pictureInPictureWindow.close();
4076 });
4077 browserWindowConstructor.mockImplementation(function () {
4078 return pictureInPictureWindow;
4079 });
4080 const states: PreviewManager.PreviewTabState[] = [];
4081 yield* manager.subscribeStateChanges((_tabId, state) =>
4082 Effect.sync(() => {
4083 states.push(state);
4084 }),
4085 );
4086
4087 yield* manager.createTab("tab_pip_teardown");
4088 yield* manager.registerWebview("tab_pip_teardown", 42);
4089 const openExit = yield* Effect.exit(manager.openPictureInPicture("tab_pip_teardown"));
4090
4091 expect(Exit.hasInterrupts(openExit)).toBe(true);
4092 expect(pictureInPictureWindow.close).toHaveBeenCalledOnce();
4093 expect(states.some((state) => state.pictureInPicture)).toBe(false);
4094 expect(states.at(-1)?.pictureInPicture).toBe(false);
4095 }),
4096 ),
4097 );
4098
4099 effectIt.effect("closes an initializing picture-in-picture without blocking later opens", () =>
4100 withManager((manager) =>
4101 Effect.gen(function* () {
4102 const capturePage = vi.fn(async () => ({
4103 toJPEG: () => Buffer.from("serialized-preview-frame"),
4104 getSize: () => ({ width: 1280, height: 720 }),
4105 }));
4106 fromId.mockReturnValue(makeTestPreviewWebContents(capturePage));
4107 const { pictureInPictureWindow: initializingWindow } = makeTestPictureInPictureWindow(
4108 () =>
4109 new Promise<void>(() => {
4110 // Simulate a renderer load that never settles.
4111 }),
4112 );
4113 const { pictureInPictureWindow: reopenedWindow } = makeTestPictureInPictureWindow();
4114 browserWindowConstructor
4115 .mockImplementationOnce(function () {
4116 return initializingWindow;
4117 })
4118 .mockImplementationOnce(function () {
4119 return reopenedWindow;
4120 });
4121 const states: PreviewManager.PreviewTabState[] = [];
4122 yield* manager.subscribeStateChanges((_tabId, state) =>
4123 Effect.sync(() => {
4124 states.push(state);
4125 }),
4126 );
4127 yield* manager.createTab("tab_concurrent_pip");
4128 yield* manager.registerWebview("tab_concurrent_pip", 42);
4129
4130 const firstOpen = yield* manager
4131 .openPictureInPicture("tab_concurrent_pip")
4132 .pipe(Effect.forkChild);
4133 yield* Effect.yieldNow;
4134 const secondOpen = yield* manager
4135 .openPictureInPicture("tab_concurrent_pip")
4136 .pipe(Effect.forkChild);
4137 yield* Effect.yieldNow;
4138 const close = yield* manager
4139 .closePictureInPicture("tab_concurrent_pip")
4140 .pipe(Effect.forkChild);
4141 yield* Effect.yieldNow;
4142
4143 expect(browserWindowConstructor).toHaveBeenCalledOnce();
4144 expect(initializingWindow.loadURL).toHaveBeenCalledOnce();
4145 expect(initializingWindow.close).toHaveBeenCalledOnce();
4146 const [firstOpenExit, secondOpenExit] = yield* Effect.all([
4147 Fiber.await(firstOpen),
4148 Fiber.await(secondOpen),
4149 ]);
4150 yield* Fiber.join(close);
4151
4152 expect(Exit.hasInterrupts(firstOpenExit)).toBe(true);
4153 expect(Exit.hasInterrupts(secondOpenExit)).toBe(true);
4154 expect(initializingWindow.showInactive).not.toHaveBeenCalled();
4155 expect(capturePage).not.toHaveBeenCalled();
4156 expect(states.at(-1)?.pictureInPicture).toBe(false);
4157
4158 yield* manager.openPictureInPicture("tab_concurrent_pip");
4159
4160 expect(browserWindowConstructor).toHaveBeenCalledTimes(2);
4161 expect(reopenedWindow.showInactive).toHaveBeenCalledOnce();
4162 expect(capturePage).toHaveBeenCalledOnce();
4163 expect(states.at(-1)?.pictureInPicture).toBe(true);
4164
4165 yield* manager.closePictureInPicture("tab_concurrent_pip");
4166
4167 expect(browserWindowConstructor).toHaveBeenCalledTimes(2);
4168 expect(reopenedWindow.close).toHaveBeenCalledOnce();
4169 expect(states.at(-1)?.pictureInPicture).toBe(false);
4170 const capturesAfterClose = capturePage.mock.calls.length;
4171 yield* TestClock.adjust(200);
4172 expect(capturePage).toHaveBeenCalledTimes(capturesAfterClose);
4173 }),
4174 ),
4175 );
4176
4177 effectIt.effect("rejects picture-in-picture when its webview changes during initialization", () =>
4178 withManager((manager) =>
4179 Effect.gen(function* () {
4180 const initialCapturePage = vi.fn(async () => ({
4181 toJPEG: () => Buffer.from("stale-preview-frame"),
4182 getSize: () => ({ width: 1280, height: 720 }),
4183 }));
4184 const replacementCapturePage = vi.fn(async () => ({
4185 toJPEG: () => Buffer.from("replacement-preview-frame"),
4186 getSize: () => ({ width: 1280, height: 720 }),
4187 }));
4188 const initialWebContents = makeTestPreviewWebContents(initialCapturePage, 42);
4189 const replacementWebContents = makeTestPreviewWebContents(replacementCapturePage, 43);
4190 fromId.mockImplementation((webContentsId?: number) => {
4191 if (webContentsId === 42) return initialWebContents;
4192 if (webContentsId === 43) return replacementWebContents;
4193 return null;
4194 });
4195 let resolveLoad: (() => void) | undefined;
4196 const { pictureInPictureWindow } = makeTestPictureInPictureWindow(
4197 () =>
4198 new Promise<void>((resolve) => {
4199 resolveLoad = resolve;
4200 }),
4201 );
4202 browserWindowConstructor.mockImplementation(function () {
4203 return pictureInPictureWindow;
4204 });
4205
4206 yield* manager.createTab("tab_replaced_webview");
4207 yield* manager.registerWebview("tab_replaced_webview", 42);
4208 const open = yield* manager
4209 .openPictureInPicture("tab_replaced_webview")
4210 .pipe(Effect.forkChild({ startImmediately: true }));
4211 yield* Effect.yieldNow;
4212 expect(pictureInPictureWindow.loadURL).toHaveBeenCalledOnce();
4213 expect(resolveLoad).toBeDefined();
4214 const concurrentOpen = yield* manager
4215 .openPictureInPicture("tab_replaced_webview")
4216 .pipe(Effect.forkChild({ startImmediately: true }));
4217 yield* Effect.yieldNow;
4218
4219 yield* manager.registerWebview("tab_replaced_webview", 43);
4220 resolveLoad?.();
4221
4222 const openExits = yield* Effect.all([Fiber.await(open), Fiber.await(concurrentOpen)]);
4223 for (const openExit of openExits) {
4224 expect(Exit.isFailure(openExit)).toBe(true);
4225 if (Exit.isSuccess(openExit)) continue;
4226 const error = Option.getOrThrow(Cause.findErrorOption(openExit.cause));
4227 expect(error).toMatchObject({
4228 _tag: "PreviewOperationError",
4229 operation: "pictureInPicture.validateWebContents",
4230 tabId: "tab_replaced_webview",
4231 webContentsId: 42,
4232 });
4233 }
4234 expect(browserWindowConstructor).toHaveBeenCalledOnce();
4235 expect(pictureInPictureWindow.close).toHaveBeenCalledOnce();
4236 expect(pictureInPictureWindow.showInactive).not.toHaveBeenCalled();
4237 expect(initialCapturePage).not.toHaveBeenCalled();
4238 expect(replacementCapturePage).not.toHaveBeenCalled();
4239 }),
4240 ),
4241 );
4242
4243 effectIt.effect("keeps element picking active during subframe navigation", () =>
4244 withManager((manager) =>
4245 Effect.gen(function* () {
4246 const listeners = new Map<string, (...args: unknown[]) => void>();
4247 fromId.mockReturnValue({
4248 id: 42,
4249 isDestroyed: () => false,
4250 getType: () => "webview",
4251 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
4252 getURL: () => "https://example.com",
4253 getTitle: () => "Example",
4254 isLoadingMainFrame: () => false,
4255 isFocused: () => true,
4256 getZoomFactor: () => 1,
4257 setZoomFactor: vi.fn(),
4258 setAudioMuted: vi.fn(),
4259 isCurrentlyAudible: () => false,
4260 on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
4261 listeners.set(event, listener);
4262 }),
4263 once: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
4264 listeners.set(event, (...args) => {
4265 listeners.delete(event);
4266 listener(...args);
4267 });
4268 }),
4269 off: vi.fn(),
4270 ipc: { on: vi.fn(), off: vi.fn(), removeListener: vi.fn() },
4271 send: webviewSend,
4272 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
4273 setIgnoreMenuShortcuts: vi.fn(),
4274 setWindowOpenHandler: vi.fn(),
4275 debugger: {
4276 isAttached: () => false,
4277 attach: vi.fn(),
4278 sendCommand: vi.fn(async () => undefined),
4279 on: vi.fn(),
4280 off: vi.fn(),
4281 },
4282 } as never);
4283
4284 yield* manager.createTab("tab_1");
4285 yield* manager.registerWebview("tab_1", 42);
4286 const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
4287 yield* Effect.yieldNow;
4288
4289 listeners.get("did-start-navigation")?.({
4290 url: "about:blank",
4291 isSameDocument: false,
4292 isMainFrame: false,
4293 frame: null,
4294 });
4295 yield* Effect.yieldNow;
4296 expect(pick.pollUnsafe()).toBeUndefined();
4297
4298 listeners.get("did-start-navigation")?.({
4299 url: "https://example.com/next",
4300 isSameDocument: false,
4301 isMainFrame: true,
4302 frame: null,
4303 });
4304 yield* Effect.yieldNow;
4305 expect(pick.pollUnsafe()).toBeDefined();
4306 expect(yield* Fiber.join(pick)).toBeNull();
4307 }),
4308 ),
4309 );
4310
4311 effectIt.effect("keeps annotation send grants isolated and updates an active pick", () =>
4312 withManager((manager) =>
4313 Effect.gen(function* () {
4314 const picked = new Map<number, (event: unknown, ...args: unknown[]) => void>();
4315 const capturePage = vi.fn(async () => ({
4316 toJPEG: () => Buffer.from("annotation"),
4317 toDataURL: () => "data:image/png;base64,YW5ub3RhdGlvbg==",
4318 getSize: () => ({ width: 10, height: 10 }),
4319 }));
4320 const guests = new Map(
4321 [42, 43, 44].map(
4322 (id) =>
4323 [
4324 id,
4325 Object.assign(makeTestPreviewWebContents(capturePage, id), {
4326 isFocused: () => true,
4327 once: vi.fn(),
4328 ipc: {
4329 on: vi.fn(
4330 (channel: string, listener: (event: unknown, ...args: unknown[]) => void) => {
4331 if (channel === "preview:element-picked") picked.set(id, listener);
4332 },
4333 ),
4334 off: vi.fn(),
4335 removeListener: vi.fn(),
4336 },
4337 }),
4338 ] as const,
4339 ),
4340 );
4341 fromId.mockImplementation((id) => (id === undefined ? null : (guests.get(id) ?? null)));
4342 yield* manager.createTab("tab_a");
4343 yield* manager.createTab("tab_b");
4344 yield* manager.registerWebview("tab_a", 42);
4345 yield* manager.registerWebview("tab_b", 43);
4346 yield* manager.setAnnotationSendEnabled("tab_a", true);
4347 const annotation = {
4348 id: "annotation-scopes",
4349 pageUrl: "https://example.com",
4350 pageTitle: "Example",
4351 comment: "Tighten this spacing",
4352 elements: [],
4353 regions: [],
4354 strokes: [],
4355 styleChanges: [],
4356 screenshot: null,
4357 createdAt: "2026-09-05T00:00:00.000Z",
4358 };
4359 for (const [tabId, id, nextGrant, expected] of [
4360 ["tab_b", 43, undefined, "attach"],
4361 ["tab_a", 42, false, "attach"],
4362 ["tab_a", 42, true, "send"],
4363 ] as const) {
4364 const pick = yield* manager.pickElement(tabId).pipe(Effect.forkChild);
4365 yield* Effect.yieldNow;
4366 if (nextGrant !== undefined) {
4367 yield* manager.setAnnotationSendEnabled(tabId, nextGrant);
4368 }
4369 picked.get(id)?.({}, annotation, null, "send");
4370 const result = yield* Fiber.join(pick);
4371 expect(result?.annotation.id).toBe(annotation.id);
4372 expect(result?.submission).toBe(expected);
4373 }
4374 yield* manager.registerWebview("tab_a", 44);
4375 const replacementPick = yield* manager.pickElement("tab_a").pipe(Effect.forkChild);
4376 yield* Effect.yieldNow;
4377 picked.get(44)?.({}, annotation, null, "send");
4378 expect((yield* Fiber.join(replacementPick))?.submission).toBe("send");
4379 yield* manager.closeTab("tab_a");
4380 yield* manager.createTab("tab_a");
4381 yield* manager.registerWebview("tab_a", 44);
4382 const reopenedPick = yield* manager.pickElement("tab_a").pipe(Effect.forkChild);
4383 yield* Effect.yieldNow;
4384 picked.get(44)?.({}, annotation, null, "send");
4385 expect((yield* Fiber.join(reopenedPick))?.submission).toBe("attach");
4386 }),
4387 ),
4388 );
4389
4390 effectIt.effect("settles the pick when the annotation screenshot never arrives", () =>
4391 withManager((manager) =>
4392 Effect.gen(function* () {
4393 let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined;
4394 fromId.mockReturnValue({
4395 id: 42,
4396 isDestroyed: () => false,
4397 getType: () => "webview",
4398 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
4399 getURL: () => "https://example.com",
4400 getTitle: () => "Example",
4401 isLoadingMainFrame: () => false,
4402 isFocused: () => true,
4403 getZoomFactor: () => 1,
4404 setZoomFactor: vi.fn(),
4405 setAudioMuted: vi.fn(),
4406 isCurrentlyAudible: () => false,
4407 on: vi.fn(),
4408 once: vi.fn(),
4409 off: vi.fn(),
4410 // A wedged compositor leaves `capturePage` pending forever.
4411 capturePage: vi.fn(() => new Promise(() => {})),
4412 ipc: {
4413 on: vi.fn((channel: string, listener: typeof onPicked) => {
4414 if (channel === "preview:element-picked") onPicked = listener;
4415 }),
4416 off: vi.fn(),
4417 removeListener: vi.fn(),
4418 },
4419 send: webviewSend,
4420 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
4421 setIgnoreMenuShortcuts: vi.fn(),
4422 setWindowOpenHandler: vi.fn(),
4423 debugger: {
4424 isAttached: () => false,
4425 attach: vi.fn(),
4426 sendCommand: vi.fn(async () => undefined),
4427 on: vi.fn(),
4428 off: vi.fn(),
4429 },
4430 } as never);
4431
4432 yield* manager.createTab("tab_1");
4433 yield* manager.registerWebview("tab_1", 42);
4434 const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
4435 yield* Effect.yieldNow;
4436
4437 yield* manager.setAnnotationSendEnabled("tab_1", true);
4438 onPicked?.(
4439 {},
4440 {
4441 id: "annotation_1",
4442 pageUrl: "https://example.com",
4443 pageTitle: "Example",
4444 comment: "Tighten this spacing",
4445 elements: [],
4446 regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }],
4447 strokes: [],
4448 styleChanges: [],
4449 screenshot: null,
4450 createdAt: "2026-06-11T00:00:00.000Z",
4451 },
4452 null,
4453 "send",
4454 );
4455 yield* Effect.yieldNow;
4456 expect(pick.pollUnsafe()).toBeUndefined();
4457
4458 yield* TestClock.adjust("6 seconds");
4459 // The pick has to give up on the crop rather than strand the renderer,
4460 // which would leave the composer stuck on "Capturing…".
4461 const result = yield* Fiber.join(pick);
4462 expect(result?.annotation.screenshot).toBeNull();
4463 expect(result?.screenshotFailed).toBe(true);
4464 expect(result?.submission).toBe("send");
4465 expect(webviewSend).toHaveBeenCalledWith("preview:annotation-captured");
4466 }),
4467 ),
4468 );
4469
4470 effectIt.effect("crops the picked area out of a full-page capture at device scale", () =>
4471 withManager((manager) =>
4472 Effect.gen(function* () {
4473 let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined;
4474 const crop = vi.fn((rect: Electron.Rectangle) => ({
4475 getSize: () => ({ width: rect.width, height: rect.height }),
4476 toDataURL: () => "data:image/png;base64,cropped",
4477 }));
4478 const capturePage = vi.fn(async () => ({
4479 getSize: () => ({ width: 2000, height: 1200 }),
4480 crop,
4481 }));
4482 fromId.mockReturnValue(
4483 Object.assign(makeTestPreviewWebContents(capturePage as never), {
4484 isFocused: () => true,
4485 once: vi.fn(),
4486 ipc: {
4487 on: vi.fn((channel: string, listener: typeof onPicked) => {
4488 if (channel === "preview:element-picked") onPicked = listener;
4489 }),
4490 off: vi.fn(),
4491 removeListener: vi.fn(),
4492 },
4493 }),
4494 );
4495
4496 yield* manager.createTab("tab_1");
4497 yield* manager.registerWebview("tab_1", 42);
4498 const pick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
4499 yield* Effect.yieldNow;
4500 onPicked?.(
4501 {},
4502 {
4503 id: "annotation_1",
4504 pageUrl: "https://example.com",
4505 pageTitle: "Example",
4506 comment: "Tighten this spacing",
4507 elements: [],
4508 regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }],
4509 strokes: [],
4510 styleChanges: [],
4511 screenshot: null,
4512 createdAt: "2026-06-11T00:00:00.000Z",
4513 },
4514 { x: 5, y: 6, width: 20, height: 30 },
4515 "attach",
4516 2,
4517 );
4518
4519 const result = yield* Fiber.join(pick);
4520 expect(capturePage).toHaveBeenCalledWith();
4521 expect(crop).toHaveBeenCalledWith({ x: 10, y: 12, width: 40, height: 60 });
4522 expect(result?.screenshotFailed).toBeUndefined();
4523 expect(result?.annotation.screenshot).toEqual({
4524 dataUrl: "data:image/png;base64,cropped",
4525 width: 40,
4526 height: 60,
4527 cropRect: { x: 5, y: 6, width: 20, height: 30 },
4528 });
4529 }),
4530 ),
4531 );
4532
4533 effectIt.effect("a stale capture from a replaced pick never touches the next pick", () =>
4534 withManager((manager) =>
4535 Effect.gen(function* () {
4536 let onPicked: ((event: unknown, ...args: unknown[]) => void) | undefined;
4537 fromId.mockReturnValue({
4538 id: 42,
4539 isDestroyed: () => false,
4540 getType: () => "webview",
4541 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
4542 getURL: () => "https://example.com",
4543 getTitle: () => "Example",
4544 isLoadingMainFrame: () => false,
4545 isFocused: () => true,
4546 getZoomFactor: () => 1,
4547 setZoomFactor: vi.fn(),
4548 setAudioMuted: vi.fn(),
4549 isCurrentlyAudible: () => false,
4550 on: vi.fn(),
4551 once: vi.fn(),
4552 off: vi.fn(),
4553 capturePage: vi.fn(() => new Promise(() => {})),
4554 ipc: {
4555 on: vi.fn((channel: string, listener: typeof onPicked) => {
4556 if (channel === "preview:element-picked") onPicked = listener;
4557 }),
4558 off: vi.fn(),
4559 removeListener: vi.fn(),
4560 },
4561 send: webviewSend,
4562 navigationHistory: { canGoBack: () => false, canGoForward: () => false },
4563 setIgnoreMenuShortcuts: vi.fn(),
4564 setWindowOpenHandler: vi.fn(),
4565 debugger: {
4566 isAttached: () => false,
4567 attach: vi.fn(),
4568 sendCommand: vi.fn(async () => undefined),
4569 on: vi.fn(),
4570 off: vi.fn(),
4571 },
4572 } as never);
4573 const annotation = {
4574 id: "annotation_1",
4575 pageUrl: "https://example.com",
4576 pageTitle: "Example",
4577 comment: "Tighten this spacing",
4578 elements: [],
4579 regions: [{ id: "region_1", rect: { x: 5, y: 6, width: 20, height: 30 } }],
4580 strokes: [],
4581 styleChanges: [],
4582 screenshot: null,
4583 createdAt: "2026-06-11T00:00:00.000Z",
4584 };
4585
4586 yield* manager.createTab("tab_1");
4587 yield* manager.registerWebview("tab_1", 42);
4588 const firstPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
4589 yield* Effect.yieldNow;
4590 // The first pick submits and its crop hangs.
4591 onPicked?.({}, annotation, null, "send");
4592 yield* Effect.yieldNow;
4593
4594 // A second pick on the same tab replaces the first, which resumes null.
4595 const secondPick = yield* manager.pickElement("tab_1").pipe(Effect.forkChild);
4596 yield* Effect.yieldNow;
4597 expect(yield* Fiber.join(firstPick)).toBeNull();
4598 webviewSend.mockClear();
4599
4600 // The first pick's crop times out while the second pick is live. It
4601 // must not signal the overlay, which would tear down the second pick.
4602 yield* TestClock.adjust("6 seconds");
4603 yield* Effect.yieldNow;
4604 expect(webviewSend).not.toHaveBeenCalledWith("preview:annotation-captured");
4605 expect(secondPick.pollUnsafe()).toBeUndefined();
4606
4607 onPicked?.({}, { ...annotation, id: "annotation_2" }, null, "attach");
4608 yield* TestClock.adjust("6 seconds");
4609 const result = yield* Fiber.join(secondPick);
4610 expect(result?.annotation.id).toBe("annotation_2");
4611 expect(result?.submission).toBe("attach");
4612 }),
4613 ),
4614 );
4615
4616 effectIt.effect("navigates the guest history when the thumb-button ipc fires", () =>
4617 withManager((manager) =>
4618 Effect.gen(function* () {
4619 let mouseNavigate: ((event: unknown, payload: unknown) => void) | undefined;
4620 const goBack = vi.fn();
4621 const goForward = vi.fn();
4622 let canGoBack = true;
4623 fromId.mockReturnValue({
4624 id: 42,
4625 isDestroyed: () => false,
4626 getType: () => "webview",
4627 getUserAgent: () => "Mozilla/5.0 Chrome/148.0.7734.19 Safari/537.36",
4628 getURL: () => "https://example.com",
4629 getTitle: () => "Example",
4630 isLoadingMainFrame: () => false,
4631 getZoomFactor: () => 1,
4632 setZoomFactor: vi.fn(),
4633 setAudioMuted: vi.fn(),
4634 isCurrentlyAudible: () => false,
4635 on: vi.fn(),
4636 off: vi.fn(),
4637 ipc: {
4638 on: vi.fn((channel: string, listener: typeof mouseNavigate) => {
4639 if (channel === "preview:mouse-navigate") mouseNavigate = listener;
4640 }),
4641 off: vi.fn(),
4642 },
4643 send: webviewSend,
4644 navigationHistory: {
4645 canGoBack: () => canGoBack,
4646 canGoForward: () => true,
4647 goBack,
4648 goForward,
4649 },
4650 setIgnoreMenuShortcuts: vi.fn(),
4651 setWindowOpenHandler: vi.fn(),
4652 debugger: {
4653 isAttached: () => false,
4654 attach: vi.fn(),
4655 sendCommand: vi.fn(async () => undefined),
4656 on: vi.fn(),
4657 off: vi.fn(),
4658 },
4659 } as never);
4660
4661 yield* manager.createTab("tab_nav");
4662 yield* manager.registerWebview("tab_nav", 42);
4663 expect(mouseNavigate).toBeDefined();
4664
4665 mouseNavigate?.({}, { direction: "back" });
4666 yield* Effect.yieldNow;
4667 expect(goBack).toHaveBeenCalledOnce();
4668
4669 mouseNavigate?.({}, { direction: "forward" });
4670 yield* Effect.yieldNow;
4671 expect(goForward).toHaveBeenCalledOnce();
4672
4673 // Ignores unknown payloads and never navigates when history is exhausted.
4674 mouseNavigate?.({}, { direction: "sideways" });
4675 canGoBack = false;
4676 mouseNavigate?.({}, { direction: "back" });
4677 yield* Effect.yieldNow;
4678 expect(goBack).toHaveBeenCalledOnce();
4679 }),
4680 ),
4681 );
4682
4683 effectIt.effect("reveals only files inside the configured browser artifact directory", () =>
4684 withManager((manager) =>
4685 Effect.gen(function* () {
4686 yield* manager.revealArtifact("/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png");
4687
4688 expect(showItemInFolder).toHaveBeenCalledWith(
4689 "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png",
4690 );
4691 const exit = yield* Effect.exit(manager.revealArtifact("/tmp/t3/dev/settings.json"));
4692 expect(Exit.isFailure(exit)).toBe(true);
4693 if (Exit.isSuccess(exit)) return;
4694 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
4695 expect(error).toMatchObject({
4696 _tag: "PreviewArtifactPathOutsideDirectoryError",
4697 artifactPath: "/tmp/t3/dev/settings.json",
4698 artifactDirectory: "/tmp/t3/dev/browser-artifacts",
4699 });
4700 expect("cause" in error).toBe(false);
4701 }),
4702 ),
4703 );
4704
4705 effectIt.effect("copies screenshot artifacts to the system clipboard", () =>
4706 withManager((manager) =>
4707 Effect.gen(function* () {
4708 const artifactPath = "/tmp/t3/dev/browser-artifacts/browser-screenshot-test.png";
4709
4710 yield* manager.copyArtifactToClipboard(artifactPath);
4711
4712 expect(createFromPath).toHaveBeenCalledWith(artifactPath);
4713 expect(clipboardItemConstructor).toHaveBeenCalledWith({
4714 "image/png": expect.any(Blob),
4715 });
4716 expect(writeClipboard).toHaveBeenCalledOnce();
4717 const exit = yield* Effect.exit(
4718 manager.copyArtifactToClipboard("/tmp/t3/dev/settings.json"),
4719 );
4720 expect(Exit.isFailure(exit)).toBe(true);
4721 if (Exit.isSuccess(exit)) return;
4722 const error = Option.getOrThrow(Cause.findErrorOption(exit.cause));
4723 expect(error).toMatchObject({
4724 _tag: "PreviewArtifactPathOutsideDirectoryError",
4725 artifactPath: "/tmp/t3/dev/settings.json",
4726 artifactDirectory: "/tmp/t3/dev/browser-artifacts",
4727 });
4728 expect("cause" in error).toBe(false);
4729
4730 createFromPath.mockReturnValueOnce({
4731 isEmpty: () => true,
4732 toPNG: () => Buffer.from("invalid"),
4733 });
4734 const invalidImageExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath));
4735 expect(Exit.isFailure(invalidImageExit)).toBe(true);
4736 if (Exit.isSuccess(invalidImageExit)) return;
4737 expect(Option.getOrThrow(Cause.findErrorOption(invalidImageExit.cause))).toMatchObject({
4738 _tag: "PreviewArtifactImageLoadError",
4739 artifactPath,
4740 });
4741
4742 const writeCause = new Error("clipboard write failed");
4743 writeClipboard.mockRejectedValueOnce(writeCause);
4744 const writeExit = yield* Effect.exit(manager.copyArtifactToClipboard(artifactPath));
4745 expect(Exit.isFailure(writeExit)).toBe(true);
4746 if (Exit.isSuccess(writeExit)) return;
4747 expect(Option.getOrThrow(Cause.findErrorOption(writeExit.cause))).toMatchObject({
4748 _tag: "PreviewOperationError",
4749 operation: "copyArtifactToClipboard.write",
4750 artifactPath,
4751 cause: writeCause,
4752 });
4753 }),
4754 ),
4755 );
4756});
4757
4758describe("PreviewOperationError", () => {
4759 it("keeps timeline detail separate from its structured message", () => {
4760 const cause = new Error("CDP command failed with an invalid node id");
4761 const error = new PreviewManager.PreviewOperationError({
4762 operation: "click.DOM.resolveNode",
4763 tabId: "tab_1",
4764 webContentsId: 42,
4765 cause,
4766 });
4767
4768 expect(error.message).not.toContain(cause.message);
4769 expect(PreviewManager.PreviewOperationError.toTimelineMessage(error)).toBe(cause.message);
4770 });
4771});