1import * as NodeEvents from "node:events";
2
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { it } from "@effect/vitest";
5import {
6 EnvironmentId,
7 PreviewTabId,
8 ProviderInstanceId,
9 ThreadId,
10 type PreviewAutomationSnapshot,
11 type PreviewAutomationStatus,
12} from "@t3tools/contracts";
13import * as Clock from "effect/Clock";
14import * as Effect from "effect/Effect";
15import * as Fiber from "effect/Fiber";
16import * as FileSystem from "effect/FileSystem";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as PubSub from "effect/PubSub";
20import * as Queue from "effect/Queue";
21import * as Stream from "effect/Stream";
22import type { BrowserContext, Page } from "playwright-core";
23import { beforeEach, expect, vi } from "vite-plus/test";
24
25import * as ServerConfig from "../config.ts";
26import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
27import * as Broker from "../mcp/PreviewAutomationBroker.ts";
28import * as DesktopChannel from "./DesktopBrowserChannel.ts";
29import * as Manager from "./Manager.ts";
30import * as ServerBrowser from "./ServerBrowser.ts";
31import * as PreviewBrowser from "./PreviewBrowser.ts";
32
33// Keep the manager, broker, ownership, refs, and viewer paths real; replace Chromium I/O only.
34vi.mock("./ServerBrowserContexts.ts", () => ({
35 presentAsChrome: async () => {},
36 ServerBrowserContexts: class {
37 private readonly onClose: ((context: BrowserContext) => void) | undefined;
38 constructor(options: { onContextClose?: (context: BrowserContext) => void }) {
39 this.onClose = options.onContextClose;
40 }
41 async contextFor(profileId: string, isolationKey?: string) {
42 contextRequests.push({ profileId, isolated: isolationKey !== undefined });
43 if (contextFailure) throw contextFailure;
44 await contextGate?.promise;
45 const context = makeContext(this.onClose);
46 contexts.push(context);
47 return context as unknown as BrowserContext;
48 }
49 async scratchPage() {
50 return makeContext().page as unknown as Page;
51 }
52 async connectDesktopPage(endpoint: string) {
53 const context = makeContext();
54 desktopConnections.push({ endpoint, context });
55 return { browser: { close: async () => {} }, page: context.page as unknown as Page };
56 }
57 async close() {
58 for (const context of contexts) await context.close();
59 }
60 },
61}));
62
63/** A frame in its own process. Only its own session reaches the copy it holds. */
64function makeFrame() {
65 const frame = {
66 copy: "",
67 detached: false,
68 isDetached: () => frame.detached,
69 recording: Promise.withResolvers<void>(),
70 };
71 return frame;
72}
73
74function makeSession(frame?: ReturnType<typeof makeFrame>) {
75 const events = new NodeEvents.EventEmitter();
76 return {
77 on: vi.fn((name: string, listener: () => void) => events.on(name, listener)),
78 emit: (name: string) => events.emit(name),
79 detach: vi.fn(async () => {}),
80 send: vi.fn(async (method: string, input?: unknown): Promise<Record<string, unknown>> => {
81 if (method === "Page.getNavigationHistory") return { currentIndex: 0, entries: [{}] };
82 if (method === "Page.getLayoutMetrics") return { cssVisualViewport: { pageX: 0, pageY: 0 } };
83 if (method === "Page.captureScreenshot") return { data: "ZnJhbWU=" };
84 if (method === "Page.getFrameTree") return { frameTree: { frame: { id: "main" } } };
85 if (method === "Page.createIsolatedWorld") return { executionContextId: 7 };
86 if (method === "Page.addScriptToEvaluateOnNewDocument") frame?.recording.resolve();
87 // A copy or cut key copies keyCopy in the focused frame, or the page's process if none;
88 // one that copies nothing leaves the frames as they were.
89 if (method === "Input.dispatchKeyEvent" && (input as { commands?: unknown }).commands) {
90 if (focusedFrame) focusedFrame.copy = keyCopy || focusedFrame.copy;
91 else pageCopy = keyCopy || pageCopy;
92 }
93 // The clipboard world hands over what the session's frames copied, once.
94 if (method === "Runtime.evaluate" && (input as { contextId?: number }).contextId === 7) {
95 const value = frame ? frame.copy : pageCopy;
96 if (frame) frame.copy = "";
97 else pageCopy = "";
98 return { result: { value } };
99 }
100 return { result: { value: "evaluated" } };
101 }),
102 };
103}
104
105function makeContext(onClose?: (context: BrowserContext) => void) {
106 const events = new NodeEvents.EventEmitter();
107 const sessions: ReturnType<typeof makeSession>[] = [];
108 let url = "about:blank";
109 let viewport = { width: 1280, height: 800 };
110 let closed = false;
111 let contextClosed = false;
112 const page = {
113 on: (name: string, callback: (...args: unknown[]) => void) => events.on(name, callback),
114 once: (name: string, callback: (...args: unknown[]) => void) => events.once(name, callback),
115 off: (name: string, callback: (...args: unknown[]) => void) => events.off(name, callback),
116 emit: (name: string, ...args: unknown[]) => events.emit(name, ...args),
117 emitAsync: (name: string, ...args: unknown[]) =>
118 Promise.all(events.listeners(name).map((listener) => listener(...args))),
119 setDefaultTimeout: vi.fn(),
120 setDefaultNavigationTimeout: vi.fn(),
121 context: () => context,
122 mainFrame: () => page,
123 frames: () => [page],
124 url: () => url,
125 title: vi.fn(async () => "test page"),
126 viewportSize: () => viewport,
127 setViewportSize: vi.fn(async (size: typeof viewport) => {
128 viewport = size;
129 }),
130 goto: vi.fn(async (next: string) => {
131 url = next;
132 events.emit("load");
133 }),
134 goBack: vi.fn(async () => {}),
135 goForward: vi.fn(async () => {}),
136 reload: vi.fn(async () => {}),
137 emulateMedia: vi.fn(async () => {}),
138 waitForLoadState: vi.fn(async () => {}),
139 evaluate: vi.fn(async () => ({
140 url,
141 title: "test page",
142 loading: false,
143 visibleText: "delete",
144 interactiveElements: [],
145 })),
146 ariaSnapshot: vi.fn(async () => '- button "delete" [ref=e1]'),
147 locator: vi.fn(() => {
148 throw new Error("Unexpected locator action");
149 }),
150 isClosed: () => closed,
151 close: vi.fn(async () => {
152 if (!closed) {
153 closed = true;
154 events.emit("close");
155 }
156 }),
157 };
158 const context = {
159 page,
160 sessions,
161 newPage: async () => page as unknown as Page,
162 grantPermissions: vi.fn(async () => {}),
163 newCDPSession: async (target: unknown) => {
164 const session = makeSession(
165 target === page ? undefined : (target as ReturnType<typeof makeFrame>),
166 );
167 sessions.push(session);
168 return session;
169 },
170 close: vi.fn(async () => {
171 if (contextClosed) return;
172 contextClosed = true;
173 await page.close();
174 onClose?.(context as unknown as BrowserContext);
175 }),
176 };
177 return context;
178}
179
180const contexts: ReturnType<typeof makeContext>[] = [];
181/** The profile and isolation each headless tab asked its context for. */
182const contextRequests: Array<{ profileId: string; isolated: boolean }> = [];
183let contextGate: PromiseWithResolvers<void> | null = null;
184/** The text the page's frames hold from their latest copy. */
185let pageCopy = "";
186/** The text the next copy or cut key copies. */
187let keyCopy = "";
188/** The frame in its own process that has focus; otherwise keys copy in the page's process. */
189let focusedFrame: ReturnType<typeof makeFrame> | null = null;
190let contextFailure: Error | null = null;
191/** Server tabs the fake desktop renders, and the endpoints the server connected to. */
192let desktopRendersNext = false;
193/** Pages the fake desktop takes back or returns; the channel's streams emit them. */
194const desktopDetaches = new NodeEvents.EventEmitter();
195const desktopTabs = new Set<string>();
196const desktopRenders = (tabId: string) => {
197 if (desktopRendersNext) {
198 desktopRendersNext = false;
199 desktopTabs.add(tabId);
200 }
201 return desktopTabs.has(tabId);
202};
203const releasedDesktopTabs: Array<string> = [];
204const desktopConnections: Array<{ endpoint: string; context: ReturnType<typeof makeContext> }> = [];
205const testThread = {
206 threadId: ThreadId.make("browser-test-thread"),
207 providerSessionId: "agent-a",
208 providerInstanceId: ProviderInstanceId.make("codex"),
209};
210const scope = {
211 environmentId: EnvironmentId.make("browser-test-environment"),
212 thread: testThread,
213 client: undefined,
214 requestNamespace: "browser-test",
215 capabilities: new Set(["preview"] as const),
216 issuedAt: 1,
217};
218/** The same thread, as another of its agent sessions. */
219const asSession = (providerSessionId: string) => ({
220 ...scope,
221 thread: { ...testThread, providerSessionId },
222});
223const dependencies = Layer.mergeAll(
224 Broker.layer,
225 Manager.layer,
226 Layer.succeed(ServerEnvironment.ServerEnvironment, {
227 getEnvironmentId: Effect.succeed(scope.environmentId),
228 getDescriptor: Effect.die("unused descriptor"),
229 }),
230 Layer.succeed(PreviewBrowser.PreviewBrowser, {
231 executable: Effect.die("mock Chromium does not need an executable"),
232 installed: Effect.die("mock Chromium does not need an executable"),
233 }),
234 Layer.succeed(DesktopChannel.DesktopBrowserChannel, {
235 // Only tabs a test marks render on the desktop; the rest stay headless.
236 available: true,
237 awaitAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)),
238 detached: Stream.callback<{ threadId: string; tabId: string }>((queue) =>
239 Effect.acquireRelease(
240 Effect.sync(() => {
241 const onDetach = (key: { threadId: string; tabId: string }) =>
242 Queue.offerUnsafe(queue, key);
243 desktopDetaches.on("detach", onDetach);
244 return onDetach;
245 }),
246 (onDetach) => Effect.sync(() => desktopDetaches.off("detach", onDetach)),
247 ),
248 ),
249 attached: Stream.callback<{ threadId: string; tabId: string }>((queue) =>
250 Effect.acquireRelease(
251 Effect.sync(() => {
252 const onAttach = (key: { threadId: string; tabId: string }) =>
253 Queue.offerUnsafe(queue, key);
254 desktopDetaches.on("attach", onAttach);
255 return onAttach;
256 }),
257 (onAttach) => Effect.sync(() => desktopDetaches.off("attach", onAttach)),
258 ),
259 ),
260 isAttached: (key) => Effect.sync(() => desktopRenders(key.tabId)),
261 endpoint: (key) =>
262 Effect.acquireRelease(Effect.succeed(`ws://desktop/${key.tabId}`), () =>
263 Effect.sync(() => releasedDesktopTabs.push(key.tabId)),
264 ),
265 pointer: () => Effect.void,
266 }),
267).pipe(
268 Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "t3-server-browser-" })),
269 Layer.provideMerge(NodeServices.layer),
270);
271const layer = ServerBrowser.layer.pipe(Layer.provideMerge(dependencies));
272const ready = Effect.gen(function* () {
273 const browser = yield* ServerBrowser.ServerBrowser;
274 const broker = yield* Broker.PreviewAutomationBroker;
275 yield* Effect.yieldNow;
276 const opened = yield* broker.invoke<PreviewAutomationStatus>({
277 scope,
278 operation: "open",
279 input: { reuseExistingTab: false, show: false },
280 });
281 const tabId = PreviewTabId.make(opened.tabId!);
282 return { browser, broker, tabId };
283});
284/** Fills a viewer's output the way a viewer that stopped reading leaves it. */
285const stallViewer = (
286 viewer: ServerBrowser.ServerBrowserViewer,
287 item: ServerBrowser.ServerBrowserViewerOutput,
288) => {
289 // The service hands out the read side of a queue it also writes to.
290 const output = viewer.output as unknown as Queue.Queue<ServerBrowser.ServerBrowserViewerOutput>;
291 while (Queue.offerUnsafe(output, item));
292};
293const viewerInput = (tabId: string, canOperate: boolean) => ({
294 threadId: scope.thread.threadId,
295 tabId,
296 canOperate,
297 maxWidth: 1280,
298 maxHeight: 800,
299 quality: 70,
300});
301
302beforeEach(() => {
303 contexts.length = 0;
304 contextRequests.length = 0;
305 contextGate = null;
306 contextFailure = null;
307 pageCopy = "";
308 keyCopy = "";
309 focusedFrame = null;
310 desktopTabs.clear();
311 desktopRendersNext = false;
312 releasedDesktopTabs.length = 0;
313 desktopConnections.length = 0;
314});
315
316it.live("readiness none responds immediately but takeover input waits for navigation commit", () =>
317 Effect.scoped(
318 Effect.gen(function* () {
319 const { browser, broker, tabId } = yield* ready;
320 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
321 const committed = Promise.withResolvers<void>();
322 const events: string[] = [];
323 contexts[0]!.page.goto.mockImplementationOnce(async () => {
324 await committed.promise;
325 events.push("navigation committed");
326 });
327 yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve()));
328 const response = yield* broker.invoke<PreviewAutomationStatus>({
329 scope,
330 tabId,
331 operation: "navigate",
332 input: { url: "http://localhost:5173/next", readiness: "none" },
333 });
334 expect(response.available).toBe(true);
335 expect(events).toEqual([]);
336 yield* Queue.clear(viewer.output);
337 const takeover = yield* viewer.input({ type: "takeControl" }).pipe(Effect.forkScoped);
338 let control = yield* Queue.take(viewer.output);
339 while (control._tag !== "control" || control.controller !== "you") {
340 control = yield* Queue.take(viewer.output);
341 }
342 const cdp = contexts[0]!.sessions.at(-1)!;
343 const send = cdp.send.getMockImplementation()!;
344 cdp.send.mockImplementation(async (operation, input) => {
345 if (operation === "Input.insertText") events.push("human typed");
346 return send(operation, input);
347 });
348 const typing = yield* viewer.input({ type: "text", text: "hello" }).pipe(Effect.forkScoped);
349 yield* broker.invoke({ scope, tabId, operation: "status", input: {} });
350 expect(events).toEqual([]);
351 committed.resolve();
352 yield* Fiber.join(takeover);
353 yield* Fiber.join(typing);
354 expect(events).toEqual(["navigation committed", "human typed"]);
355 }),
356 ).pipe(Effect.provide(layer)),
357);
358
359it.live.each([
360 { method: "goto" as const, message: { type: "navigate", url: "http://localhost:5173/next" } },
361 { method: "goBack" as const, message: { type: "history", delta: -1 } },
362 { method: "goForward" as const, message: { type: "history", delta: 1 } },
363 { method: "reload" as const, message: { type: "reload" } },
364])("release waits for viewer $method to commit before agent actions", ({ method, message }) =>
365 Effect.scoped(
366 Effect.gen(function* () {
367 const { browser, broker, tabId } = yield* ready;
368 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
369 yield* viewer.input({ type: "takeControl" });
370 yield* Queue.clear(viewer.output);
371 const started = Promise.withResolvers<void>();
372 const committed = Promise.withResolvers<void>();
373 const events: string[] = [];
374 contexts[0]!.page[method].mockImplementationOnce(async () => {
375 started.resolve();
376 await committed.promise;
377 events.push("navigation committed");
378 });
379 const navigate = yield* viewer.input(message).pipe(Effect.forkScoped);
380 yield* Effect.addFinalizer(() => Effect.sync(() => committed.resolve()));
381 yield* Effect.promise(() => started.promise);
382 const releasing = yield* viewer.input({ type: "releaseControl" }).pipe(Effect.forkScoped);
383 let control = yield* Queue.take(viewer.output);
384 while (control._tag !== "control" || control.controller !== "agent") {
385 control = yield* Queue.take(viewer.output);
386 }
387 const cdp = contexts[0]!.sessions[0]!;
388 const send = cdp.send.getMockImplementation()!;
389 cdp.send.mockImplementation(async (operation, input) => {
390 if (operation === "Runtime.evaluate") events.push("agent acted");
391 return send(operation, input);
392 });
393 const resumed = yield* broker
394 .invoke({
395 scope,
396 tabId,
397 operation: "evaluate",
398 input: { expression: "resumed()" },
399 })
400 .pipe(Effect.forkScoped);
401 yield* broker.invoke({ scope, tabId, operation: "status", input: {} });
402 expect(events).toEqual([]);
403 committed.resolve();
404 yield* Fiber.join(navigate);
405 yield* Fiber.join(releasing);
406 yield* Fiber.join(resumed);
407 expect(events).toEqual(["navigation committed", "agent acted"]);
408 const calls = contexts[0]!.page[method].mock.calls;
409 expect(calls[0]?.at(-1)).toMatchObject({ waitUntil: "commit", timeout: 15_000 });
410 }),
411 ).pipe(Effect.provide(layer)),
412);
413
414it.live("enforces provider ownership and explicit targets when a session has multiple tabs", () =>
415 Effect.scoped(
416 Effect.gen(function* () {
417 const { broker, tabId } = yield* ready;
418 const foreign = yield* broker
419 .invoke<void>({
420 scope: asSession("agent-b"),
421 tabId,
422 operation: "navigate",
423 input: { url: "http://localhost:5173/foreign" },
424 })
425 .pipe(Effect.flip);
426 expect(foreign).toMatchObject({
427 _tag: "PreviewAutomationControlInterruptedError",
428 reason: "agentMismatch",
429 });
430 expect(contexts[0]!.page.goto).not.toHaveBeenCalledWith(
431 "http://localhost:5173/foreign",
432 expect.anything(),
433 );
434 // Another session may still read the tab, but not run page script in it.
435 yield* broker.invoke<PreviewAutomationSnapshot>({
436 scope: asSession("agent-b"),
437 tabId,
438 operation: "snapshot",
439 input: {},
440 });
441 const foreignEvaluate = yield* broker
442 .invoke<void>({
443 scope: asSession("agent-b"),
444 tabId,
445 operation: "evaluate",
446 input: { expression: "read()" },
447 })
448 .pipe(Effect.flip);
449 expect(foreignEvaluate).toMatchObject({
450 _tag: "PreviewAutomationControlInterruptedError",
451 reason: "agentMismatch",
452 });
453 yield* broker.invoke({
454 scope,
455 operation: "open",
456 input: { reuseExistingTab: false, show: false },
457 });
458 const ambiguous = yield* broker
459 .invoke<void>({ scope, operation: "evaluate", input: { expression: "ambiguous()" } })
460 .pipe(Effect.flip);
461 expect(ambiguous).toMatchObject({
462 _tag: "PreviewAutomationControlInterruptedError",
463 reason: "tabRequired",
464 });
465 const ambiguousStop = yield* broker
466 .invoke<void>({ scope, operation: "recordingStop", input: {} })
467 .pipe(Effect.flip);
468 expect(ambiguousStop).toMatchObject({
469 _tag: "PreviewAutomationControlInterruptedError",
470 reason: "tabRequired",
471 });
472 const explicitStop = yield* broker
473 .invoke<void>({ scope, tabId, operation: "recordingStop", input: {} })
474 .pipe(Effect.flip);
475 expect(explicitStop).toMatchObject({
476 _tag: "PreviewAutomationExecutionError",
477 cause: { _tag: "PreviewAutomationRecordingNotActiveError" },
478 });
479 const result = yield* broker.invoke({
480 scope,
481 tabId,
482 operation: "evaluate",
483 input: { expression: "owned()" },
484 });
485 expect(result).toBe("evaluated");
486 expect(contexts).toHaveLength(2);
487 }),
488 ).pipe(Effect.provide(layer)),
489);
490
491it.live("streams to a read-only viewer without allowing takeover, input, or viewport changes", () =>
492 Effect.scoped(
493 Effect.gen(function* () {
494 const { browser, tabId } = yield* ready;
495 const viewer = yield* browser.attachViewer(viewerInput(tabId, false));
496 const page = contexts[0]!.page;
497 page.setViewportSize.mockClear();
498 const session = contexts[0]!.sessions.at(-1)!;
499 session.send.mockClear();
500 for (const message of [
501 { type: "takeControl" },
502 { type: "key", action: "down", key: "a", text: "a" },
503 { type: "resize", width: 390, height: 844 },
504 { type: "viewport", setting: { _tag: "freeform", width: 390, height: 844 } },
505 ])
506 yield* viewer.input(message);
507 expect(page.setViewportSize).not.toHaveBeenCalled();
508 expect(session.send.mock.calls.some(([method]) => method.startsWith("Input."))).toBe(false);
509 const outputs = yield* Queue.takeAll(viewer.output);
510 expect(outputs).toContainEqual(
511 expect.objectContaining({ _tag: "frame", data: Buffer.from("frame") }),
512 );
513 expect(outputs).toContainEqual(expect.objectContaining({ _tag: "viewport" }));
514 expect(outputs).toContainEqual(
515 expect.objectContaining({ _tag: "control", canOperate: false, controller: "agent" }),
516 );
517 const operator = yield* browser.attachViewer(viewerInput(tabId, true));
518 yield* operator.input({ type: "takeControl" });
519 yield* operator.input({ type: "text", text: "typed" });
520 yield* operator.input({ type: "resize", width: 390, height: 844 });
521 expect(contexts[0]!.sessions.at(-1)!.send).toHaveBeenCalledWith("Input.insertText", {
522 text: "typed",
523 });
524 expect(page.setViewportSize).toHaveBeenCalledWith({ width: 390, height: 844 });
525 yield* operator.input({ type: "releaseControl" });
526 }),
527 ).pipe(Effect.provide(layer)),
528);
529
530it.live("applies any client's viewport, appearance, and zoom to a headless tab", () =>
531 Effect.scoped(
532 Effect.gen(function* () {
533 const { tabId } = yield* ready;
534 const manager = yield* Manager.PreviewManager;
535 const page = contexts[0]!.page;
536 const session = contexts[0]!.sessions[0]!;
537 const target = { threadId: scope.thread.threadId, tabId };
538 // The agent owns this tab, and the client sends no takeover first.
539 yield* manager.resize({ ...target, viewport: { _tag: "freeform", width: 390, height: 844 } });
540 yield* manager.adjust({ ...target, colorScheme: "dark", zoomFactor: 1.25 });
541 // Settings apply in order, so the reload landing means the earlier ones did too.
542 const reloaded = Promise.withResolvers<void>();
543 const send = session.send.getMockImplementation()!;
544 session.send.mockImplementation(async (method, input) => {
545 if (method === "Page.reload") reloaded.resolve();
546 return send(method, input);
547 });
548 yield* manager.adjust({ ...target, hardReload: true });
549 yield* Effect.promise(() => reloaded.promise);
550 expect(session.send).toHaveBeenCalledWith("Page.reload", { ignoreCache: true });
551 expect(page.setViewportSize).toHaveBeenCalledWith({ width: 390, height: 844 });
552 expect(page.emulateMedia).toHaveBeenCalledWith({ colorScheme: "dark" });
553 // Zoom lays the page out in fewer CSS pixels and draws each one larger.
554 expect(session.send).toHaveBeenCalledWith("Emulation.setDeviceMetricsOverride", {
555 width: 312,
556 height: 675,
557 deviceScaleFactor: 2.5,
558 mobile: false,
559 });
560 }),
561 ).pipe(Effect.provide(layer)),
562);
563
564it.live(
565 "takeover waits for the running agent and revokes snapshot refs before returning control",
566 () =>
567 Effect.scoped(
568 Effect.gen(function* () {
569 const { browser, broker, tabId } = yield* ready;
570 const snapshot = yield* broker.invoke<PreviewAutomationSnapshot>({
571 scope,
572 tabId,
573 operation: "snapshot",
574 input: {},
575 });
576 const ref = /\[ref=([^\]]+)\]/.exec(String(snapshot.accessibilityTree))![1]!;
577 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
578 const started = Promise.withResolvers<void>();
579 const finish = Promise.withResolvers<Record<string, unknown>>();
580 const session = contexts[0]!.sessions[0]!;
581 session.send.mockImplementationOnce(async () => {
582 started.resolve();
583 return finish.promise;
584 });
585 const running = yield* broker
586 .invoke({ scope, tabId, operation: "evaluate", input: { expression: "pending()" } })
587 .pipe(Effect.forkScoped);
588 yield* Effect.addFinalizer(() =>
589 Effect.sync(() => finish.resolve({ result: { value: "finished" } })),
590 );
591 yield* Effect.promise(() => started.promise);
592 const takeover = yield* viewer.input({ type: "takeControl" }).pipe(Effect.forkScoped);
593 let control = yield* Queue.take(viewer.output);
594 while (control._tag !== "control" || control.controller !== "you") {
595 control = yield* Queue.take(viewer.output);
596 }
597 const rejected = yield* broker
598 .invoke<void>({ scope, tabId, operation: "evaluate", input: { expression: "racing()" } })
599 .pipe(Effect.flip);
600 expect(rejected).toMatchObject({
601 _tag: "PreviewAutomationControlInterruptedError",
602 reason: "humanControl",
603 });
604 finish.resolve({ result: { value: "finished" } });
605 expect(yield* Fiber.join(running)).toBe("finished");
606 yield* Fiber.join(takeover);
607 yield* viewer.input({ type: "releaseControl" });
608 const stale = yield* broker
609 .invoke<void>({ scope, tabId, operation: "click", input: { locator: `aria-ref=${ref}` } })
610 .pipe(Effect.flip);
611 expect(stale._tag).toBe("PreviewAutomationInvalidSelectorError");
612 expect(contexts[0]!.page.locator).not.toHaveBeenCalled();
613 expect(
614 yield* broker.invoke({
615 scope,
616 tabId,
617 operation: "evaluate",
618 input: { expression: "resumed()" },
619 }),
620 ).toBe("evaluated");
621 }),
622 ).pipe(Effect.provide(layer)),
623);
624
625it.live("reports a pending dialog without evaluating the page and resolves it explicitly", () =>
626 Effect.scoped(
627 Effect.gen(function* () {
628 const { broker, tabId } = yield* ready;
629 const page = contexts[0]!.page;
630 yield* broker.invoke({
631 scope,
632 tabId,
633 operation: "navigate",
634 input: { url: "http://localhost:5173" },
635 });
636 const started = Promise.withResolvers<void>();
637 const resolvedEvaluation = Promise.withResolvers<Record<string, unknown>>();
638 const session = contexts[0]!.sessions[0]!;
639 const send = session.send.getMockImplementation()!;
640 session.send.mockImplementation(async (method, input) => {
641 if (method !== "Runtime.evaluate") return send(method, input);
642 started.resolve();
643 return resolvedEvaluation.promise;
644 });
645 const blockedAction = yield* broker
646 .invoke({
647 scope,
648 tabId,
649 operation: "evaluate",
650 input: { expression: 'confirm("Delete row?")' },
651 })
652 .pipe(Effect.forkScoped);
653 yield* Effect.addFinalizer(() =>
654 Effect.sync(() => resolvedEvaluation.resolve({ result: { value: true } })),
655 );
656 yield* Effect.promise(() => started.promise);
657 const dialog = {
658 type: () => "confirm",
659 message: () => "Delete row?",
660 defaultValue: () => "",
661 accept: vi.fn(async () => {
662 resolvedEvaluation.resolve({ result: { value: true } });
663 }),
664 dismiss: vi.fn(async () => {}),
665 };
666 page.emit("dialog", dialog);
667 page.title.mockClear();
668 const status = yield* broker.invoke<PreviewAutomationStatus>({
669 scope,
670 tabId,
671 operation: "status",
672 input: {},
673 });
674 expect(status.dialog).toMatchObject({ type: "confirm", message: "Delete row?" });
675 expect(page.title).not.toHaveBeenCalled();
676 expect(dialog.dismiss).not.toHaveBeenCalled();
677 const reuse = yield* broker
678 .invoke<void>({
679 scope,
680 tabId,
681 operation: "open",
682 input: { url: "http://localhost:5173/other" },
683 })
684 .pipe(Effect.flip);
685 expect(reuse).toMatchObject({
686 _tag: "PreviewAutomationControlInterruptedError",
687 reason: "dialogPending",
688 });
689 expect(page.goto).toHaveBeenCalledTimes(1);
690 yield* broker.invoke({ scope, tabId, operation: "dialog", input: { accept: true } });
691 expect(yield* Fiber.join(blockedAction)).toBe(true);
692 expect(dialog.accept).toHaveBeenCalledTimes(1);
693 const resolved = yield* broker.invoke<PreviewAutomationStatus>({
694 scope,
695 tabId,
696 operation: "status",
697 input: {},
698 });
699 expect(resolved.dialog).toBeNull();
700 }),
701 ).pipe(Effect.provide(layer)),
702);
703
704it.live("the owner can close a tab while an agent action waits on its dialog", () =>
705 Effect.scoped(
706 Effect.gen(function* () {
707 const { broker, tabId } = yield* ready;
708 const page = contexts[0]!.page;
709 const started = Promise.withResolvers<void>();
710 const finish = Promise.withResolvers<Record<string, unknown>>();
711 contexts[0]!.sessions[0]!.send.mockImplementationOnce(async () => {
712 started.resolve();
713 return finish.promise;
714 });
715 page.on("close", () => finish.resolve({ result: { value: "closed" } }));
716 const running = yield* broker
717 .invoke({
718 scope,
719 tabId,
720 operation: "evaluate",
721 input: { expression: 'confirm("Delete?")' },
722 })
723 .pipe(Effect.forkScoped);
724 yield* Effect.addFinalizer(() =>
725 Effect.sync(() => finish.resolve({ result: { value: "cleanup" } })),
726 );
727 yield* Effect.promise(() => started.promise);
728 page.emit("dialog", {
729 type: () => "confirm",
730 message: () => "Delete?",
731 defaultValue: () => "",
732 accept: vi.fn(),
733 dismiss: vi.fn(),
734 });
735 const foreign = yield* broker
736 .invoke<void>({
737 scope: asSession("agent-b"),
738 tabId,
739 operation: "close",
740 input: {},
741 })
742 .pipe(Effect.flip);
743 expect(foreign).toMatchObject({
744 _tag: "PreviewAutomationControlInterruptedError",
745 reason: "agentMismatch",
746 });
747 expect(page.close).not.toHaveBeenCalled();
748 yield* broker.invoke({ scope, tabId, operation: "close", input: {} });
749 expect(yield* Fiber.join(running)).toBe("closed");
750 expect(page.close).toHaveBeenCalled();
751 const manager = yield* Manager.PreviewManager;
752 expect((yield* manager.list({ threadId: scope.thread.threadId })).sessions).toHaveLength(0);
753 const afterClose = yield* broker
754 .invoke<void>({ scope, tabId, operation: "evaluate", input: { expression: "late()" } })
755 .pipe(Effect.flip);
756 expect(afterClose._tag).toBe("PreviewAutomationTabNotFoundError");
757 }),
758 ).pipe(Effect.provide(layer)),
759);
760
761it.live("a popup becomes the agent's own tab and keeps its opener page", () =>
762 Effect.scoped(
763 Effect.gen(function* () {
764 const { broker, tabId } = yield* ready;
765 const opener = contexts[0]!.page;
766 const popup = makeContext();
767 opener.emit("popup", popup.page);
768 const manager = yield* Manager.PreviewManager;
769 let sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions;
770 while (sessions.length < 2) {
771 yield* Effect.sleep("5 millis");
772 sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions;
773 }
774 const popupTab = sessions.find((session) => session.tabId !== tabId)!;
775 const opened = sessions.find((session) => session.tabId === tabId)!;
776 expect(popupTab).toMatchObject({ automationOwner: opened.automationOwner, reveal: false });
777 const status = yield* broker.invoke<PreviewAutomationStatus>({
778 scope,
779 tabId,
780 operation: "status",
781 input: {},
782 });
783 expect(status.tabs).toEqual(
784 expect.arrayContaining([
785 expect.objectContaining({ tabId }),
786 expect.objectContaining({ tabId: popupTab.tabId, openerTabId: tabId }),
787 ]),
788 );
789 expect(opener.goto).not.toHaveBeenCalled();
790 expect(opener.close).not.toHaveBeenCalled();
791 // The page the popup script holds is the tab, so closing it ends the tab.
792 yield* Effect.promise(() => popup.page.close());
793 while ((yield* manager.list({ threadId: scope.thread.threadId })).sessions.length > 1) {
794 yield* Effect.sleep("5 millis");
795 }
796 }),
797 ).pipe(Effect.provide(layer)),
798);
799
800it.live("a tab opened to a file the browser cannot show reports the file to download", () =>
801 Effect.scoped(
802 Effect.gen(function* () {
803 const browser = yield* ServerBrowser.ServerBrowser;
804 const manager = yield* Manager.PreviewManager;
805 const events = yield* manager.subscribeEvents;
806 yield* Effect.yieldNow;
807 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
808 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
809 const page = contexts[0]!.page;
810 const pdf = "https://example.com/paper.pdf";
811 const request = { url: () => pdf, method: () => "GET", isNavigationRequest: () => true };
812 // What Chromium reports when a navigation turns into a download.
813 page.emit("requestfailed", {
814 ...request,
815 frame: () => page,
816 failure: () => ({ errorText: "net::ERR_ABORTED" }),
817 });
818 // Stands in for Chromium, which writes the file itself.
819 const saved = yield* Queue.unbounded<string>();
820 const written = Promise.withResolvers<void>();
821 page.emit("download", {
822 failure: async () => null,
823 saveAs: (path: string) => {
824 Queue.offerUnsafe(saved, path);
825 return written.promise;
826 },
827 suggestedFilename: () => "paper.pdf",
828 url: () => pdf,
829 });
830 const fs = yield* FileSystem.FileSystem;
831 yield* fs.writeFileString(yield* Queue.take(saved), "%PDF");
832 written.resolve();
833 let status = yield* PubSub.take(events);
834 while (status.type !== "failed") status = yield* PubSub.take(events);
835 expect(status).toMatchObject({ url: pdf, download: { fileName: "paper.pdf" } });
836 // The tab shows the file, so no separate download toast is offered.
837 expect((yield* Queue.clear(viewer.output)).some((item) => item._tag === "download")).toBe(
838 false,
839 );
840 }),
841 ).pipe(Effect.provide(layer)),
842);
843
844it.live("a file a blank tab opened is offered to download if the tab moves on while it saves", () =>
845 Effect.scoped(
846 Effect.gen(function* () {
847 const browser = yield* ServerBrowser.ServerBrowser;
848 const manager = yield* Manager.PreviewManager;
849 yield* Effect.yieldNow;
850 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
851 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
852 yield* viewer.input({ type: "takeControl" });
853 const page = contexts[0]!.page;
854 const pdf = "https://example.com/paper.pdf";
855 const navigation = {
856 url: () => pdf,
857 method: () => "GET",
858 isNavigationRequest: () => true,
859 frame: () => page,
860 failure: () => ({ errorText: "net::ERR_ABORTED" }),
861 };
862 page.emit("request", navigation);
863 page.emit("requestfailed", navigation);
864 const saved = yield* Queue.unbounded<string>();
865 const written = Promise.withResolvers<void>();
866 page.emit("download", {
867 failure: async () => null,
868 saveAs: (path: string) => {
869 Queue.offerUnsafe(saved, path);
870 return written.promise;
871 },
872 suggestedFilename: () => "paper.pdf",
873 url: () => pdf,
874 });
875 const fs = yield* FileSystem.FileSystem;
876 yield* fs.writeFileString(yield* Queue.take(saved), "%PDF");
877 // The person navigates elsewhere while the file is still being written.
878 page.emit("request", {
879 url: () => "https://example.com/next",
880 method: () => "GET",
881 isNavigationRequest: () => true,
882 frame: () => page,
883 });
884 written.resolve();
885 let offered = yield* Queue.take(viewer.output);
886 while (offered._tag !== "download") offered = yield* Queue.take(viewer.output);
887 expect(offered).toMatchObject({ _tag: "download", fileName: "paper.pdf" });
888 }),
889 ).pipe(Effect.provide(layer)),
890);
891
892it.live("a download from a superseded navigation leaves the newer navigation loading", () =>
893 Effect.scoped(
894 Effect.gen(function* () {
895 const { broker, tabId } = yield* ready;
896 const page = contexts[0]!.page;
897 const pdf = "https://example.com/paper.pdf";
898 const navigation = {
899 url: () => pdf,
900 method: () => "GET",
901 isNavigationRequest: () => true,
902 frame: () => page,
903 failure: () => ({ errorText: "net::ERR_ABORTED" }),
904 };
905 page.emit("request", navigation);
906 page.emit("requestfailed", navigation);
907 const saved = yield* Queue.unbounded<string>();
908 const written = Promise.withResolvers<void>();
909 page.emit("download", {
910 failure: async () => null,
911 saveAs: (path: string) => {
912 Queue.offerUnsafe(saved, path);
913 return written.promise;
914 },
915 suggestedFilename: () => "paper.pdf",
916 url: () => pdf,
917 });
918 const fs = yield* FileSystem.FileSystem;
919 yield* fs.writeFileString(yield* Queue.take(saved), "%PDF");
920 // The person navigates elsewhere while the file is still being written.
921 page.emit("request", {
922 url: () => "https://example.com/next",
923 method: () => "GET",
924 isNavigationRequest: () => true,
925 frame: () => page,
926 });
927 written.resolve();
928 const status = () =>
929 broker.invoke<PreviewAutomationStatus>({ scope, tabId, operation: "status", input: {} });
930 while ((yield* status()).downloads?.length !== 1) yield* Effect.sleep("5 millis");
931 expect((yield* status()).loading).toBe(true);
932 }),
933 ).pipe(Effect.provide(layer)),
934);
935
936it.live(
937 "a late failure from a request that predates tracking leaves a newer navigation loading",
938 () =>
939 Effect.scoped(
940 Effect.gen(function* () {
941 const { broker, tabId } = yield* ready;
942 const page = contexts[0]!.page;
943 const navigation = (url: string) => ({
944 url: () => url,
945 method: () => "GET",
946 isNavigationRequest: () => true,
947 frame: () => page,
948 failure: () => ({ errorText: "net::ERR_CONNECTION_REFUSED" }),
949 });
950 page.emit("request", navigation("https://example.com/next"));
951 // A popup's first request can start before the tab listens for requests.
952 page.emit("requestfailed", navigation("https://example.com/first"));
953 const status = yield* broker.invoke<PreviewAutomationStatus>({
954 scope,
955 tabId,
956 operation: "status",
957 input: {},
958 });
959 expect(status.loading).toBe(true);
960 }),
961 ).pipe(Effect.provide(layer)),
962);
963
964it.live("an aborted navigation that no download explains stops loading", () =>
965 Effect.scoped(
966 Effect.gen(function* () {
967 const { broker, tabId } = yield* ready;
968 const page = contexts[0]!.page;
969 const navigation = {
970 url: () => "https://example.com/cancelled",
971 method: () => "GET",
972 isNavigationRequest: () => true,
973 frame: () => page,
974 failure: () => ({ errorText: "net::ERR_ABORTED" }),
975 };
976 const status = () =>
977 broker.invoke<PreviewAutomationStatus>({ scope, tabId, operation: "status", input: {} });
978 vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
979 try {
980 page.emit("request", navigation);
981 page.emit("requestfailed", navigation);
982 expect((yield* status()).loading).toBe(true);
983 vi.advanceTimersByTime(5_000);
984 } finally {
985 vi.useRealTimers();
986 }
987 expect((yield* status()).loading).toBe(false);
988 }),
989 ).pipe(Effect.provide(layer)),
990);
991
992it.live("a popup from a person's click is shown to that person, with its opener kept", () =>
993 Effect.scoped(
994 Effect.gen(function* () {
995 const browser = yield* ServerBrowser.ServerBrowser;
996 const manager = yield* Manager.PreviewManager;
997 yield* Effect.yieldNow;
998 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
999 const watcher = yield* browser.attachViewer(viewerInput(opened.tabId, false));
1000 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
1001 yield* viewer.input({ type: "takeControl" });
1002 const opener = contexts[0]!.page;
1003 const popup = makeContext();
1004 opener.emit("popup", popup.page);
1005 let shown = yield* Queue.take(viewer.output);
1006 while (shown._tag !== "popup") shown = yield* Queue.take(viewer.output);
1007 const sessions = (yield* manager.list({ threadId: scope.thread.threadId })).sessions;
1008 expect(sessions.map((session) => session.tabId)).toEqual(
1009 expect.arrayContaining([opened.tabId, shown.tabId]),
1010 );
1011 expect((yield* Queue.clear(watcher.output)).some((item) => item._tag === "popup")).toBe(
1012 false,
1013 );
1014 expect(opener.close).not.toHaveBeenCalled();
1015 }),
1016 ).pipe(Effect.provide(layer)),
1017);
1018
1019it.live("closing a tab while a viewer is still opening it does not leave its page behind", () =>
1020 Effect.scoped(
1021 Effect.gen(function* () {
1022 const browser = yield* ServerBrowser.ServerBrowser;
1023 const manager = yield* Manager.PreviewManager;
1024 yield* Effect.yieldNow;
1025 // The background open fails, so the tab exists only as a session.
1026 contextFailure = new Error("first launch failed");
1027 const snapshot = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
1028 yield* Effect.sleep("10 millis");
1029 contextFailure = null;
1030 contextGate = Promise.withResolvers<void>();
1031 const attaching = yield* browser
1032 .attachViewer(viewerInput(snapshot.tabId, false))
1033 .pipe(Effect.flip, Effect.forkScoped);
1034 yield* Effect.sleep("10 millis");
1035 yield* manager.close({ threadId: scope.thread.threadId, tabId: snapshot.tabId });
1036 yield* Effect.sleep("10 millis");
1037 contextGate.resolve();
1038 expect((yield* Fiber.join(attaching))._tag).toBe("ServerBrowserTabNotFoundError");
1039 expect(contexts).toHaveLength(1);
1040 expect(contexts[0]!.page.close).toHaveBeenCalled();
1041 }),
1042 ).pipe(Effect.provide(layer)),
1043);
1044
1045it.live("only the controlling viewer's copy and cut keys reach its clipboard", () =>
1046 Effect.scoped(
1047 Effect.gen(function* () {
1048 const { browser, tabId } = yield* ready;
1049 const watcher = yield* browser.attachViewer(viewerInput(tabId, false));
1050 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
1051 yield* viewer.input({ type: "takeControl" });
1052 const clipboard = (queue: typeof viewer.output) =>
1053 Queue.clear(queue).pipe(
1054 Effect.map((items) => items.filter((item) => item._tag === "clipboard")),
1055 );
1056 // What the page copied on its own stays on the server through other input.
1057 pageCopy = "unprompted";
1058 yield* viewer.input({ type: "mouse", action: "move", x: 10, y: 10 });
1059 yield* viewer.input({ type: "wheel", x: 10, y: 10, deltaY: 40 });
1060 yield* viewer.input({ type: "key", action: "down", key: "v", code: "KeyV", modifiers: 4 });
1061 yield* viewer.input({ type: "unknown" });
1062 expect(yield* clipboard(viewer.output)).toEqual([]);
1063 keyCopy = "copied";
1064 yield* viewer.input({ type: "key", action: "down", key: "c", code: "KeyC", modifiers: 4 });
1065 const cdp = contexts[0]!.sessions.at(-1)!;
1066 expect(cdp.send).toHaveBeenCalledWith(
1067 "Input.dispatchKeyEvent",
1068 expect.objectContaining({ key: "c", commands: ["copy"] }),
1069 );
1070 expect(yield* clipboard(viewer.output)).toEqual([{ _tag: "clipboard", text: "copied" }]);
1071 keyCopy = "cut";
1072 yield* viewer.input({ type: "key", action: "down", key: "x", code: "KeyX", modifiers: 2 });
1073 expect(yield* clipboard(viewer.output)).toEqual([{ _tag: "clipboard", text: "cut" }]);
1074 // A copy key with nothing copied sends nothing, not what a frame kept from before.
1075 pageCopy = "left over";
1076 keyCopy = "";
1077 yield* viewer.input({ type: "key", action: "down", key: "c", code: "KeyC", modifiers: 4 });
1078 expect(yield* clipboard(viewer.output)).toEqual([]);
1079 expect(yield* clipboard(watcher.output)).toEqual([]);
1080 }),
1081 ).pipe(Effect.provide(layer)),
1082);
1083
1084it.live("copy keys reach the viewer from a frame in its own process", () =>
1085 Effect.scoped(
1086 Effect.gen(function* () {
1087 const { browser, tabId } = yield* ready;
1088 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
1089 yield* viewer.input({ type: "takeControl" });
1090 const copy = () =>
1091 viewer.input({ type: "key", action: "down", key: "c", code: "KeyC", modifiers: 4 }).pipe(
1092 Effect.andThen(Queue.clear(viewer.output)),
1093 Effect.map((items) => items.filter((item) => item._tag === "clipboard")),
1094 );
1095 const frame = makeFrame();
1096 contexts[0]!.page.emit("framenavigated", frame);
1097 yield* Effect.promise(() => frame.recording.promise);
1098 const frameSession = contexts[0]!.sessions.at(-1)!;
1099 focusedFrame = frame;
1100 keyCopy = "framed";
1101 expect(yield* copy()).toEqual([{ _tag: "clipboard", text: "framed" }]);
1102 // What the frame kept from before is cleared, not sent by a key elsewhere.
1103 frame.copy = "left over";
1104 focusedFrame = null;
1105 keyCopy = "";
1106 expect(yield* copy()).toEqual([]);
1107 expect(frame.copy).toBe("");
1108 // A frame that went away is no longer read.
1109 frame.detached = true;
1110 frame.copy = "gone";
1111 frameSession.emit("close");
1112 expect(yield* copy()).toEqual([]);
1113 expect(frame.copy).toBe("gone");
1114 }),
1115 ).pipe(Effect.provide(layer)),
1116);
1117
1118it.live("a page download is saved, offered to the controller, and listed for the agent", () =>
1119 Effect.scoped(
1120 Effect.gen(function* () {
1121 const { browser, broker, tabId } = yield* ready;
1122 const page = contexts[0]!.page;
1123 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
1124 yield* viewer.input({ type: "takeControl" });
1125 yield* Queue.clear(viewer.output);
1126 // Stands in for Chromium, which writes the file itself.
1127 const saved = yield* Queue.unbounded<string>();
1128 const written = Promise.withResolvers<void>();
1129 page.emit("download", {
1130 failure: async () => null,
1131 saveAs: (path: string) => {
1132 Queue.offerUnsafe(saved, path);
1133 return written.promise;
1134 },
1135 suggestedFilename: () => "report.csv",
1136 url: () => "blob:http://localhost:5173/1",
1137 });
1138 const fs = yield* FileSystem.FileSystem;
1139 const path = yield* Queue.take(saved);
1140 yield* fs.writeFileString(path, "a,b");
1141 written.resolve();
1142 let offered = yield* Queue.take(viewer.output);
1143 while (offered._tag !== "download") offered = yield* Queue.take(viewer.output);
1144 expect(offered).toMatchObject({ _tag: "download", fileName: "report.csv", sizeBytes: 3 });
1145 const file = yield* browser.openDownload({
1146 threadId: scope.thread.threadId,
1147 tabId,
1148 downloadId: offered.id,
1149 });
1150 expect(Option.isSome(file) && file.value.fileName).toBe("report.csv");
1151 const status = yield* broker.invoke<PreviewAutomationStatus>({
1152 scope,
1153 tabId,
1154 operation: "status",
1155 input: {},
1156 });
1157 expect(status.downloads).toEqual([
1158 expect.objectContaining({ fileName: "report.csv", sizeBytes: 3 }),
1159 ]);
1160 expect(
1161 Option.isNone(
1162 yield* browser.openDownload({
1163 threadId: scope.thread.threadId,
1164 tabId,
1165 downloadId: "guess",
1166 }),
1167 ),
1168 ).toBe(true);
1169 }),
1170 ).pipe(Effect.provide(layer)),
1171);
1172
1173it.live("a page's file picker goes to the controller and takes its uploaded files", () =>
1174 Effect.scoped(
1175 Effect.gen(function* () {
1176 const { browser, tabId } = yield* ready;
1177 const page = contexts[0]!.page;
1178 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
1179 yield* viewer.input({ type: "takeControl" });
1180 const setFiles = vi.fn(async (_files: unknown) => {});
1181 page.emit("filechooser", {
1182 isMultiple: () => false,
1183 element: () => ({ getAttribute: async () => ".csv" }),
1184 setFiles,
1185 });
1186 let offered = yield* Queue.take(viewer.output);
1187 while (offered._tag !== "fileChooser") offered = yield* Queue.take(viewer.output);
1188 expect(offered).toMatchObject({ multiple: false, accept: ".csv" });
1189 const file = (name: string) => ({ name, mimeType: "text/csv", buffer: Buffer.from(name) });
1190 const answer = (chooserId: string) =>
1191 browser.answerFileChooser({
1192 threadId: scope.thread.threadId,
1193 tabId,
1194 chooserId,
1195 files: [file("a.csv"), file("b.csv")],
1196 });
1197 expect(yield* answer("other")).toBe(false);
1198 expect(yield* answer(offered.id)).toBe(true);
1199 // A single-file input only receives the first file.
1200 expect(setFiles).toHaveBeenCalledExactlyOnceWith([file("a.csv")]);
1201 let closed = yield* Queue.take(viewer.output);
1202 while (closed._tag !== "fileChooserClosed") closed = yield* Queue.take(viewer.output);
1203 expect(closed.id).toBe(offered.id);
1204 expect(yield* answer(offered.id)).toBe(false);
1205 }),
1206 ).pipe(Effect.provide(layer)),
1207);
1208
1209it.live("only the current controller can answer a file picker", () =>
1210 Effect.scoped(
1211 Effect.gen(function* () {
1212 const { browser, tabId } = yield* ready;
1213 const first = yield* browser.attachViewer(viewerInput(tabId, true));
1214 yield* first.input({ type: "takeControl" });
1215 const setFiles = vi.fn(async (_files: unknown) => {});
1216 contexts[0]!.page.emit("filechooser", {
1217 isMultiple: () => false,
1218 element: () => ({ getAttribute: async () => "" }),
1219 setFiles,
1220 });
1221 let offered = yield* Queue.take(first.output);
1222 while (offered._tag !== "fileChooser") offered = yield* Queue.take(first.output);
1223 const second = yield* browser.attachViewer(viewerInput(tabId, true));
1224 yield* first.input({ type: "releaseControl" });
1225 yield* second.input({ type: "takeControl" });
1226 const answered = yield* browser.answerFileChooser({
1227 threadId: scope.thread.threadId,
1228 tabId,
1229 chooserId: offered.id,
1230 files: [{ name: "a.txt", mimeType: "text/plain", buffer: Buffer.from("a") }],
1231 });
1232 expect(answered).toBe(false);
1233 expect(setFiles).not.toHaveBeenCalled();
1234 }),
1235 ).pipe(Effect.provide(layer)),
1236);
1237
1238it.live("a file picker replaces a stalled viewer backlog instead of being dropped", () =>
1239 Effect.scoped(
1240 Effect.gen(function* () {
1241 const { browser, tabId } = yield* ready;
1242 const page = contexts[0]!.page;
1243 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
1244 yield* viewer.input({ type: "takeControl" });
1245 const accept = Promise.withResolvers<string>();
1246 page.emit("filechooser", {
1247 isMultiple: () => false,
1248 element: () => ({ getAttribute: () => accept.promise }),
1249 setFiles: async () => {},
1250 });
1251 // The viewer stopped reading: its output is full when the picker opens.
1252 yield* Queue.clear(viewer.output);
1253 stallViewer(viewer, { _tag: "viewport", width: 1, height: 1 });
1254 accept.resolve(".csv");
1255 // The picker is offered within the microtasks that follow its accept attribute.
1256 yield* Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)));
1257 expect(yield* Queue.clear(viewer.output)).toEqual([
1258 expect.objectContaining({ _tag: "fileChooser", accept: ".csv" }),
1259 ]);
1260 }),
1261 ).pipe(Effect.provide(layer)),
1262);
1263
1264it.live("control updates replace a stalled viewer backlog in the order they happen", () =>
1265 Effect.scoped(
1266 Effect.gen(function* () {
1267 const { browser, tabId } = yield* ready;
1268 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
1269 yield* Queue.clear(viewer.output);
1270 // Chromium is slow to take back the frames the replacement drops.
1271 const acked = Promise.withResolvers<void>();
1272 yield* Effect.addFinalizer(() => Effect.sync(() => acked.resolve()));
1273 stallViewer(viewer, {
1274 _tag: "frame",
1275 data: Buffer.alloc(0),
1276 ack: Effect.promise(() => acked.promise),
1277 });
1278 yield* viewer.input({ type: "takeControl" });
1279 yield* viewer.input({ type: "releaseControl" });
1280 const controls = (yield* Queue.clear(viewer.output)).flatMap((item) =>
1281 item._tag === "control" ? [item.controller] : [],
1282 );
1283 expect(controls[0]).toBe("you");
1284 expect(controls.at(-1)).not.toBe("you");
1285 }),
1286 ).pipe(Effect.provide(layer)),
1287);
1288
1289it.live("an agent's tabs stop at the limit until an unwatched idle tab closes", () =>
1290 Effect.scoped(
1291 Effect.gen(function* () {
1292 const { broker, tabId } = yield* ready;
1293 const open = broker.invoke<PreviewAutomationStatus>({
1294 scope,
1295 operation: "open",
1296 input: { reuseExistingTab: false, show: false },
1297 });
1298 for (let count = 1; count < 8; count += 1) yield* open;
1299 expect(yield* Effect.flip(open)).toMatchObject({
1300 _tag: "PreviewAutomationControlInterruptedError",
1301 reason: "tabLimit",
1302 });
1303 // Another agent session keeps its own budget.
1304 yield* broker.invoke({
1305 scope: asSession("agent-b"),
1306 operation: "open",
1307 input: { reuseExistingTab: false, show: false },
1308 });
1309 const later = (yield* Clock.currentTimeMillis) + 31 * 60 * 1000;
1310 const clock = vi.spyOn(Date, "now").mockReturnValue(later);
1311 yield* Effect.addFinalizer(() => Effect.sync(() => clock.mockRestore()));
1312 // The first tab is watched, so it stays open while the agent's other idle tabs close.
1313 const browser = yield* ServerBrowser.ServerBrowser;
1314 yield* browser.attachViewer(viewerInput(tabId, false));
1315 const reopened = yield* open;
1316 const manager = yield* Manager.PreviewManager;
1317 const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });
1318 expect(sessions.map((session) => session.tabId).toSorted()).toEqual(
1319 [tabId, reopened.tabId].toSorted(),
1320 );
1321 }),
1322 ).pipe(Effect.provide(layer)),
1323);
1324
1325it.live("an agent answers the page's file picker or sets files on a file input", () =>
1326 Effect.scoped(
1327 Effect.gen(function* () {
1328 const { broker, tabId } = yield* ready;
1329 const page = contexts[0]!.page;
1330 const upload = (input: Record<string, unknown>) =>
1331 broker.invoke<void>({ scope, tabId, operation: "upload", input });
1332 expect(yield* Effect.flip(upload({ paths: ["/tmp/a.csv"] }))).toMatchObject({
1333 _tag: "PreviewAutomationExecutionError",
1334 });
1335 const setFiles = vi.fn(async (_files: unknown) => {});
1336 page.emit("filechooser", {
1337 isMultiple: () => false,
1338 element: () => ({ getAttribute: async () => ".csv" }),
1339 setFiles,
1340 });
1341 const status = broker.invoke<PreviewAutomationStatus>({
1342 scope,
1343 tabId,
1344 operation: "status",
1345 input: {},
1346 });
1347 let opened = yield* status;
1348 while (!opened.fileChooser) {
1349 yield* Effect.sleep("5 millis");
1350 opened = yield* status;
1351 }
1352 expect(opened.fileChooser).toEqual({ multiple: false, accept: ".csv" });
1353 // A single-file picker rejects several files instead of dropping some.
1354 yield* Effect.flip(upload({ paths: ["/tmp/a.csv", "/tmp/b.csv"] }));
1355 yield* upload({ paths: ["/tmp/a.csv"] });
1356 expect(setFiles).toHaveBeenCalledExactlyOnceWith(["/tmp/a.csv"], expect.anything());
1357 expect((yield* status).fileChooser).toBeNull();
1358
1359 const setInputFiles = vi.fn(async () => {});
1360 page.locator.mockReturnValue({ setInputFiles } as never);
1361 yield* upload({ paths: ["/tmp/b.csv"], locator: "input[type=file]" });
1362 expect(page.locator).toHaveBeenLastCalledWith("input[type=file]");
1363 expect(setInputFiles).toHaveBeenCalledWith(["/tmp/b.csv"], expect.anything());
1364 }),
1365 ).pipe(Effect.provide(layer)),
1366);
1367
1368it.live("viewers see the agent's pointer move to its target and click there", () =>
1369 Effect.scoped(
1370 Effect.gen(function* () {
1371 const { browser, broker, tabId } = yield* ready;
1372 const page = contexts[0]!.page;
1373 const viewer = yield* browser.attachViewer(viewerInput(tabId, false));
1374 const click = vi.fn(async () => {});
1375 page.locator.mockReturnValue({
1376 scrollIntoViewIfNeeded: async () => {},
1377 boundingBox: async () => ({ x: 100, y: 40, width: 80, height: 20 }),
1378 click,
1379 } as never);
1380 yield* broker.invoke({ scope, tabId, operation: "click", input: { locator: "#go" } });
1381 const pointers = (yield* Queue.clear(viewer.output)).filter(
1382 (item) => item._tag === "pointer",
1383 );
1384 expect(pointers).toEqual([
1385 expect.objectContaining({ phase: "move", x: 140, y: 50 }),
1386 expect.objectContaining({ phase: "click", x: 140, y: 50 }),
1387 ]);
1388 expect(click).toHaveBeenCalledOnce();
1389 }),
1390 ).pipe(Effect.provide(layer)),
1391);
1392
1393it.live("drives the desktop's own page for a tab the desktop renders", () =>
1394 Effect.scoped(
1395 Effect.gen(function* () {
1396 const browser = yield* ServerBrowser.ServerBrowser;
1397 const broker = yield* Broker.PreviewAutomationBroker;
1398 const manager = yield* Manager.PreviewManager;
1399 yield* Effect.yieldNow;
1400 desktopRendersNext = true;
1401 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
1402 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, true));
1403 expect(desktopConnections.map((connection) => connection.endpoint)).toEqual([
1404 `ws://desktop/${opened.tabId}`,
1405 ]);
1406 // No headless context was launched for it.
1407 expect(contexts).toEqual([]);
1408 const page = desktopConnections[0]!.context.page;
1409 // The desktop panel sizes its page, so a viewer resize leaves it alone.
1410 yield* viewer.input({ type: "takeControl" });
1411 yield* viewer.input({ type: "resize", width: 390, height: 844 });
1412 expect(page.setViewportSize).not.toHaveBeenCalled();
1413 yield* viewer.input({ type: "releaseControl" });
1414 // Agents reach it through the same engine as a headless tab.
1415 const evaluated = yield* broker.invoke({
1416 scope: asSession("agent-desktop"),
1417 operation: "status",
1418 input: {},
1419 tabId: PreviewTabId.make(opened.tabId),
1420 });
1421 expect(evaluated).toMatchObject({ tabId: opened.tabId });
1422 // Closing the session lets go of the desktop's page without closing it.
1423 yield* manager.close({ threadId: scope.thread.threadId, tabId: opened.tabId });
1424 while (releasedDesktopTabs.length === 0) yield* Effect.yieldNow;
1425 expect(releasedDesktopTabs).toEqual([opened.tabId]);
1426 expect(page.close).not.toHaveBeenCalled();
1427 }),
1428 ).pipe(Effect.provide(layer)),
1429);
1430
1431it.live("a desktop page the desktop takes back reconnects instead of closing", () =>
1432 Effect.scoped(
1433 Effect.gen(function* () {
1434 const browser = yield* ServerBrowser.ServerBrowser;
1435 const manager = yield* Manager.PreviewManager;
1436 yield* Effect.yieldNow;
1437 desktopRendersNext = true;
1438 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
1439 const viewer = yield* browser.attachViewer(viewerInput(opened.tabId, false));
1440 // Devtools opened on the desktop, so it withdrew the page's debugger.
1441 // The server's listener subscribes in its own fiber; detach once it is there.
1442 while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow;
1443 desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId });
1444 let end = yield* Queue.take(viewer.output);
1445 while (end._tag !== "reconnect" && end._tag !== "gone")
1446 end = yield* Queue.take(viewer.output);
1447 expect(end._tag).toBe("reconnect");
1448 expect(releasedDesktopTabs).toEqual([opened.tabId]);
1449 // The session survives, and the next viewer reaches the page again.
1450 const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });
1451 expect(sessions.map((session) => session.tabId)).toContain(opened.tabId);
1452 yield* browser.attachViewer(viewerInput(opened.tabId, false));
1453 expect(desktopConnections).toHaveLength(2);
1454 }),
1455 ).pipe(Effect.provide(layer)),
1456);
1457
1458it.live("agents read a human's tab with no arguments and act on it only while nobody drives", () =>
1459 Effect.scoped(
1460 Effect.gen(function* () {
1461 const browser = yield* ServerBrowser.ServerBrowser;
1462 const broker = yield* Broker.PreviewAutomationBroker;
1463 const manager = yield* Manager.PreviewManager;
1464 yield* Effect.yieldNow;
1465 const opened = yield* manager.open({
1466 threadId: scope.thread.threadId,
1467 url: "http://localhost:5173/mine",
1468 runtime: "server",
1469 });
1470 const tabId = PreviewTabId.make(opened.tabId);
1471 // The user opened and is looking at the tab; attaching takes control.
1472 const viewer = yield* browser.attachViewer(viewerInput(tabId, true));
1473 const status = yield* broker.invoke<PreviewAutomationStatus>({
1474 scope,
1475 operation: "status",
1476 input: {},
1477 });
1478 expect(status).toMatchObject({
1479 tabId,
1480 control: { owner: "human", ownedByCaller: false },
1481 });
1482 expect(status.tabs).toEqual([
1483 expect.objectContaining({ tabId, owner: "human", ownedByCaller: false, visible: true }),
1484 ]);
1485 // Reads work while the user drives; page script does not, since it can change the page.
1486 const evaluated = yield* broker
1487 .invoke<void>({ scope, operation: "evaluate", input: { expression: "read()" } })
1488 .pipe(Effect.flip);
1489 expect(evaluated).toMatchObject({
1490 _tag: "PreviewAutomationControlInterruptedError",
1491 reason: "humanControl",
1492 });
1493 yield* broker.invoke<PreviewAutomationSnapshot>({
1494 scope,
1495 tabId,
1496 operation: "snapshot",
1497 input: {},
1498 });
1499 // Acting is refused while the user controls the tab.
1500 const refused = yield* broker
1501 .invoke<void>({
1502 scope,
1503 tabId,
1504 operation: "navigate",
1505 input: { url: "http://localhost:5173/agent" },
1506 })
1507 .pipe(Effect.flip);
1508 expect(refused).toMatchObject({
1509 _tag: "PreviewAutomationControlInterruptedError",
1510 reason: "humanControl",
1511 });
1512 // Once they let go, the tab is unclaimed and the agent may act on it.
1513 yield* viewer.input({ type: "releaseControl" });
1514 const released = yield* broker.invoke<PreviewAutomationStatus>({
1515 scope,
1516 tabId,
1517 operation: "status",
1518 input: {},
1519 });
1520 expect(released.control).toMatchObject({ owner: "unclaimed", ownedByCaller: false });
1521 yield* broker.invoke({
1522 scope,
1523 tabId,
1524 operation: "navigate",
1525 input: { url: "http://localhost:5173/agent" },
1526 });
1527 expect(contexts[0]!.page.goto).toHaveBeenCalledWith(
1528 "http://localhost:5173/agent",
1529 expect.anything(),
1530 );
1531 // Taking control back refuses the agent again.
1532 yield* viewer.input({ type: "takeControl" });
1533 const retaken = yield* broker
1534 .invoke<void>({ scope, tabId, operation: "press", input: { key: "Enter" } })
1535 .pipe(Effect.flip);
1536 expect(retaken).toMatchObject({ reason: "humanControl" });
1537 }),
1538 ).pipe(Effect.provide(layer)),
1539);
1540
1541it.live("a session's own tab stays its default while other sessions' tabs are listed", () =>
1542 Effect.scoped(
1543 Effect.gen(function* () {
1544 const { broker, tabId } = yield* ready;
1545 const other = yield* broker.invoke<PreviewAutomationStatus>({
1546 scope: asSession("agent-b"),
1547 operation: "open",
1548 input: { reuseExistingTab: false, show: false },
1549 });
1550 const status = yield* broker.invoke<PreviewAutomationStatus>({
1551 scope,
1552 operation: "status",
1553 input: {},
1554 });
1555 expect(status.tabId).toBe(tabId);
1556 expect(status.tabs).toEqual(
1557 expect.arrayContaining([
1558 expect.objectContaining({ tabId, owner: "agent", ownedByCaller: true }),
1559 expect.objectContaining({ tabId: other.tabId, owner: "agent", ownedByCaller: false }),
1560 ]),
1561 );
1562 }),
1563 ).pipe(Effect.provide(layer)),
1564);
1565
1566it.live(
1567 "preview_open picks a reported profile by id or name and defaults to the reported one",
1568 () =>
1569 Effect.scoped(
1570 Effect.gen(function* () {
1571 const browser = yield* ServerBrowser.ServerBrowser;
1572 const broker = yield* Broker.PreviewAutomationBroker;
1573 const manager = yield* Manager.PreviewManager;
1574 yield* Effect.yieldNow;
1575 yield* browser.reportProfiles({
1576 profiles: [{ id: "profile-work", name: "Work", kind: "persistent" }],
1577 defaultProfileId: "profile-work",
1578 });
1579 const openWith = (profileId?: string) =>
1580 broker.invoke<PreviewAutomationStatus>({
1581 scope,
1582 operation: "open",
1583 input: {
1584 reuseExistingTab: false,
1585 show: false,
1586 ...(profileId === undefined ? {} : { profileId }),
1587 },
1588 });
1589 const byDefault = yield* openWith();
1590 const byName = yield* openWith("WORK");
1591 const byId = yield* openWith("incognito");
1592 const { sessions } = yield* manager.list({ threadId: scope.thread.threadId });
1593 const profileOf = (tabId: string | null) =>
1594 sessions.find((session) => session.tabId === tabId)?.profileId;
1595 expect(profileOf(byDefault.tabId)).toBe("profile-work");
1596 expect(profileOf(byName.tabId)).toBe("profile-work");
1597 expect(profileOf(byId.tabId)).toBe("incognito");
1598 // The chosen profile reaches the headless tab's storage.
1599 expect(contextRequests).toEqual([
1600 { profileId: "profile-work", isolated: false },
1601 { profileId: "profile-work", isolated: false },
1602 { profileId: "incognito", isolated: true },
1603 ]);
1604 expect(byDefault).toMatchObject({
1605 defaultProfileId: "profile-work",
1606 profiles: [
1607 { id: "default", name: "Default" },
1608 { id: "incognito", name: "Incognito", incognito: true },
1609 { id: "profile-work", name: "Work" },
1610 ],
1611 });
1612 const unknown = yield* openWith("Personal").pipe(Effect.flip);
1613 expect(unknown).toMatchObject({
1614 _tag: "PreviewAutomationExecutionError",
1615 reason:
1616 'No browser profile is named "Personal". Use one of: Default (id default), Incognito (id incognito), Work (id profile-work).',
1617 });
1618 }),
1619 ).pipe(Effect.provide(layer)),
1620);
1621
1622it.live("an agent tab keeps throwaway storage when no client reported profiles", () =>
1623 Effect.scoped(
1624 Effect.gen(function* () {
1625 yield* ready;
1626 expect(contextRequests).toEqual([{ profileId: "default", isolated: true }]);
1627 }),
1628 ).pipe(Effect.provide(layer)),
1629);
1630
1631it.live("a desktop page that comes back reconnects without waiting for a viewer", () =>
1632 Effect.scoped(
1633 Effect.gen(function* () {
1634 const manager = yield* Manager.PreviewManager;
1635 yield* ServerBrowser.ServerBrowser;
1636 yield* Effect.yieldNow;
1637 desktopRendersNext = true;
1638 const opened = yield* manager.open({ threadId: scope.thread.threadId, runtime: "server" });
1639 while (desktopConnections.length === 0) yield* Effect.yieldNow;
1640 while (desktopDetaches.listenerCount("detach") === 0) yield* Effect.yieldNow;
1641 // DevTools opened, then closed: the desktop withdraws the page and returns it.
1642 desktopDetaches.emit("detach", { threadId: scope.thread.threadId, tabId: opened.tabId });
1643 while (releasedDesktopTabs.length === 0) yield* Effect.yieldNow;
1644 desktopDetaches.emit("attach", { threadId: scope.thread.threadId, tabId: opened.tabId });
1645 // Without a viewer or agent, the server drives the page again, so its URL keeps reaching clients.
1646 while (desktopConnections.length < 2) yield* Effect.yieldNow;
1647 expect(desktopConnections).toHaveLength(2);
1648 }),
1649 ).pipe(Effect.provide(layer)),
1650);