Open History to see the last commit
1import * as NodeVM from "node:vm";
2
3import { describe, expect, it, vi } from "vite-plus/test";
4
5import { sendPageCommand, toKeyboardInputEvents } from "./GuestInput.ts";
6
7const makeGuest = ({
8 focused,
9 focusedDocument = true,
10}: {
11 readonly focused: boolean;
12 readonly focusedDocument?: boolean;
13}) => {
14 const sendCommand = vi.fn(async (method: string, params?: { expression?: string }) =>
15 method === "Runtime.evaluate" && params?.expression?.endsWith("!== null")
16 ? { result: { value: focusedDocument } }
17 : { result: {} },
18 );
19 const webContents = {
20 focusedFrame: focused ? {} : null,
21 insertText: vi.fn(async () => {}),
22 sendInputEvent: vi.fn(),
23 reload: vi.fn(),
24 reloadIgnoringCache: vi.fn(),
25 };
26 return {
27 webContents,
28 sendCommand,
29 send: (method: string, params: Record<string, unknown>) =>
30 sendPageCommand(
31 webContents as unknown as Electron.WebContents,
32 { sendCommand } as unknown as Electron.Debugger,
33 method,
34 params,
35 ),
36 };
37};
38
39describe("preview guest input", () => {
40 it("types into an unfocused guest's own page, not the app's focused field", async () => {
41 const guest = makeGuest({ focused: false });
42 await guest.send("Input.insertText", { text: "pairing-code" });
43 expect(guest.webContents.insertText).toHaveBeenCalledWith("pairing-code");
44 expect(guest.sendCommand).not.toHaveBeenCalledWith("Input.insertText", expect.anything());
45 });
46
47 it("stops the guest navigation without stopping its embedder", async () => {
48 let guestLoading = true;
49 let hostLoading = true;
50 const guest = {
51 stop: () => {
52 guestLoading = false;
53 },
54 };
55 const debuggee = {
56 sendCommand: async () => {
57 hostLoading = false;
58 return {};
59 },
60 };
61 await sendPageCommand(
62 guest as unknown as Electron.WebContents,
63 debuggee as unknown as Electron.Debugger,
64 "Page.stopLoading",
65 {},
66 );
67 expect(guestLoading).toBe(false);
68 expect(hostLoading).toBe(true);
69 });
70
71 it.each([
72 {
73 sessionId: undefined,
74 method: "Input.dispatchMouseEvent",
75 type: "mousePressed",
76 outcome: "restore",
77 },
78 {
79 sessionId: "child-frame",
80 method: "Input.dispatchMouseEvent",
81 type: "mousePressed",
82 outcome: "restore",
83 },
84 {
85 sessionId: undefined,
86 method: "Input.dispatchTouchEvent",
87 type: "touchStart",
88 outcome: "restore",
89 },
90 {
91 sessionId: undefined,
92 method: "Input.dispatchMouseEvent",
93 type: "mousePressed",
94 outcome: "other-field",
95 },
96 {
97 sessionId: undefined,
98 method: "Input.dispatchMouseEvent",
99 type: "mousePressed",
100 outcome: "removed",
101 },
102 {
103 sessionId: undefined,
104 method: "Input.dispatchMouseEvent",
105 type: "mousePressed",
106 outcome: "failure",
107 },
108 ])(
109 "preserves host focus for $method in $sessionId with $outcome",
110 async ({ sessionId, method, type, outcome }) => {
111 const document = { activeElement: {} as object };
112 const composer = {
113 isConnected: true,
114 focus: () => {
115 document.activeElement = composer;
116 },
117 };
118 const preview = { tagName: "WEBVIEW", getWebContentsId: () => 7 };
119 const otherField = { tagName: "INPUT" };
120 document.activeElement = composer;
121 const context = NodeVM.createContext({ document });
122 const host = {
123 isDestroyed: () => false,
124 executeJavaScript: async (script: string) => NodeVM.runInContext(script, context),
125 };
126 const guest = { id: 7, focusedFrame: null, hostWebContents: host };
127 const debuggee = {
128 sendCommand: async () => {
129 document.activeElement = outcome === "other-field" ? otherField : preview;
130 if (outcome === "removed") composer.isConnected = false;
131 if (outcome === "failure") throw new Error("Pointer failed");
132 return {};
133 },
134 };
135 const result = sendPageCommand(
136 guest as unknown as Electron.WebContents,
137 debuggee as unknown as Electron.Debugger,
138 method,
139 { type, x: 10, y: 10, button: "left" },
140 sessionId,
141 );
142 if (outcome === "failure") await expect(result).rejects.toThrow("Pointer failed");
143 else await result;
144 expect(document.activeElement).toBe(
145 outcome === "other-field" ? otherField : outcome === "removed" ? preview : composer,
146 );
147 expect(Object.keys(context)).toEqual(["document"]);
148 },
149 );
150
151 it.each([{ userClicksStuckTab: false }, { userClicksStuckTab: true }])(
152 "keeps the right focus when a sibling press finishes beside a stuck one, user clicks: $userClicksStuckTab",
153 async ({ userClicksStuckTab }) => {
154 const document = { activeElement: {} as object };
155 const composer = {
156 isConnected: true,
157 focus: () => {
158 document.activeElement = composer;
159 },
160 };
161 document.activeElement = composer;
162 const context = NodeVM.createContext({ document });
163 const host = {
164 isDestroyed: () => false,
165 executeJavaScript: async (script: string) => NodeVM.runInContext(script, context),
166 };
167 const previews = new Map(
168 [7, 8].map((id) => [id, { tagName: "WEBVIEW", getWebContentsId: () => id }]),
169 );
170 const stuckPressFocused = Promise.withResolvers<void>();
171 const stuckPressEnds = Promise.withResolvers<void>();
172 const press = (id: number) => {
173 const debuggee = {
174 sendCommand: async () => {
175 document.activeElement = previews.get(id)!;
176 if (id === 7) {
177 stuckPressFocused.resolve();
178 await stuckPressEnds.promise;
179 } else if (userClicksStuckTab) {
180 document.activeElement = previews.get(7)!;
181 }
182 return {};
183 },
184 };
185 return sendPageCommand(
186 { id, focusedFrame: null, hostWebContents: host } as unknown as Electron.WebContents,
187 debuggee as unknown as Electron.Debugger,
188 "Input.dispatchMouseEvent",
189 { type: "mousePressed", x: 10, y: 10, button: "left" },
190 );
191 };
192 // A press held open, as by a page's confirm() on mousedown, must not block or win focus.
193 const stuck = press(7);
194 await stuckPressFocused.promise;
195 await press(8);
196 const expected = userClicksStuckTab ? previews.get(7) : composer;
197 expect(document.activeElement).toBe(expected);
198 stuckPressEnds.resolve();
199 await stuck;
200 expect(Object.keys(context)).toEqual(["document"]);
201 },
202 );
203
204 it.each([
205 {
206 method: "Input.dispatchMouseEvent",
207 press: "mousePressed",
208 release: "mouseReleased",
209 failPress: false,
210 },
211 {
212 method: "Input.dispatchTouchEvent",
213 press: "touchStart",
214 release: "touchEnd",
215 failPress: false,
216 },
217 {
218 method: "Input.dispatchMouseEvent",
219 press: "mousePressed",
220 release: "mouseReleased",
221 failPress: true,
222 },
223 ])(
224 "keeps $method releases behind presses waiting for host focus, failed press: $failPress",
225 async ({ method, press, release, failPress }) => {
226 const captureStarted = Promise.withResolvers<void>();
227 const finishCapture = Promise.withResolvers<void>();
228 const events: Array<unknown> = [];
229 const guest = {
230 id: 7,
231 focusedFrame: null,
232 hostWebContents: {
233 isDestroyed: () => false,
234 executeJavaScript: async () => {
235 captureStarted.resolve();
236 await finishCapture.promise;
237 },
238 },
239 };
240 const debuggee = {
241 sendCommand: async (_method: string, params: Record<string, unknown>) => {
242 events.push(params["type"]);
243 if (failPress && params["type"] === press) throw new Error("Press failed");
244 return {};
245 },
246 };
247 const send = (type: string) =>
248 sendPageCommand(
249 guest as unknown as Electron.WebContents,
250 debuggee as unknown as Electron.Debugger,
251 method,
252 { type },
253 );
254 const pressed = send(press);
255 await captureStarted.promise;
256 const released = send(release);
257 finishCapture.resolve();
258 const results = await Promise.allSettled([pressed, released]);
259 expect(results.map((result) => result.status)).toEqual([
260 failPress ? "rejected" : "fulfilled",
261 "fulfilled",
262 ]);
263 expect(events).toEqual([press, release]);
264 },
265 );
266
267 it("refuses input when a cross-site frame holds the unfocused page's focus", async () => {
268 const guest = makeGuest({ focused: false, focusedDocument: false });
269 await expect(guest.send("Input.insertText", { text: "secret" })).rejects.toThrow(
270 /cross-site frame/,
271 );
272 expect(guest.webContents.insertText).not.toHaveBeenCalled();
273 });
274});
275
276describe("CDP key events as Electron input", () => {
277 it("sends a key's text as char events after its key down", () => {
278 expect(toKeyboardInputEvents({ type: "keyDown", key: "Enter", text: "\r" })).toEqual([
279 { type: "rawKeyDown", keyCode: "Enter", modifiers: [] },
280 { type: "char", keyCode: "\r", modifiers: [] },
281 ]);
282 expect(toKeyboardInputEvents({ type: "keyDown", key: "é", text: "é", modifiers: 8 })).toEqual([
283 { type: "rawKeyDown", keyCode: "é", modifiers: ["shift"] },
284 { type: "char", keyCode: "é", modifiers: ["shift"] },
285 ]);
286 });
287
288 // regression: #18556 agent typing in a preview tab landed in the app instead of the guest
289 it("names arrows and function keys as Electron accelerators", () => {
290 expect(toKeyboardInputEvents({ type: "keyUp", key: "ArrowLeft", modifiers: 3 })).toEqual([
291 { type: "keyUp", keyCode: "Left", modifiers: ["alt", "control"] },
292 ]);
293 expect(toKeyboardInputEvents({ type: "rawKeyDown", key: "F12" })).toEqual([
294 { type: "rawKeyDown", keyCode: "F12", modifiers: [] },
295 ]);
296 expect(toKeyboardInputEvents({ type: "rawKeyDown", key: "AudioVolumeUp" })).toEqual([]);
297 });
298});