1// @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron debugger.
2import { describe, expect, it, vi } from "@effect/vitest";
3import { DesktopBrowserEvent } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Queue from "effect/Queue";
7import * as Schema from "effect/Schema";
8import * as Stream from "effect/Stream";
9import * as TestClock from "effect/testing/TestClock";
10import * as NodeEvents from "node:events";
11
12import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
13
14const decodeEvent = Schema.decodeUnknownSync(Schema.fromJsonString(DesktopBrowserEvent));
15const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
16const decodeCdpReply = Schema.decodeUnknownSync(
17 Schema.fromJsonString(Schema.Struct({ id: Schema.Number })),
18);
19const decodeCdpMessage = Schema.decodeUnknownSync(
20 Schema.fromJsonString(
21 Schema.Struct({
22 id: Schema.optional(Schema.Number),
23 method: Schema.optional(Schema.String),
24 sessionId: Schema.optional(Schema.String),
25 }),
26 ),
27);
28const key = { threadId: "thread-1", tabId: "tab-1" };
29/** Sends a page command whose id the fake debugger records as it receives it. */
30const command = (
31 host: DesktopBrowserHost.DesktopBrowserHost["Service"],
32 id: number,
33 method: string,
34) =>
35 host.handleCommandLine(
36 encodeJson({
37 type: "cdp",
38 ...key,
39 message: encodeJson({ id, method, params: { id }, sessionId: "t3-preview-page" }),
40 }),
41 );
42
43const makeDebuggee = (
44 options: {
45 readonly deferFocus?: boolean;
46 /** The guest is still loading its first page. */
47 readonly loading?: boolean;
48 readonly refuse?: (method: string, sessionId: string | undefined) => boolean;
49 } = {},
50) => {
51 const emitter = new NodeEvents.EventEmitter();
52 const pending: Array<() => void> = [];
53 const focusChanges: Array<() => void> = [];
54 const sent: Array<unknown> = [];
55 const methods: Array<string> = [];
56 const rejects: Array<() => void> = [];
57 const sendWaiters: Array<() => void> = [];
58 const focusEmulation: Array<boolean> = [];
59 const debuggee = Object.assign(emitter, {
60 sendCommand: (
61 method: string,
62 params?: { id?: number; enabled?: boolean },
63 sessionId?: string,
64 ) =>
65 new Promise((resolve, reject) => {
66 if (options.refuse?.(method, sessionId)) {
67 reject(new Error("Refused"));
68 return;
69 }
70 if (method === "Target.getTargetInfo") {
71 resolve({ targetInfo: { targetId: "GUEST" } });
72 return;
73 }
74 if (method === "Emulation.setFocusEmulationEnabled") {
75 focusEmulation.push(params?.enabled === true);
76 if (options.deferFocus) focusChanges.push(() => resolve({}));
77 else resolve({});
78 return;
79 }
80 sent.push(params?.id);
81 methods.push(method);
82 rejects.push(() => reject(new Error("Refused")));
83 pending.push(() => resolve({ method }));
84 sendWaiters.shift()?.();
85 }),
86 });
87 let destroyed = false;
88 const webContents = Object.assign(new NodeEvents.EventEmitter(), {
89 getURL: () => "http://localhost/",
90 getTitle: () => "Page",
91 getUserAgent: () => "Electron",
92 isLoadingMainFrame: () => options.loading === true,
93 isDestroyed: () => destroyed,
94 });
95 return {
96 tab: {
97 webContents: webContents as unknown as Electron.WebContents,
98 debugger: debuggee as unknown as Electron.Debugger,
99 },
100 emit: (method: string, params: unknown) => emitter.emit("message", {}, method, params, ""),
101 navigate: () => webContents.emit("did-navigate"),
102 domReady: () => webContents.emit("dom-ready"),
103 destroy: () => {
104 destroyed = true;
105 },
106 failLoad: (code: number) =>
107 webContents.emit("did-fail-load", {}, code, "Failed", "http://localhost/", true),
108 /** Ids of the commands the debugger received, in order. */
109 sent,
110 /** Methods of the same commands, in order. */
111 methods,
112 /** Refuses the pending command the debugger received with this id. */
113 refuse: (id: number) => rejects[sent.indexOf(id)]?.(),
114 focusEmulation,
115 nextSend: () => new Promise<void>((resolve) => sendWaiters.push(resolve)),
116 release: () => pending.splice(0).forEach((resolve) => resolve()),
117 releaseFirst: () => pending.shift()?.(),
118 settleFocus: () => focusChanges.splice(0).forEach((resolve) => resolve()),
119 };
120};
121
122/** Commands wait behind a focus emulation change; it settles within a macrotask. */
123// oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time
124const settle = Effect.promise(() => new Promise<void>((resolve) => setImmediate(resolve)));
125
126const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], count: number) =>
127 host.events.pipe(
128 Stream.take(count),
129 Stream.runCollect,
130 Effect.map((lines) => lines.map((line) => decodeEvent(new TextDecoder().decode(line)))),
131 );
132
133/**
134 * Ids of the replies the host sends, in order. A screenshot's reply leaves after its turn
135 * has passed on, so by then the next waiting screenshot, if any, has been sent. Subscribe
136 * before sending commands: a new subscriber releases the tab's relay. Screencast frames
137 * go to `frames` by the session they were sent to.
138 */
139const watchReplies = (
140 host: DesktopBrowserHost.DesktopBrowserHost["Service"],
141 frames: Array<string | undefined> = [],
142) =>
143 Effect.gen(function* () {
144 const replies = yield* Queue.unbounded<number>();
145 yield* host.events.pipe(
146 Stream.map((line) => decodeEvent(new TextDecoder().decode(line))),
147 Stream.runForEach((event) => {
148 if (event.type !== "cdp") return Effect.void;
149 const { id, method, sessionId } = decodeCdpMessage(event.message);
150 if (id !== undefined) return Queue.offer(replies, id);
151 if (method === "Page.screencastFrame") frames.push(sessionId);
152 return Effect.void;
153 }),
154 Effect.forkScoped,
155 );
156 yield* Effect.yieldNow;
157 return replies;
158 });
159
160/** Whether the client should paint the guest, each time that changes. */
161const watchPainting = (host: DesktopBrowserHost.DesktopBrowserHost["Service"]) =>
162 Effect.gen(function* () {
163 const painting: Array<boolean> = [];
164 yield* host.painting.pipe(
165 Stream.runForEach(({ active }) => Effect.sync(() => painting.push(active))),
166 Effect.forkScoped,
167 );
168 yield* Effect.yieldNow;
169 return painting;
170 });
171
172describe("DesktopBrowserHost", () => {
173 it.effect("announces tabs already attached to a backend that starts later", () =>
174 Effect.gen(function* () {
175 const host = yield* DesktopBrowserHost.make;
176 host.attach(key, makeDebuggee().tab);
177
178 expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]);
179 expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]);
180 }),
181 );
182
183 it.effect("announces a guest still loading its first page only once that page is up", () =>
184 Effect.gen(function* () {
185 const host = yield* DesktopBrowserHost.make;
186 const loading = makeDebuggee({ loading: true });
187 const other = { ...key, tabId: "tab-2" };
188 const reader = yield* takeEvents(host, 3).pipe(Effect.forkScoped);
189
190 yield* Effect.yieldNow;
191 host.attach(key, loading.tab);
192 host.setShown(key, true);
193 host.attach(other, makeDebuggee().tab);
194 loading.domReady();
195
196 expect(yield* Fiber.join(reader)).toEqual([
197 { type: "attached", ...other },
198 { type: "attached", ...key },
199 { type: "shown", ...key, shown: true },
200 ]);
201 }),
202 );
203
204 it.effect(
205 "keeps a loading guest unannounced across a replaced navigation and a new backend",
206 () =>
207 Effect.gen(function* () {
208 const host = yield* DesktopBrowserHost.make;
209 const loading = makeDebuggee({ loading: true });
210 const other = { ...key, tabId: "tab-2" };
211 host.attach(key, loading.tab);
212 loading.failLoad(-3);
213 const reader = yield* takeEvents(host, 2).pipe(Effect.forkScoped);
214
215 yield* Effect.yieldNow;
216 host.attach(other, makeDebuggee().tab);
217 loading.domReady();
218
219 expect(yield* Fiber.join(reader)).toEqual([
220 { type: "attached", ...other },
221 { type: "attached", ...key },
222 ]);
223 }),
224 );
225
226 it.effect(
227 "announces a first page that never comes up, but never a tab closed or destroyed first",
228 () =>
229 Effect.gen(function* () {
230 const host = yield* DesktopBrowserHost.make;
231 const closed = { ...key, tabId: "tab-2" };
232 const destroyed = { ...key, tabId: "tab-3" };
233 const destroyedGuest = makeDebuggee({ loading: true });
234 const reader = yield* takeEvents(host, 1).pipe(Effect.forkScoped);
235
236 yield* Effect.yieldNow;
237 host.attach(closed, makeDebuggee({ loading: true }).tab);
238 host.detach(closed);
239 host.attach(destroyed, destroyedGuest.tab);
240 destroyedGuest.destroy();
241 host.attach(key, makeDebuggee({ loading: true }).tab);
242 yield* TestClock.adjust("5 seconds");
243
244 expect(yield* Fiber.join(reader)).toEqual([{ type: "attached", ...key }]);
245 }),
246 );
247
248 it.effect("re-sends shown to a backend that starts later and when a tab attaches again", () =>
249 Effect.gen(function* () {
250 const host = yield* DesktopBrowserHost.make;
251 host.attach(key, makeDebuggee().tab);
252 host.setShown(key, true);
253 const reader = yield* takeEvents(host, 5).pipe(Effect.forkScoped);
254
255 yield* Effect.yieldNow;
256 host.detach(key);
257 host.attach(key, makeDebuggee().tab);
258
259 expect(yield* Fiber.join(reader)).toEqual([
260 { type: "attached", ...key },
261 { type: "shown", ...key, shown: true },
262 { type: "detached", ...key },
263 { type: "attached", ...key },
264 { type: "shown", ...key, shown: true },
265 ]);
266 }),
267 );
268
269 it.effect("replays an explicit hidden report before announcing another tab", () =>
270 Effect.gen(function* () {
271 const host = yield* DesktopBrowserHost.make;
272 const other = { ...key, tabId: "tab-2" };
273 host.setShown(key, false);
274 host.attach(key, makeDebuggee().tab);
275 host.setShown(other, true);
276 host.attach(other, makeDebuggee().tab);
277
278 expect(yield* takeEvents(host, 3)).toEqual([
279 { type: "attached", ...key },
280 { type: "shown", ...key, shown: false },
281 { type: "attached", ...other },
282 ]);
283 }),
284 );
285
286 it.effect("drops replies from a relay the server released", () =>
287 Effect.gen(function* () {
288 const host = yield* DesktopBrowserHost.make;
289 const debuggee = makeDebuggee();
290 host.attach(key, debuggee.tab);
291 const reader = yield* takeEvents(host, 2).pipe(Effect.forkScoped);
292
293 yield* Effect.yieldNow;
294 const command = (id: number, method: string) =>
295 host.handleCommandLine(
296 encodeJson({
297 type: "cdp",
298 ...key,
299 message: encodeJson({ id, method, sessionId: "t3-preview-page" }),
300 }),
301 );
302 yield* command(1, "Page.captureScreenshot");
303 yield* host.handleCommandLine(encodeJson({ type: "release", ...key }));
304 yield* command(2, "DOM.enable");
305 yield* settle;
306 debuggee.release();
307 const [, reply] = yield* Fiber.join(reader);
308
309 expect(reply).toMatchObject({ type: "cdp" });
310 expect(decodeCdpReply((reply as { message: string }).message).id).toBe(2);
311 }),
312 );
313
314 it.effect("sends a page one screenshot at a time", () =>
315 Effect.gen(function* () {
316 const host = yield* DesktopBrowserHost.make;
317 const debuggee = makeDebuggee();
318 host.attach(key, debuggee.tab);
319 const replies = yield* watchReplies(host);
320
321 yield* command(host, 1, "Page.captureScreenshot");
322 yield* command(host, 2, "Page.captureScreenshot");
323 yield* command(host, 3, "DOM.enable");
324 yield* settle;
325 expect(debuggee.sent).toEqual([1, 3]);
326
327 debuggee.releaseFirst();
328 expect(yield* Queue.take(replies)).toBe(1);
329 expect(debuggee.sent).toEqual([1, 3, 2]);
330 }),
331 );
332
333 it.effect("stops waiting on a screenshot the page navigated away from", () =>
334 Effect.gen(function* () {
335 const host = yield* DesktopBrowserHost.make;
336 const debuggee = makeDebuggee();
337 host.attach(key, debuggee.tab);
338 const replies = yield* watchReplies(host);
339
340 yield* command(host, 1, "Page.captureScreenshot");
341 yield* command(host, 2, "Page.captureScreenshot");
342 yield* command(host, 3, "Page.captureScreenshot");
343 yield* settle;
344 debuggee.navigate();
345 expect(debuggee.sent).toEqual([1, 2]);
346
347 // The superseded screenshot answering late must not start a third beside the second.
348 debuggee.releaseFirst();
349 expect(yield* Queue.take(replies)).toBe(1);
350 expect(debuggee.sent).toEqual([1, 2]);
351
352 debuggee.releaseFirst();
353 expect(yield* Queue.take(replies)).toBe(2);
354 expect(debuggee.sent).toEqual([1, 2, 3]);
355 }),
356 );
357
358 it.effect("drops screenshots a released relay left waiting", () =>
359 Effect.gen(function* () {
360 const host = yield* DesktopBrowserHost.make;
361 const debuggee = makeDebuggee();
362 host.attach(key, debuggee.tab);
363
364 yield* command(host, 1, "Page.captureScreenshot");
365 yield* command(host, 2, "Page.captureScreenshot");
366 yield* settle;
367 yield* host.handleCommandLine(encodeJson({ type: "release", ...key }));
368 yield* command(host, 3, "Page.captureScreenshot");
369 expect(debuggee.sent).toEqual([1]);
370
371 const sent = debuggee.nextSend();
372 debuggee.release();
373 yield* Effect.promise(() => sent);
374 expect(debuggee.sent).toEqual([1, 3]);
375 }),
376 );
377
378 it.effect("emulates focus while the server drives a guest, then lets a hidden one throttle", () =>
379 Effect.gen(function* () {
380 const host = yield* DesktopBrowserHost.make;
381 const debuggee = makeDebuggee();
382 host.attach(key, debuggee.tab);
383
384 yield* command(host, 1, "DOM.enable");
385 yield* settle;
386 expect(debuggee.focusEmulation).toEqual([true]);
387 expect(debuggee.sent).toEqual([1]);
388 debuggee.release();
389 yield* settle;
390
391 yield* TestClock.adjust("4 seconds");
392 yield* command(host, 2, "Input.dispatchMouseEvent");
393 yield* settle;
394 debuggee.release();
395 yield* settle;
396 yield* TestClock.adjust("4 seconds");
397 expect(debuggee.focusEmulation).toEqual([true]);
398
399 yield* TestClock.adjust("10 seconds");
400 expect(debuggee.focusEmulation).toEqual([true, false]);
401
402 yield* command(host, 3, "Page.captureScreenshot");
403 yield* settle;
404 expect(debuggee.focusEmulation).toEqual([true, false, true]);
405 expect(debuggee.sent).toEqual([1, 2, 3]);
406 }),
407 );
408
409 it.effect("keeps a guest focused while a command it is running is still pending", () =>
410 Effect.gen(function* () {
411 const host = yield* DesktopBrowserHost.make;
412 const debuggee = makeDebuggee();
413 host.attach(key, debuggee.tab);
414 const replies = yield* watchReplies(host);
415
416 // An evaluation awaiting the page's next frame, which a throttled guest never paints.
417 yield* command(host, 1, "Runtime.evaluate");
418 yield* settle;
419 expect(debuggee.sent).toEqual([1]);
420 yield* TestClock.adjust("1 minute");
421 expect(debuggee.focusEmulation).toEqual([true]);
422
423 debuggee.release();
424 expect(yield* Queue.take(replies)).toBe(1);
425 yield* TestClock.adjust("1 minute");
426 expect(debuggee.focusEmulation).toEqual([true, false]);
427 }),
428 );
429
430 it.effect("lets a guest throttle once Chromium can no longer answer its pending command", () =>
431 Effect.gen(function* () {
432 const host = yield* DesktopBrowserHost.make;
433 const debuggee = makeDebuggee();
434 host.attach(key, debuggee.tab);
435
436 yield* command(host, 1, "Runtime.evaluate");
437 yield* settle;
438 yield* host.handleCommandLine(encodeJson({ type: "release", ...key }));
439 yield* TestClock.adjust("1 minute");
440 expect(debuggee.focusEmulation).toEqual([true, false]);
441
442 // A command the old document never answers.
443 yield* command(host, 2, "Page.captureScreenshot");
444 yield* settle;
445 expect(debuggee.sent).toEqual([1, 2]);
446 debuggee.navigate();
447 yield* TestClock.adjust("1 minute");
448 expect(debuggee.focusEmulation).toEqual([true, false, true, false]);
449 }),
450 );
451
452 it.effect("keeps a guest focused for the screenshot a navigation lets start", () =>
453 Effect.gen(function* () {
454 const host = yield* DesktopBrowserHost.make;
455 const debuggee = makeDebuggee();
456 host.attach(key, debuggee.tab);
457
458 yield* command(host, 1, "Page.captureScreenshot");
459 yield* command(host, 2, "Page.captureScreenshot");
460 yield* settle;
461 debuggee.navigate();
462 expect(debuggee.sent).toEqual([1, 2]);
463 yield* TestClock.adjust("1 minute");
464 expect(debuggee.focusEmulation).toEqual([true]);
465 }),
466 );
467
468 it.effect("never sends a screenshot a navigation superseded while focus was changing", () =>
469 Effect.gen(function* () {
470 const host = yield* DesktopBrowserHost.make;
471 const debuggee = makeDebuggee({ deferFocus: true });
472 host.attach(key, debuggee.tab);
473
474 yield* command(host, 1, "Page.captureScreenshot");
475 yield* command(host, 2, "Page.captureScreenshot");
476 yield* settle;
477 debuggee.navigate();
478 debuggee.settleFocus();
479 yield* settle;
480 // Two captures in flight would undo the one-at-a-time guard.
481 expect(debuggee.sent).toEqual([2]);
482 }),
483 );
484
485 it.effect("keeps a guest focused when its turn starts after the agent's first command", () =>
486 Effect.gen(function* () {
487 const host = yield* DesktopBrowserHost.make;
488 const debuggee = makeDebuggee();
489 host.attach(key, debuggee.tab);
490 const turn = (active: boolean) =>
491 host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active }));
492
493 yield* command(host, 1, "Input.dispatchKeyEvent");
494 yield* settle;
495 yield* turn(true);
496 debuggee.release();
497 yield* settle;
498 yield* TestClock.adjust("1 minute");
499 expect(debuggee.focusEmulation).toEqual([true]);
500
501 yield* turn(false);
502 yield* TestClock.adjust("1 minute");
503 expect(debuggee.focusEmulation).toEqual([true, false]);
504 }),
505 );
506
507 it.effect("lets a guest throttle through a turn that only sent it housekeeping", () =>
508 Effect.gen(function* () {
509 const host = yield* DesktopBrowserHost.make;
510 const debuggee = makeDebuggee();
511 host.attach(key, debuggee.tab);
512 yield* host.handleCommandLine(
513 encodeJson({ type: "turn", threadId: key.threadId, active: true }),
514 );
515
516 // Playwright re-enabling a domain after a reload is not the agent using the tab.
517 yield* command(host, 1, "Runtime.enable");
518 yield* settle;
519 // The relay resets Runtime before a connection's first enable: answer the disable, then the enable.
520 debuggee.release();
521 yield* settle;
522 debuggee.release();
523 yield* settle;
524 yield* TestClock.adjust("1 minute");
525 expect(debuggee.focusEmulation).toEqual([true, false]);
526
527 yield* command(host, 2, "Runtime.evaluate");
528 yield* settle;
529 debuggee.release();
530 yield* settle;
531 yield* TestClock.adjust("1 minute");
532 expect(debuggee.focusEmulation).toEqual([true, false, true]);
533 }),
534 );
535
536 it.effect("keeps a guest the agent used focused through its turn's pauses", () =>
537 Effect.gen(function* () {
538 const host = yield* DesktopBrowserHost.make;
539 const debuggee = makeDebuggee();
540 host.attach(key, debuggee.tab);
541 const turn = (active: boolean) =>
542 host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active }));
543
544 yield* turn(true);
545 yield* TestClock.adjust("1 minute");
546 expect(debuggee.focusEmulation).toEqual([]);
547
548 yield* command(host, 1, "Input.dispatchKeyEvent");
549 yield* settle;
550 debuggee.release();
551 yield* settle;
552 // The model thinks between steps; a focus-sensitive menu must not blur meanwhile.
553 yield* TestClock.adjust("1 minute");
554 expect(debuggee.focusEmulation).toEqual([true]);
555
556 yield* turn(false);
557 yield* TestClock.adjust("1 minute");
558 expect(debuggee.focusEmulation).toEqual([true, false]);
559 }),
560 );
561
562 it.effect(
563 "ends a command's hold with the turn that sent it, even if Chromium never answers",
564 () =>
565 Effect.gen(function* () {
566 const host = yield* DesktopBrowserHost.make;
567 const debuggee = makeDebuggee();
568 host.attach(key, debuggee.tab);
569 yield* host.handleCommandLine(
570 encodeJson({ type: "turn", threadId: key.threadId, active: true }),
571 );
572
573 // A screenshot of a hidden guest the server already gave up on.
574 yield* command(host, 1, "Page.captureScreenshot");
575 yield* settle;
576 expect(debuggee.sent).toEqual([1]);
577 yield* host.handleCommandLine(
578 encodeJson({ type: "turn", threadId: key.threadId, active: false }),
579 );
580 yield* TestClock.adjust("1 minute");
581 expect(debuggee.focusEmulation).toEqual([true, false]);
582 }),
583 );
584
585 it.effect("ends the agent's own session's holds with its turn, but not a viewer's stream", () =>
586 Effect.gen(function* () {
587 const host = yield* DesktopBrowserHost.make;
588 const debuggee = makeDebuggee();
589 host.attach(key, debuggee.tab);
590 const replies = yield* watchReplies(host);
591 const raw = (message: Record<string, unknown>) =>
592 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
593 const turn = (active: boolean) =>
594 host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active }));
595
596 // The server's own CDP session, evaluating a promise that never settles.
597 yield* turn(true);
598 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
599 expect(yield* Queue.take(replies)).toBe(1);
600 yield* raw({ id: 2, method: "Runtime.evaluate", sessionId: "t3-preview-page-1" });
601 yield* settle;
602 yield* turn(false);
603 yield* TestClock.adjust("1 minute");
604 expect(debuggee.focusEmulation).toEqual([true, false]);
605
606 // A viewer streaming the guest keeps it focused through a turn's end.
607 yield* turn(true);
608 yield* raw({ id: 3, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
609 expect(yield* Queue.take(replies)).toBe(3);
610 yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" });
611 yield* settle;
612 yield* turn(false);
613 yield* TestClock.adjust("1 minute");
614 expect(debuggee.focusEmulation).toEqual([true, false, true]);
615 }),
616 );
617
618 it.effect("paints for a screenshot queued behind one its viewer abandoned mid-capture", () =>
619 Effect.gen(function* () {
620 const host = yield* DesktopBrowserHost.make;
621 const debuggee = makeDebuggee();
622 host.attach(key, debuggee.tab);
623 const replies = yield* watchReplies(host);
624 const painting = yield* watchPainting(host);
625 const raw = (message: Record<string, unknown>) =>
626 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
627
628 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
629 expect(yield* Queue.take(replies)).toBe(1);
630 yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" });
631 yield* settle;
632 yield* raw({
633 id: 3,
634 method: "Target.detachFromTarget",
635 params: { sessionId: "t3-preview-page-1" },
636 });
637 expect(yield* Queue.take(replies)).toBe(3);
638 expect(painting).toEqual([true]);
639
640 // The agent's screenshot waits its turn; painting lets the abandoned one finish first.
641 yield* command(host, 4, "Page.captureScreenshot");
642 yield* settle;
643 expect(painting).toEqual([true]);
644 debuggee.release();
645 expect(yield* Queue.take(replies)).toBe(2);
646 yield* settle;
647 expect(debuggee.sent).toEqual([undefined, 4]);
648 expect(painting).toEqual([true]);
649 debuggee.release();
650 expect(yield* Queue.take(replies)).toBe(4);
651 yield* settle;
652 expect(painting).toEqual([true, false]);
653 }),
654 );
655
656 it.effect("stops painting for a queued screenshot once its session detaches", () =>
657 Effect.gen(function* () {
658 const host = yield* DesktopBrowserHost.make;
659 const debuggee = makeDebuggee();
660 host.attach(key, debuggee.tab);
661 const replies = yield* watchReplies(host);
662 const painting = yield* watchPainting(host);
663 const raw = (message: Record<string, unknown>) =>
664 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
665 const turn = (active: boolean) =>
666 host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active }));
667
668 // The agent's screenshot is abandoned at turn end; a viewer's waits behind it, then leaves.
669 yield* turn(true);
670 yield* command(host, 1, "Page.captureScreenshot");
671 yield* settle;
672 yield* turn(false);
673 yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
674 expect(yield* Queue.take(replies)).toBe(2);
675 yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" });
676 yield* settle;
677 expect(painting).toEqual([true]);
678 yield* raw({
679 id: 4,
680 method: "Target.detachFromTarget",
681 params: { sessionId: "t3-preview-page-1" },
682 });
683 expect(yield* Queue.take(replies)).toBe(4);
684
685 // The agent's answers late; the viewer's is dropped unsent, so nothing is left to paint.
686 debuggee.release();
687 yield* settle;
688 expect(debuggee.sent).toEqual([1]);
689 expect(painting).toEqual([true, false]);
690 yield* TestClock.adjust("1 minute");
691 expect(debuggee.focusEmulation).toEqual([true, false]);
692 }),
693 );
694
695 it.effect("never sends a screenshot whose session detached while focus was changing", () =>
696 Effect.gen(function* () {
697 const host = yield* DesktopBrowserHost.make;
698 const debuggee = makeDebuggee({ deferFocus: true });
699 host.attach(key, debuggee.tab);
700 const replies = yield* watchReplies(host);
701 const raw = (message: Record<string, unknown>) =>
702 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
703
704 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
705 expect(yield* Queue.take(replies)).toBe(1);
706 yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" });
707 yield* raw({
708 id: 3,
709 method: "Target.detachFromTarget",
710 params: { sessionId: "t3-preview-page-1" },
711 });
712 expect(yield* Queue.take(replies)).toBe(3);
713 debuggee.settleFocus();
714 yield* settle;
715 expect(debuggee.sent).toEqual([]);
716 }),
717 );
718
719 it.effect("holds no focus for a screenshot still queued when the agent's turn ends", () =>
720 Effect.gen(function* () {
721 const host = yield* DesktopBrowserHost.make;
722 const debuggee = makeDebuggee();
723 host.attach(key, debuggee.tab);
724 const painting = yield* watchPainting(host);
725 const turn = (active: boolean) =>
726 host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active }));
727
728 yield* turn(true);
729 yield* command(host, 1, "Page.captureScreenshot");
730 yield* command(host, 2, "Page.captureScreenshot");
731 yield* settle;
732 yield* turn(false);
733 yield* settle;
734 expect(painting).toEqual([true]);
735
736 // The first answers late; the second still goes in turn, painting but holding no focus.
737 debuggee.releaseFirst();
738 yield* settle;
739 expect(debuggee.sent).toEqual([1, 2]);
740 yield* TestClock.adjust("1 minute");
741 expect(debuggee.focusEmulation).toEqual([true, false]);
742 expect(painting).toEqual([true]);
743 debuggee.release();
744 yield* settle;
745 expect(painting).toEqual([true, false]);
746 }),
747 );
748
749 it.effect("lets a guest throttle when Chromium refuses a viewer's screencast", () =>
750 Effect.gen(function* () {
751 const host = yield* DesktopBrowserHost.make;
752 const debuggee = makeDebuggee({ refuse: (method) => method === "Page.startScreencast" });
753 host.attach(key, debuggee.tab);
754 const replies = yield* watchReplies(host);
755 const painting = yield* watchPainting(host);
756 const raw = (message: Record<string, unknown>) =>
757 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
758
759 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
760 expect(yield* Queue.take(replies)).toBe(1);
761 yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" });
762 expect(yield* Queue.take(replies)).toBe(2);
763 yield* settle;
764 expect(painting).toEqual([true, false]);
765 yield* TestClock.adjust("1 minute");
766 expect(debuggee.focusEmulation).toEqual([true, false]);
767 }),
768 );
769
770 it.effect("lets a guest throttle after a person's input through a viewer that then closed", () =>
771 Effect.gen(function* () {
772 const host = yield* DesktopBrowserHost.make;
773 const debuggee = makeDebuggee();
774 host.attach(key, debuggee.tab);
775 const replies = yield* watchReplies(host);
776 const raw = (message: Record<string, unknown>) =>
777 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
778
779 yield* host.handleCommandLine(
780 encodeJson({ type: "turn", threadId: key.threadId, active: true }),
781 );
782 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
783 expect(yield* Queue.take(replies)).toBe(1);
784 yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" });
785 yield* raw({ id: 3, method: "Input.dispatchMouseEvent", sessionId: "t3-preview-page-1" });
786 yield* settle;
787 debuggee.release();
788 yield* raw({
789 id: 4,
790 method: "Target.detachFromTarget",
791 params: { sessionId: "t3-preview-page-1" },
792 });
793 yield* settle;
794 yield* TestClock.adjust("1 minute");
795 expect(debuggee.focusEmulation).toEqual([true, false]);
796 }),
797 );
798
799 it.effect("keeps a page's screencast when a child session's screencast is refused", () =>
800 Effect.gen(function* () {
801 const host = yield* DesktopBrowserHost.make;
802 const debuggee = makeDebuggee({
803 refuse: (method, sessionId) => method === "Page.startScreencast" && sessionId !== undefined,
804 });
805 host.attach(key, debuggee.tab);
806 const replies = yield* watchReplies(host);
807 const raw = (message: Record<string, unknown>) =>
808 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
809
810 yield* command(host, 1, "Page.startScreencast");
811 yield* settle;
812 debuggee.release();
813 expect(yield* Queue.take(replies)).toBe(1);
814 yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "child-frame" });
815 expect(yield* Queue.take(replies)).toBe(2);
816 yield* TestClock.adjust("1 minute");
817 expect(debuggee.focusEmulation).toEqual([true]);
818 }),
819 );
820
821 it.effect("ignores a screencast that arrives on a session after it detached", () =>
822 Effect.gen(function* () {
823 const host = yield* DesktopBrowserHost.make;
824 const debuggee = makeDebuggee();
825 host.attach(key, debuggee.tab);
826 const replies = yield* watchReplies(host);
827 const raw = (message: Record<string, unknown>) =>
828 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
829
830 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
831 expect(yield* Queue.take(replies)).toBe(1);
832 yield* raw({
833 id: 2,
834 method: "Target.detachFromTarget",
835 params: { sessionId: "t3-preview-page-1" },
836 });
837 expect(yield* Queue.take(replies)).toBe(2);
838 yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" });
839 yield* settle;
840 debuggee.release();
841 yield* settle;
842 yield* TestClock.adjust("1 minute");
843 expect(debuggee.focusEmulation).toEqual([true, false]);
844 }),
845 );
846
847 it.effect("lets a guest throttle once the session waiting on its command detaches", () =>
848 Effect.gen(function* () {
849 const host = yield* DesktopBrowserHost.make;
850 const debuggee = makeDebuggee();
851 host.attach(key, debuggee.tab);
852 const replies = yield* watchReplies(host);
853 const raw = (message: Record<string, unknown>) =>
854 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
855
856 // A viewer's own CDP session, closed while its still of a hidden guest is pending.
857 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
858 expect(yield* Queue.take(replies)).toBe(1);
859 yield* raw({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" });
860 yield* settle;
861 yield* TestClock.adjust("1 minute");
862 expect(debuggee.focusEmulation).toEqual([true]);
863
864 yield* raw({
865 id: 3,
866 method: "Target.detachFromTarget",
867 params: { sessionId: "t3-preview-page-1" },
868 });
869 yield* TestClock.adjust("1 minute");
870 expect(debuggee.focusEmulation).toEqual([true, false]);
871 }),
872 );
873
874 it.effect("never sends a screenshot queued by a session that has since detached", () =>
875 Effect.gen(function* () {
876 const host = yield* DesktopBrowserHost.make;
877 const debuggee = makeDebuggee();
878 host.attach(key, debuggee.tab);
879 const replies = yield* watchReplies(host);
880 const raw = (message: Record<string, unknown>) =>
881 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
882
883 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
884 expect(yield* Queue.take(replies)).toBe(1);
885 yield* command(host, 2, "Page.captureScreenshot");
886 yield* raw({ id: 3, method: "Page.captureScreenshot", sessionId: "t3-preview-page-1" });
887 yield* raw({
888 id: 4,
889 method: "Target.detachFromTarget",
890 params: { sessionId: "t3-preview-page-1" },
891 });
892 yield* settle;
893 debuggee.release();
894 expect(yield* Queue.take(replies)).toBe(4);
895 // The dropped screenshot's error reply leaves as the first frees its turn.
896 expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([3, 2]);
897 yield* settle;
898 expect(debuggee.sent).toEqual([2]);
899
900 yield* TestClock.adjust("1 minute");
901 expect(debuggee.focusEmulation).toEqual([true, false]);
902 }),
903 );
904
905 it.effect("keeps a streamed guest focused until its screencast ends", () =>
906 Effect.gen(function* () {
907 const host = yield* DesktopBrowserHost.make;
908 const debuggee = makeDebuggee();
909 host.attach(key, debuggee.tab);
910
911 yield* command(host, 1, "Page.startScreencast");
912 yield* TestClock.adjust("1 minute");
913 expect(debuggee.focusEmulation).toEqual([true]);
914
915 yield* command(host, 2, "Page.stopScreencast");
916 yield* settle;
917 debuggee.release();
918 yield* settle;
919 yield* TestClock.adjust("1 minute");
920 expect(debuggee.focusEmulation).toEqual([true, false]);
921
922 // A viewer whose connection went away without stopping its screencast.
923 yield* command(host, 3, "Page.startScreencast");
924 yield* settle;
925 debuggee.release();
926 yield* settle;
927 yield* TestClock.adjust("1 minute");
928 yield* host.handleCommandLine(encodeJson({ type: "release", ...key }));
929 yield* TestClock.adjust("1 minute");
930 expect(debuggee.focusEmulation).toEqual([true, false, true, false]);
931 }),
932 );
933
934 it.effect("keeps a guest focused while any of its screencasts runs", () =>
935 Effect.gen(function* () {
936 const host = yield* DesktopBrowserHost.make;
937 const debuggee = makeDebuggee();
938 host.attach(key, debuggee.tab);
939 const replies = yield* watchReplies(host);
940 const raw = (message: Record<string, unknown>) =>
941 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
942
943 // Two viewers, or a viewer and a recording, each on its own session.
944 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
945 yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
946 expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]);
947 yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" });
948 yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" });
949 yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" });
950 yield* settle;
951 debuggee.release();
952 yield* settle;
953 yield* TestClock.adjust("1 minute");
954 expect(debuggee.focusEmulation).toEqual([true]);
955
956 yield* raw({
957 id: 6,
958 method: "Target.detachFromTarget",
959 params: { sessionId: "t3-preview-page-2" },
960 });
961 yield* TestClock.adjust("1 minute");
962 expect(debuggee.focusEmulation).toEqual([true, false]);
963 }),
964 );
965
966 it.effect("paints a hidden guest only while the screenshot it is taking is pending", () =>
967 Effect.gen(function* () {
968 const host = yield* DesktopBrowserHost.make;
969 const debuggee = makeDebuggee();
970 host.attach(key, debuggee.tab);
971 const painting = yield* watchPainting(host);
972
973 yield* command(host, 1, "DOM.enable");
974 yield* settle;
975 expect(painting).toEqual([]);
976
977 yield* command(host, 2, "Page.captureScreenshot");
978 yield* command(host, 3, "Page.captureScreenshot");
979 yield* settle;
980 expect(painting).toEqual([true]);
981
982 // The first answers once the guest paints; the queued one then takes its turn.
983 debuggee.release();
984 yield* settle;
985 expect(debuggee.sent).toEqual([1, 2, 3]);
986 debuggee.release();
987 yield* settle;
988 expect(painting).toEqual([true, false]);
989 }),
990 );
991
992 it.effect("stops painting for a screenshot Chromium never answers once its document goes", () =>
993 Effect.gen(function* () {
994 const host = yield* DesktopBrowserHost.make;
995 const debuggee = makeDebuggee();
996 host.attach(key, debuggee.tab);
997 const painting = yield* watchPainting(host);
998 const turn = (active: boolean) =>
999 host.handleCommandLine(encodeJson({ type: "turn", threadId: key.threadId, active }));
1000
1001 // The server gave up on it, and the agent's turn ends; Chromium still holds the capture.
1002 yield* turn(true);
1003 yield* command(host, 1, "Page.captureScreenshot");
1004 yield* settle;
1005 yield* turn(false);
1006 yield* settle;
1007 expect(painting).toEqual([true]);
1008
1009 // The page loads a new document, which also frees the screenshot turn.
1010 debuggee.navigate();
1011 expect(painting).toEqual([true, false]);
1012 yield* command(host, 2, "Page.captureScreenshot");
1013 yield* settle;
1014 debuggee.navigate();
1015 yield* settle;
1016 expect(painting).toEqual([true, false, true, false]);
1017
1018 // The server releases the relay; Chromium still holds the capture.
1019 yield* command(host, 3, "Page.captureScreenshot");
1020 yield* settle;
1021 yield* host.handleCommandLine(encodeJson({ type: "release", ...key }));
1022 yield* settle;
1023 expect(painting).toEqual([true, false, true, false, true]);
1024
1025 // The guest detaches; a replacement starts with no screenshot pending.
1026 const replacement = makeDebuggee();
1027 host.attach(key, replacement.tab);
1028 expect(painting).toEqual([true, false, true, false, true, false]);
1029 yield* command(host, 4, "Page.captureScreenshot");
1030 yield* settle;
1031 host.detach(key);
1032 yield* settle;
1033 expect(painting).toEqual([true, false, true, false, true, false, true, false]);
1034 }),
1035 );
1036
1037 it.effect("paints a streamed guest until its last screencast ends", () =>
1038 Effect.gen(function* () {
1039 const host = yield* DesktopBrowserHost.make;
1040 const debuggee = makeDebuggee();
1041 host.attach(key, debuggee.tab);
1042 const replies = yield* watchReplies(host);
1043 const painting = yield* watchPainting(host);
1044 const raw = (message: Record<string, unknown>) =>
1045 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
1046
1047 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
1048 yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
1049 expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]);
1050 yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" });
1051 yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" });
1052 yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" });
1053 yield* settle;
1054 debuggee.release();
1055 yield* settle;
1056 expect(painting).toEqual([true]);
1057
1058 yield* raw({
1059 id: 6,
1060 method: "Target.detachFromTarget",
1061 params: { sessionId: "t3-preview-page-2" },
1062 });
1063 yield* settle;
1064 expect(painting).toEqual([true, false]);
1065 }),
1066 );
1067
1068 it.effect("keeps a guest's one screencast running until its last streaming session stops", () =>
1069 Effect.gen(function* () {
1070 const host = yield* DesktopBrowserHost.make;
1071 const debuggee = makeDebuggee();
1072 host.attach(key, debuggee.tab);
1073 const frames: Array<string | undefined> = [];
1074 const replies = yield* watchReplies(host, frames);
1075 const raw = (message: Record<string, unknown>) =>
1076 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
1077 const nativeStops = () =>
1078 debuggee.methods.filter((method) => method === "Page.stopScreencast").length;
1079
1080 // A viewer and a recording share Chromium's single screencast for the guest.
1081 yield* raw({ id: 0, method: "Target.setAutoAttach", params: { autoAttach: true } });
1082 expect(yield* Queue.take(replies)).toBe(0);
1083 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
1084 yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
1085 expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]);
1086 yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" });
1087 yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" });
1088 yield* settle;
1089 debuggee.release();
1090 yield* raw({ id: 5, method: "Page.stopScreencast", sessionId: "t3-preview-page-1" });
1091 yield* settle;
1092 expect(nativeStops(), "stopped the screencast another session still streams").toBe(0);
1093 debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 });
1094 yield* settle;
1095 expect(frames, "sent a stopped or idle session the screencast's frames").toEqual([
1096 "t3-preview-page-2",
1097 ]);
1098
1099 yield* raw({ id: 6, method: "Page.stopScreencast", sessionId: "t3-preview-page-2" });
1100 yield* settle;
1101 expect(nativeStops()).toBe(1);
1102 }),
1103 );
1104
1105 it.effect("keeps a session's screencast when another of its starts is refused", () =>
1106 Effect.gen(function* () {
1107 const host = yield* DesktopBrowserHost.make;
1108 const debuggee = makeDebuggee();
1109 host.attach(key, debuggee.tab);
1110 const frames: Array<string | undefined> = [];
1111 const replies = yield* watchReplies(host, frames);
1112 const raw = (message: Record<string, unknown>) =>
1113 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
1114
1115 yield* raw({ id: 0, method: "Target.setAutoAttach", params: { autoAttach: true } });
1116 expect(yield* Queue.take(replies)).toBe(0);
1117 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
1118 expect(yield* Queue.take(replies)).toBe(1);
1119 yield* raw({
1120 id: 2,
1121 method: "Page.startScreencast",
1122 params: { id: 2 },
1123 sessionId: "t3-preview-page-1",
1124 });
1125 yield* raw({
1126 id: 3,
1127 method: "Page.startScreencast",
1128 params: { id: 3 },
1129 sessionId: "t3-preview-page-1",
1130 });
1131 yield* settle;
1132 debuggee.refuse(2);
1133 debuggee.release();
1134 expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([2, 3]);
1135 yield* settle;
1136 expect(debuggee.methods).not.toContain("Page.stopScreencast");
1137 debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 1 });
1138 yield* settle;
1139 expect(frames).toContain("t3-preview-page-1");
1140
1141 // A refused replacement leaves the session's running stream, shared with another session.
1142 yield* raw({ id: 4, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
1143 expect(yield* Queue.take(replies)).toBe(4);
1144 yield* raw({
1145 id: 5,
1146 method: "Page.startScreencast",
1147 params: { id: 5 },
1148 sessionId: "t3-preview-page-2",
1149 });
1150 yield* raw({
1151 id: 6,
1152 method: "Page.startScreencast",
1153 params: { id: 6 },
1154 sessionId: "t3-preview-page-1",
1155 });
1156 yield* settle;
1157 debuggee.refuse(6);
1158 debuggee.release();
1159 expect([yield* Queue.take(replies), yield* Queue.take(replies)].toSorted()).toEqual([5, 6]);
1160 yield* settle;
1161 frames.length = 0;
1162 debuggee.emit("Page.screencastFrame", { data: "frame", sessionId: 2 });
1163 yield* settle;
1164 expect(frames).toEqual(["t3-preview-page-1", "t3-preview-page-2"]);
1165 }),
1166 );
1167
1168 it.effect("stops a guest's screencast once its last streaming session detaches", () =>
1169 Effect.gen(function* () {
1170 const host = yield* DesktopBrowserHost.make;
1171 const debuggee = makeDebuggee();
1172 host.attach(key, debuggee.tab);
1173 const replies = yield* watchReplies(host);
1174 const raw = (message: Record<string, unknown>) =>
1175 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
1176 const nativeStops = () =>
1177 debuggee.methods.filter((method) => method === "Page.stopScreencast").length;
1178
1179 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
1180 yield* raw({ id: 2, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
1181 expect([yield* Queue.take(replies), yield* Queue.take(replies)]).toEqual([1, 2]);
1182 yield* raw({ id: 3, method: "Page.startScreencast", sessionId: "t3-preview-page-1" });
1183 yield* raw({ id: 4, method: "Page.startScreencast", sessionId: "t3-preview-page-2" });
1184 yield* settle;
1185 debuggee.release();
1186 // Streamers go without stopping, such as viewers that closed.
1187 yield* raw({
1188 id: 5,
1189 method: "Target.detachFromTarget",
1190 params: { sessionId: "t3-preview-page-1" },
1191 });
1192 yield* settle;
1193 expect(nativeStops(), "stopped the screencast another session still streams").toBe(0);
1194 yield* raw({
1195 id: 6,
1196 method: "Target.detachFromTarget",
1197 params: { sessionId: "t3-preview-page-2" },
1198 });
1199 const answered = [];
1200 for (let reply = 0; reply < 4; reply++) answered.push(yield* Queue.take(replies));
1201 expect(answered.toSorted()).toEqual([3, 4, 5, 6]);
1202 yield* settle;
1203 expect(nativeStops()).toBe(1);
1204 }),
1205 );
1206
1207 it.effect("stops a screencast whose start lands after its session detached", () =>
1208 Effect.gen(function* () {
1209 const host = yield* DesktopBrowserHost.make;
1210 const debuggee = makeDebuggee({ deferFocus: true });
1211 host.attach(key, debuggee.tab);
1212 const replies = yield* watchReplies(host);
1213 const raw = (message: Record<string, unknown>) =>
1214 host.handleCommandLine(encodeJson({ type: "cdp", ...key, message: encodeJson(message) }));
1215
1216 yield* raw({ id: 1, method: "Target.attachToTarget", params: { targetId: "GUEST" } });
1217 expect(yield* Queue.take(replies)).toBe(1);
1218 // The start waits on focus emulation while the viewer closes.
1219 yield* raw({ id: 2, method: "Page.startScreencast", sessionId: "t3-preview-page-1" });
1220 yield* raw({
1221 id: 3,
1222 method: "Target.detachFromTarget",
1223 params: { sessionId: "t3-preview-page-1" },
1224 });
1225 expect(yield* Queue.take(replies)).toBe(3);
1226 debuggee.settleFocus();
1227 yield* settle;
1228 debuggee.release();
1229 expect(yield* Queue.take(replies)).toBe(2);
1230 yield* settle;
1231 expect(debuggee.methods.at(-1), "left the late screencast running").toBe(
1232 "Page.stopScreencast",
1233 );
1234 }),
1235 );
1236
1237 it.effect("saves a server tab's download under its CDP guid where the server asked", () =>
1238 Effect.gen(function* () {
1239 const host = yield* DesktopBrowserHost.make;
1240 const debuggee = makeDebuggee();
1241 host.attach(key, debuggee.tab);
1242 const replies = yield* watchReplies(host);
1243 const paths: Array<string> = [];
1244 const item = {
1245 setSavePath: (path: string) => void paths.push(path),
1246 } as unknown as Electron.DownloadItem;
1247
1248 expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(false);
1249 const sent = debuggee.nextSend();
1250 yield* host.handleCommandLine(
1251 encodeJson({
1252 type: "cdp",
1253 ...key,
1254 message: encodeJson({
1255 id: 1,
1256 method: "Browser.setDownloadBehavior",
1257 params: { behavior: "allowAndName", downloadPath: "/srv/downloads" },
1258 }),
1259 }),
1260 );
1261 yield* Effect.promise(() => sent);
1262 debuggee.release();
1263 expect(yield* Queue.take(replies)).toBe(1);
1264 debuggee.emit("Page.downloadWillBegin", { guid: "guid-1", suggestedFilename: "r.csv" });
1265 expect(host.placeDownload(makeDebuggee().tab.webContents, item)).toBe(false);
1266 expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(true);
1267 expect(paths).toEqual(["/srv/downloads/guid-1"]);
1268 expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(false);
1269 }),
1270 );
1271
1272 it.effect("tells a download the person clicked from one the agent's input started", () =>
1273 Effect.gen(function* () {
1274 const host = yield* DesktopBrowserHost.make;
1275 const debuggee = makeDebuggee();
1276 host.attach(key, debuggee.tab);
1277
1278 yield* host.handleCommandLine(
1279 encodeJson({
1280 type: "cdp",
1281 ...key,
1282 message: encodeJson({ id: 1, method: "DOM.getDocument", params: {} }),
1283 }),
1284 );
1285 expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(true);
1286 yield* host.handleCommandLine(
1287 encodeJson({
1288 type: "cdp",
1289 ...key,
1290 message: encodeJson({
1291 id: 2,
1292 method: "Input.dispatchMouseEvent",
1293 params: { type: "mousePressed", x: 1, y: 1 },
1294 }),
1295 }),
1296 );
1297 expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(false);
1298 }),
1299 );
1300
1301 it.effect("detaches only the tab whose CDP event is too large to relay", () =>
1302 Effect.gen(function* () {
1303 const host = yield* DesktopBrowserHost.make;
1304 const tabs = [makeDebuggee({ deferFocus: true }), makeDebuggee()];
1305 const send = vi.spyOn(tabs[0]!.tab.debugger, "sendCommand");
1306 const keys = [key, { threadId: "thread-2", tabId: "tab-2" }];
1307 const events = yield* Queue.unbounded<DesktopBrowserEvent>();
1308 yield* host.events.pipe(
1309 Stream.runForEach((line) =>
1310 Queue.offer(events, decodeEvent(new TextDecoder().decode(line))),
1311 ),
1312 Effect.forkScoped,
1313 );
1314 for (const [index, tabKey] of keys.entries()) {
1315 host.attach(tabKey, tabs[index]!.tab);
1316 expect(yield* Queue.take(events)).toEqual({ type: "attached", ...tabKey });
1317 yield* host.handleCommandLine(
1318 encodeJson({
1319 type: "cdp",
1320 ...tabKey,
1321 message: encodeJson({ id: 1, method: "Target.setAutoAttach" }),
1322 }),
1323 );
1324 // The command's reply and the page announcement.
1325 yield* Queue.takeN(events, 2);
1326 }
1327
1328 yield* host.handleCommandLine(
1329 encodeJson({
1330 type: "cdp",
1331 ...key,
1332 message: encodeJson({
1333 id: 2,
1334 method: "Target.setAutoAttach",
1335 params: { autoAttach: true, waitForDebuggerOnStart: true, flatten: true },
1336 sessionId: "t3-preview-page",
1337 }),
1338 }),
1339 );
1340 yield* command(host, 3, "Page.startScreencast");
1341 tabs[0]!.emit("Runtime.consoleAPICalled", {
1342 args: Array(2048).fill("x".repeat(40 * 1024)),
1343 });
1344 tabs[1]!.emit("Page.loadEventFired", { timestamp: 1 });
1345 const [detached, next] = yield* Queue.takeN(events, 2);
1346
1347 const { type, threadId, tabId } = detached!;
1348 expect({ type, threadId, tabId }).toEqual({ type: "detached", ...key });
1349 expect(send).toHaveBeenCalledWith("Target.setAutoAttach", {
1350 autoAttach: false,
1351 waitForDebuggerOnStart: false,
1352 flatten: true,
1353 });
1354 tabs[0]!.settleFocus();
1355 yield* settle;
1356 expect(send).not.toHaveBeenCalledWith("Target.setAutoAttach", {
1357 autoAttach: true,
1358 waitForDebuggerOnStart: true,
1359 flatten: true,
1360 });
1361 expect(send).toHaveBeenCalledWith("Page.stopScreencast");
1362 expect(tabs[0]!.focusEmulation).toEqual([true, false]);
1363 expect(next).toMatchObject({ type: "cdp", ...keys[1] });
1364 expect(JSON.parse((next as { message: string }).message)).toMatchObject({
1365 method: "Page.loadEventFired",
1366 });
1367 }),
1368 );
1369});