1// @effect-diagnostics nodeBuiltinImport:off - Stands in for an Electron debugger.
2import { describe, expect, it } from "@effect/vitest";
3import { DesktopBrowserEvent } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
6import * as Schema from "effect/Schema";
7import * as Stream from "effect/Stream";
8import * as NodeEvents from "node:events";
9
10import * as DesktopBrowserHost from "./DesktopBrowserHost.ts";
11
12const decodeEvent = Schema.decodeUnknownSync(Schema.fromJsonString(DesktopBrowserEvent));
13const encodeJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown));
14const decodeCdpReply = Schema.decodeUnknownSync(
15 Schema.fromJsonString(Schema.Struct({ id: Schema.Number })),
16);
17const key = { threadId: "thread-1", tabId: "tab-1" };
18
19/** A tab's webContents and debugger, with the debugger's commands left pending until released. */
20const makeDebuggee = () => {
21 const emitter = new NodeEvents.EventEmitter();
22 const pending: Array<() => void> = [];
23 const debuggee = Object.assign(emitter, {
24 sendCommand: (method: string) =>
25 new Promise((resolve) => {
26 if (method === "Target.getTargetInfo") {
27 resolve({ targetInfo: { targetId: "GUEST" } });
28 return;
29 }
30 pending.push(() => resolve({ method }));
31 }),
32 });
33 const webContents = {
34 getURL: () => "http://localhost/",
35 getTitle: () => "Page",
36 getUserAgent: () => "Electron",
37 };
38 return {
39 tab: {
40 webContents: webContents as unknown as Electron.WebContents,
41 debugger: debuggee as unknown as Electron.Debugger,
42 },
43 emit: (method: string, params: unknown) => emitter.emit("message", {}, method, params, ""),
44 release: () => pending.splice(0).forEach((resolve) => resolve()),
45 };
46};
47
48/** Reads `count` events from one backend's subscription. */
49const takeEvents = (host: DesktopBrowserHost.DesktopBrowserHost["Service"], count: number) =>
50 host.events.pipe(
51 Stream.take(count),
52 Stream.runCollect,
53 Effect.map((lines) => lines.map((line) => decodeEvent(new TextDecoder().decode(line)))),
54 );
55
56describe("DesktopBrowserHost", () => {
57 it.effect("announces tabs already attached to a backend that starts later", () =>
58 Effect.gen(function* () {
59 const host = yield* DesktopBrowserHost.make;
60 host.attach(key, makeDebuggee().tab);
61 // A restarted backend subscribes after the attach and still hears it.
62 expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]);
63 expect(yield* takeEvents(host, 1)).toEqual([{ type: "attached", ...key }]);
64 }),
65 );
66
67 it.effect("drops replies from a relay the server released", () =>
68 Effect.gen(function* () {
69 const host = yield* DesktopBrowserHost.make;
70 const debuggee = makeDebuggee();
71 host.attach(key, debuggee.tab);
72 const reader = yield* takeEvents(host, 2).pipe(Effect.forkScoped);
73 // Wait until the reader has received the announcement.
74 yield* Effect.yieldNow;
75 const command = (id: number, method: string) =>
76 host.handleCommandLine(
77 encodeJson({
78 type: "cdp",
79 ...key,
80 message: encodeJson({ id, method, sessionId: "t3-preview-page" }),
81 }),
82 );
83 yield* command(1, "Page.captureScreenshot");
84 yield* host.handleCommandLine(encodeJson({ type: "release", ...key }));
85 yield* command(2, "DOM.enable");
86 debuggee.release();
87 yield* Effect.promise(() => new Promise((resolve) => setImmediate(resolve)));
88 const [, reply] = yield* Fiber.join(reader);
89 // Only the new connection's reply arrives; the old one's id could collide.
90 expect(reply).toMatchObject({ type: "cdp" });
91 expect(decodeCdpReply((reply as { message: string }).message).id).toBe(2);
92 }).pipe(Effect.scoped),
93 );
94
95 it.effect("saves a server tab's download under its CDP guid where the server asked", () =>
96 Effect.gen(function* () {
97 const host = yield* DesktopBrowserHost.make;
98 const debuggee = makeDebuggee();
99 host.attach(key, debuggee.tab);
100 const paths: Array<string> = [];
101 const item = {
102 setSavePath: (path: string) => void paths.push(path),
103 } as unknown as Electron.DownloadItem;
104 // Before the server sets a directory, Electron keeps its own handling.
105 expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(false);
106 yield* host.handleCommandLine(
107 encodeJson({
108 type: "cdp",
109 ...key,
110 message: encodeJson({
111 id: 1,
112 method: "Browser.setDownloadBehavior",
113 params: { behavior: "allowAndName", downloadPath: "/srv/downloads" },
114 }),
115 }),
116 );
117 debuggee.emit("Browser.downloadWillBegin", { guid: "guid-1", suggestedFilename: "r.csv" });
118 expect(host.placeDownload(debuggee.tab.webContents, item)).toBe(true);
119 expect(paths).toEqual(["/srv/downloads/guid-1"]);
120 }),
121 );
122
123 it.effect("tells a download the person clicked from one the agent's input started", () =>
124 Effect.gen(function* () {
125 const host = yield* DesktopBrowserHost.make;
126 const debuggee = makeDebuggee();
127 host.attach(key, debuggee.tab);
128 // Reading the page is not acting on it.
129 yield* host.handleCommandLine(
130 encodeJson({
131 type: "cdp",
132 ...key,
133 message: encodeJson({ id: 1, method: "DOM.getDocument", params: {} }),
134 }),
135 );
136 expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(true);
137 yield* host.handleCommandLine(
138 encodeJson({
139 type: "cdp",
140 ...key,
141 message: encodeJson({
142 id: 2,
143 method: "Input.dispatchMouseEvent",
144 params: { type: "mousePressed", x: 1, y: 1 },
145 }),
146 }),
147 );
148 expect(host.humanStartedDownload(debuggee.tab.webContents)).toBe(false);
149 }),
150 );
151});