1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 createCdpRelayConnection,
5 type CdpRelayConnection,
6 type CdpRelayTarget,
7} from "./CdpRelay.ts";
8
9const makeTarget = (
10 send: CdpRelayTarget["send"],
11 setDownloadDirectory: CdpRelayTarget["setDownloadDirectory"] = () => {},
12): CdpRelayTarget => ({
13 send,
14 targetId: async () => "GUEST-TARGET",
15 url: () => "http://localhost:4719/",
16 title: () => "Fixture",
17 userAgent: () => "Electron",
18 setDownloadDirectory,
19 receivesScreencast: () => true,
20});
21
22const makeRelay = (target: CdpRelayTarget, write: (raw: string) => void) =>
23 createCdpRelayConnection(target, write, () => {});
24
25// Each relay reply resolves after a few microtasks; one macrotask lets them all land.
26// oxlint-disable-next-line t3code/no-real-time-wait-in-tests -- a zero-delay macrotask boundary, not a wait for time
27const settle = () => new Promise<void>((resolve) => setImmediate(resolve));
28
29describe("CDP relay", () => {
30 it("announces only the tab's page, under its real target id", async () => {
31 const written: Array<Record<string, unknown>> = [];
32 const relay = makeRelay(makeTarget(vi.fn()), (raw) => written.push(JSON.parse(raw)));
33 relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
34 relay.receive(JSON.stringify({ id: 2, method: "Target.createTarget", params: {} }));
35 await settle();
36 const attach = written.findIndex((message) => message["id"] === 1);
37 expect(written.slice(attach, attach + 2)).toEqual([
38 { id: 1, result: {} },
39 {
40 method: "Target.attachedToTarget",
41 params: expect.objectContaining({
42 sessionId: "t3-preview-page",
43 targetInfo: expect.objectContaining({ targetId: "GUEST-TARGET", type: "page" }),
44 }),
45 },
46 ]);
47 expect(written).toContainEqual({
48 id: 2,
49 error: expect.objectContaining({ message: expect.stringContaining("createTarget") }),
50 });
51 });
52
53 it("forwards page commands to the tab and lets a slow one finish last", async () => {
54 const pending: Array<() => void> = [];
55 const send = vi.fn(
56 (method: string) =>
57 new Promise<unknown>((resolve) => pending.push(() => resolve({ method }))),
58 );
59 const written: Array<Record<string, unknown>> = [];
60 const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw)));
61 relay.receive(
62 JSON.stringify({ id: 5, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }),
63 );
64 relay.receive(JSON.stringify({ id: 6, method: "DOM.enable", sessionId: "iframe-session" }));
65 pending[1]!();
66 await settle();
67 expect(written).toEqual([
68 { id: 6, result: { method: "DOM.enable" }, sessionId: "iframe-session" },
69 ]);
70 pending[0]!();
71 await settle();
72 expect(send).toHaveBeenNthCalledWith(1, "Page.captureScreenshot", {}, undefined, undefined);
73 expect(send).toHaveBeenNthCalledWith(2, "DOM.enable", {}, "iframe-session", undefined);
74 expect(written.at(-1)).toEqual({
75 id: 5,
76 result: { method: "Page.captureScreenshot" },
77 sessionId: "t3-preview-page",
78 });
79 });
80
81 it("resumes paused child frames, so cross-site iframes run", async () => {
82 const send = vi.fn(async () => ({}));
83 const relay = makeRelay(makeTarget(send), () => {});
84 relay.receive(
85 JSON.stringify({
86 id: 1,
87 method: "Runtime.runIfWaitingForDebugger",
88 sessionId: "t3-preview-page",
89 }),
90 );
91 relay.receive(
92 JSON.stringify({
93 id: 2,
94 method: "Runtime.runIfWaitingForDebugger",
95 sessionId: "iframe-session",
96 }),
97 );
98 await settle();
99 expect(send.mock.calls).toEqual([
100 ["Runtime.runIfWaitingForDebugger", {}, "iframe-session", undefined],
101 ]);
102 });
103
104 // Playwright enables focus emulation on every page it connects to, and Chromium then
105 // counts the guest as captured: a preview the panel hides keeps its timers unthrottled.
106 it("acknowledges Playwright's focus emulation without forcing it onto the guest", async () => {
107 const send = vi.fn(async () => ({}));
108 const written: Array<Record<string, unknown>> = [];
109 const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw)));
110 relay.receive(
111 JSON.stringify({
112 id: 1,
113 method: "Emulation.setFocusEmulationEnabled",
114 params: { enabled: true },
115 sessionId: "t3-preview-page",
116 }),
117 );
118 await settle();
119 expect(written).toEqual([{ id: 1, result: {}, sessionId: "t3-preview-page" }]);
120 expect(send).not.toHaveBeenCalled();
121 });
122
123 it("routes tab events to the page session once the page is announced", async () => {
124 const written: Array<Record<string, unknown>> = [];
125 const relay = makeRelay(makeTarget(vi.fn()), (raw) => written.push(JSON.parse(raw)));
126 relay.event("Page.loadEventFired", {}, undefined);
127 expect(written).toEqual([]);
128 relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
129 await settle();
130 relay.event("Page.loadEventFired", { timestamp: 1 }, "");
131 relay.event("Runtime.consoleAPICalled", {}, "iframe-session");
132 expect(written.slice(-2)).toEqual([
133 { method: "Page.loadEventFired", params: { timestamp: 1 }, sessionId: "t3-preview-page" },
134 { method: "Runtime.consoleAPICalled", params: {}, sessionId: "iframe-session" },
135 ]);
136 });
137});
138
139describe("CDP relay extra sessions", () => {
140 it("gives a second page session the page's commands and events", async () => {
141 const send = vi.fn(async () => ({}));
142 const written: Array<Record<string, unknown>> = [];
143 const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw)));
144 relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
145 relay.receive(JSON.stringify({ id: 2, method: "Target.attachToBrowserTarget" }));
146 await settle();
147 const browserSession = (written.find((m) => m["id"] === 2)!["result"] as { sessionId: string })
148 .sessionId;
149 relay.receive(
150 JSON.stringify({
151 id: 3,
152 method: "Target.attachToTarget",
153 params: { targetId: "GUEST-TARGET", flatten: true },
154 sessionId: browserSession,
155 }),
156 );
157 relay.receive(
158 JSON.stringify({
159 id: 4,
160 method: "Target.attachToTarget",
161 params: { targetId: "SOMETHING-ELSE" },
162 sessionId: browserSession,
163 }),
164 );
165 await settle();
166 const extra = (written.find((m) => m["id"] === 3)!["result"] as { sessionId: string })
167 .sessionId;
168 expect(written.find((m) => m["id"] === 4)).toHaveProperty("error");
169 relay.receive(JSON.stringify({ id: 5, method: "Page.startScreencast", sessionId: extra }));
170 await settle();
171 expect(send).toHaveBeenLastCalledWith("Page.startScreencast", {}, undefined, extra);
172 written.length = 0;
173 relay.event("Page.screencastFrame", { data: "x" }, undefined);
174 expect(written.map((m) => m["sessionId"])).toEqual(["t3-preview-page", extra]);
175 });
176});
177
178describe("CDP relay downloads", () => {
179 it("captures preview downloads without changing the default browser context", async () => {
180 const send = vi.fn(async () => ({}));
181 const directories: Array<string | null> = [];
182 const written: Array<Record<string, unknown>> = [];
183 const relay = makeRelay(
184 makeTarget(send, (directory) => directories.push(directory)),
185 (raw) => written.push(JSON.parse(raw)),
186 );
187 relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
188 relay.receive(
189 JSON.stringify({
190 id: 2,
191 method: "Browser.setDownloadBehavior",
192 params: {
193 behavior: "allowAndName",
194 browserContextId: "t3-preview",
195 downloadPath: "/srv/artifacts",
196 eventsEnabled: true,
197 },
198 }),
199 );
200 await settle();
201 expect(directories).toEqual(["/srv/artifacts"]);
202 expect(send.mock.calls).toEqual([
203 ["Page.setDownloadBehavior", { behavior: "default" }, undefined],
204 ]);
205 written.length = 0;
206 relay.event("Page.downloadWillBegin", { guid: "g1", suggestedFilename: "r.csv" }, "");
207 relay.event("Page.downloadProgress", { guid: "g1", state: "completed" }, "");
208 expect(written).toEqual([
209 { method: "Browser.downloadWillBegin", params: { guid: "g1", suggestedFilename: "r.csv" } },
210 { method: "Browser.downloadProgress", params: { guid: "g1", state: "completed" } },
211 ]);
212 });
213
214 it("keeps denial scoped to the preview and restores Electron's save handling when allowed", async () => {
215 const send = vi.fn(async () => ({}));
216 const directories: Array<string | null> = [];
217 const relay = makeRelay(
218 makeTarget(send, (directory) => directories.push(directory)),
219 () => {},
220 );
221 for (const [id, behavior] of ["deny", "allowAndName"].entries()) {
222 relay.receive(
223 JSON.stringify({
224 id,
225 method: "Browser.setDownloadBehavior",
226 params: { behavior, downloadPath: "/srv/artifacts" },
227 }),
228 );
229 await settle();
230 }
231 expect(directories).toEqual([null, "/srv/artifacts"]);
232 expect(send.mock.calls).toEqual([
233 ["Page.setDownloadBehavior", { behavior: "deny" }, undefined],
234 ["Page.setDownloadBehavior", { behavior: "default" }, undefined],
235 ]);
236 });
237
238 it("does not publish download events when the connection has disabled them", async () => {
239 const written: Array<Record<string, unknown>> = [];
240 const relay = makeRelay(makeTarget(vi.fn(async () => ({}))), (raw) =>
241 written.push(JSON.parse(raw)),
242 );
243 relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
244 relay.receive(
245 JSON.stringify({
246 id: 2,
247 method: "Browser.setDownloadBehavior",
248 params: { behavior: "allowAndName", downloadPath: "/srv/artifacts", eventsEnabled: false },
249 }),
250 );
251 await settle();
252 written.length = 0;
253 relay.event("Page.downloadWillBegin", { guid: "g1" }, "");
254 relay.event("Page.downloadProgress", { guid: "g1", state: "completed" }, "");
255 expect(written).toEqual([]);
256 });
257
258 it.each([false, true])(
259 "keeps the previous download configuration (events enabled: %s) when configuration fails",
260 async (enabled) => {
261 const send = vi
262 .fn(async () => ({}))
263 .mockResolvedValueOnce({})
264 .mockRejectedValueOnce(new Error("Page command rejected"));
265 const written: Array<Record<string, unknown>> = [];
266 let directory: string | null = null;
267 const relay = makeRelay(
268 makeTarget(send, (value) => {
269 directory = value;
270 }),
271 (raw) => written.push(JSON.parse(raw)),
272 );
273 relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
274 for (const [id, eventsEnabled] of [enabled, !enabled].entries()) {
275 relay.receive(
276 JSON.stringify({
277 id: id + 2,
278 method: "Browser.setDownloadBehavior",
279 params: {
280 behavior: id === 1 && enabled ? "deny" : "allowAndName",
281 downloadPath: id === 0 ? "/srv/artifacts" : "/srv/new-artifacts",
282 eventsEnabled,
283 },
284 }),
285 );
286 await settle();
287 }
288 expect(written).toContainEqual({
289 id: 3,
290 error: { code: -32000, message: "Page command rejected" },
291 });
292 expect(directory).toBe("/srv/artifacts");
293 written.length = 0;
294 relay.event("Page.downloadWillBegin", { guid: "g1" }, "");
295 relay.event("Page.downloadProgress", { guid: "g1", state: "completed" }, "");
296 expect(written).toEqual(
297 enabled
298 ? [
299 { method: "Browser.downloadWillBegin", params: { guid: "g1" } },
300 { method: "Browser.downloadProgress", params: { guid: "g1", state: "completed" } },
301 ]
302 : [],
303 );
304 },
305 );
306});
307
308describe("CDP relay payload budget", () => {
309 // 2,048 references to one 40 KiB string: about 80 MiB once serialized, little memory here.
310 const oversized = Array(2048).fill("x".repeat(40 * 1024));
311
312 it("fails only an oversized reply and still returns a large screenshot", async () => {
313 const screenshot = "A".repeat(16 * 1024 * 1024);
314 const send = vi.fn(async (method: string) =>
315 method === "DOM.getDocument" ? { nodes: oversized } : { data: screenshot },
316 );
317 const written: Array<Record<string, unknown>> = [];
318 const relay = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw)));
319 relay.receive(
320 JSON.stringify({ id: 1, method: "DOM.getDocument", sessionId: "t3-preview-page" }),
321 );
322 relay.receive(
323 JSON.stringify({ id: 2, method: "Page.captureScreenshot", sessionId: "t3-preview-page" }),
324 );
325 await settle();
326
327 const replies = written.map(({ id, error, result }) => ({
328 id,
329 error,
330 size: (result as { data?: string } | undefined)?.data?.length,
331 }));
332 expect(replies).toEqual([
333 { id: 1, error: { code: -32000, message: "CDP payload too large" }, size: undefined },
334 { id: 2, error: undefined, size: screenshot.length },
335 ]);
336 });
337
338 it.each([
339 { args: oversized },
340 { args: "\u0001".repeat(12 * 1024 * 1024) },
341 { args: Array(2_700_000).fill(-0.0000010000000000000002) },
342 ])("closes instead of dropping an oversized event (%#)", async ({ args }) => {
343 const written: Array<Record<string, unknown>> = [];
344 const close = vi.fn();
345 const relay = createCdpRelayConnection(
346 makeTarget(vi.fn()),
347 (raw) => written.push(JSON.parse(raw)),
348 close,
349 );
350 relay.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
351 await settle();
352 written.length = 0;
353
354 relay.event("Runtime.consoleAPICalled", { args }, "");
355 relay.event("Page.loadEventFired", { timestamp: 1 }, "");
356
357 relay.receive(JSON.stringify({ id: 2, method: "Target.setAutoAttach" }));
358 expect(close).toHaveBeenCalledOnce();
359 expect(written.map((message) => message["method"])).toEqual([]);
360 });
361});
362
363describe("CDP relay reconnects", () => {
364 it("reports the page's default execution context to every connection", async () => {
365 // Electron keeps one debugger attached, so Runtime stays enabled after a client drops,
366 // and the native Runtime.enable announces contexts again only after Runtime.disable.
367 let runtimeEnabled = false;
368 let current: CdpRelayConnection | undefined;
369 const context = {
370 context: { id: 1, origin: "null", name: "", auxData: { isDefault: true, frameId: "F" } },
371 };
372 const send = vi.fn(async (method: string) => {
373 if (method === "Runtime.disable") runtimeEnabled = false;
374 if (method === "Runtime.enable" && !runtimeEnabled) {
375 runtimeEnabled = true;
376 current!.event("Runtime.executionContextCreated", context, "");
377 }
378 return {};
379 });
380 const connect = async () => {
381 const written: Array<Record<string, unknown>> = [];
382 current = makeRelay(makeTarget(send), (raw) => written.push(JSON.parse(raw)));
383 current.receive(JSON.stringify({ id: 1, method: "Target.setAutoAttach", params: {} }));
384 await settle();
385 current.receive(
386 JSON.stringify({ id: 2, method: "Runtime.enable", sessionId: "t3-preview-page" }),
387 );
388 await settle();
389 return written.filter((m) => m["method"] === "Runtime.executionContextCreated");
390 };
391 const expected = [
392 {
393 method: "Runtime.executionContextCreated",
394 params: context,
395 sessionId: "t3-preview-page",
396 },
397 ];
398 expect(await connect()).toEqual(expected);
399 expect(await connect()).toEqual(expected);
400 });
401});