返回 DeepSeek-Reasonix
actions.test.ts
根目录 / desktop / electron / src / main / browser / actions.test.ts
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3 import { RpcError } from "../rpc.js";
4 import { ActionExecutor, type ActRequest } from "./actions.js";
5 import { DocumentRegistry } from "./documents.js";
6 import { BROWSER_ERR_NO_GRANT, BROWSER_ERR_STALE_REFERENCE, BROWSER_ERR_TAKEN_OVER, noGrant } from "./errors.js";
7 import { FakeGuestView, FakeViewFactory, silentLog } from "./fakeGuestViews.js";
8 import type { ResolveOutput } from "./pageScripts.js";
9 import { BrowserSurfaceManager } from "./surfaceManager.js";
10
11 const code = (value: number) => (error: unknown) => error instanceof RpcError && error.code === value;
12
13 async function setup() {
14 const factory = new FakeViewFactory();
15 const manager = new BrowserSurfaceManager({ views: factory, contentSize: () => null, onTakeover() {}, onCrash() {}, log: silentLog, openWaitMs: 5 });
16 let tokens = 0;
17 const documents = new DocumentRegistry(() => `tok-${++tokens}`);
18 const tab = await manager.open("https://a.test", { taskId: "t", temporary: false });
19 const view = tab.view as FakeGuestView;
20 const page = view.page;
21 const answers = {
22 resolve: { ok: true, x: 10, y: 20, width: 100, height: 40, tag: "button", type: "", disabled: false, editable: false, frameOffsetKnown: true } as ResolveOutput,
23 identity: true,
24 select: { ok: true, selected: ["x"] } as unknown,
25 locate: { ok: true, tag: "input", type: "file", path: "html > body:nth-child(2) > input:nth-child(1)" } as unknown,
26 beforeResolve: () => {},
27 };
28 const scripts: string[] = [];
29 page.run = (source) => {
30 scripts.push(source);
31 if (source.startsWith("(function pageResolve")) {
32 answers.beforeResolve();
33 return answers.resolve;
34 }
35 if (source.startsWith("(function pageIdentity")) return answers.identity;
36 if (source.startsWith("(function pageSelect")) return answers.select;
37 if (source.startsWith("(function pageLocate")) return answers.locate;
38 if (source.startsWith("({ width")) return { width: 800, height: 600 };
39 return undefined;
40 };
41 const token = documents.issue({ tabId: tab.id, epoch: tab.epoch, snapshotId: "snap", frames: [{ prefix: "", frameTreeNodeId: page.mainFrame.frameTreeNodeId, docId: "doc-1" }] });
42 const settled: Array<() => void> = [];
43 const actions = new ActionExecutor({
44 surfaces: manager,
45 documents,
46 fileExists: (path) => path.endsWith(".txt"),
47 sleep: async () => {
48 for (const hook of settled.splice(0)) hook();
49 },
50 });
51 const request = (extra: Partial<ActRequest>): ActRequest => ({
52 operationId: "op", tabId: tab.id, documentToken: token, action: "click", ref: "e1", text: "", keys: "", options: [], files: [], submit: false, deltaX: 0, deltaY: 0, ...extra,
53 });
54 let grantOk = true;
55 const verify = () => {
56 if (!grantOk) throw noGrant("revoked");
57 };
58 return { manager, documents, tab, view, page, answers, scripts, token, actions, request, verify, settled, revoke: () => (grantOk = false) };
59 }
60
61 test("acts are refused with the contract codes before any input is dispatched", async () => {
62 const s = await setup();
63 await assert.rejects(s.actions.act(s.tab, s.request({ documentToken: "nope" }), s.verify), code(BROWSER_ERR_STALE_REFERENCE));
64 const other = await s.manager.open("https://b.test", { taskId: "t", temporary: false });
65 await assert.rejects(s.actions.act(other, s.request({}), s.verify), code(BROWSER_ERR_STALE_REFERENCE), "a token of another tab is stale");
66
67 s.manager.takeover(s.tab.id, "user click");
68 await assert.rejects(s.actions.act(s.tab, s.request({}), s.verify), code(BROWSER_ERR_TAKEN_OVER));
69 s.manager.resume(s.tab.id);
70 await assert.rejects(s.actions.act(s.tab, s.request({}), s.verify), code(BROWSER_ERR_STALE_REFERENCE), "resume bumps the epoch, so the old snapshot is stale");
71
72 const fresh = s.documents.issue({ tabId: s.tab.id, epoch: s.tab.epoch, snapshotId: "snap2", frames: [{ prefix: "", frameTreeNodeId: s.page.mainFrame.frameTreeNodeId, docId: "doc-1" }] });
73 s.view.fire().onNavigate("https://a.test/other", false);
74 await assert.rejects(s.actions.act(s.tab, s.request({ documentToken: fresh }), s.verify), code(BROWSER_ERR_STALE_REFERENCE), "navigation bumps the epoch");
75
76 const again = s.documents.issue({ tabId: s.tab.id, epoch: s.tab.epoch, snapshotId: "snap3", frames: [{ prefix: "", frameTreeNodeId: s.page.mainFrame.frameTreeNodeId, docId: "doc-1" }] });
77 s.revoke();
78 await assert.rejects(s.actions.act(s.tab, s.request({ documentToken: again }), s.verify), code(BROWSER_ERR_NO_GRANT));
79 assert.equal(s.page.inputs.length, 0, "nothing reached the page");
80 });
81
82 test("a take-over that lands while the ref resolves cancels the act", async () => {
83 const s = await setup();
84 s.answers.beforeResolve = () => s.manager.takeover(s.tab.id, "user typing");
85 await assert.rejects(s.actions.act(s.tab, s.request({}), s.verify), code(BROWSER_ERR_TAKEN_OVER));
86 assert.equal(s.page.inputs.length, 0);
87 });
88
89 // Promoted from prototypes/electron-browser/scripts/verify-runtime.cjs: a
90 // renderer crash between approval and dispatch must cancel the pending act —
91 // the recovered page comes back in human mode and nothing is ever replayed.
92 test("a renderer crash while the ref resolves cancels the act without dispatching", async () => {
93 const s = await setup();
94 s.answers.beforeResolve = () => s.view.fire().onRenderProcessGone("crashed");
95 await assert.rejects(s.actions.act(s.tab, s.request({}), s.verify), code(BROWSER_ERR_TAKEN_OVER));
96 assert.equal(s.page.inputs.length, 0, "no input reached the crashed page");
97 assert.equal(s.tab.mode, "human", "the recovered tab waits for a fresh grant");
98 });
99
100 // Promoted from the same prototype suite: input already dispatched when the
101 // renderer dies is treated as applied (executed, no token rotation), so the
102 // Go-side ledger can never settle it as not-executed and replay it.
103 test("a renderer crash after dispatch completes the act without replay", async () => {
104 const s = await setup();
105 s.settled.push(() => s.view.fire().onRenderProcessGone("crashed"));
106 assert.deepEqual(await s.actions.act(s.tab, s.request({}), s.verify), { executed: true });
107 assert.equal(s.page.inputs.length, 3, "the click was physically dispatched before the crash");
108 assert.equal(s.tab.mode, "human");
109 assert.deepEqual(s.page.calls.at(-1), "load:https://a.test/", "the crashed view reloads its last URL");
110 });
111
112 test("click dispatches trusted mouse events at the zoomed centre and rotates the token", async () => {
113 const s = await setup();
114 s.page.zoom = 2;
115 const result = await s.actions.act(s.tab, s.request({}), s.verify);
116 assert.deepEqual(result, { executed: true, documentToken: "tok-2" });
117 assert.deepEqual(s.page.inputs, [
118 { type: "mouseMove", x: 120, y: 80 },
119 { type: "mouseDown", x: 120, y: 80, button: "left", clickCount: 1 },
120 { type: "mouseUp", x: 120, y: 80, button: "left", clickCount: 1 },
121 ]);
122 assert.ok(s.tab.agentInputUntil > 0, "the agent's own input is marked so its echo is not a take-over");
123 await assert.rejects(s.actions.act(s.tab, s.request({}), s.verify), code(BROWSER_ERR_STALE_REFERENCE), "the old token retired");
124 const chained = await s.actions.act(s.tab, s.request({ documentToken: "tok-2" }), s.verify);
125 assert.deepEqual(chained, { executed: true, documentToken: "tok-3" });
126 });
127
128 test("a navigation or document replacement during the act completes it without a token", async () => {
129 const s = await setup();
130 s.settled.push(() => s.view.fire().onNavigate("https://a.test/after-click", false));
131 assert.deepEqual(await s.actions.act(s.tab, s.request({}), s.verify), { executed: true });
132 const token = s.documents.issue({ tabId: s.tab.id, epoch: s.tab.epoch, snapshotId: "snap2", frames: [{ prefix: "", frameTreeNodeId: s.page.mainFrame.frameTreeNodeId, docId: "doc-2" }] });
133 s.answers.identity = false;
134 assert.deepEqual(await s.actions.act(s.tab, s.request({ documentToken: token }), s.verify), { executed: true });
135 });
136
137 test("non-interactable targets report executed:false with the same token", async () => {
138 const s = await setup();
139 s.answers.resolve = { ok: false, reason: "element is covered by another element" };
140 assert.deepEqual(await s.actions.act(s.tab, s.request({}), s.verify), { executed: false, reason: "element is covered by another element", documentToken: s.token });
141 s.answers.resolve = { ok: true, x: 0, y: 0, width: 5, height: 5, tag: "button", type: "", disabled: true, editable: false, frameOffsetKnown: true };
142 assert.equal((await s.actions.act(s.tab, s.request({}), s.verify)).reason, "element is disabled");
143 s.answers.resolve = { ok: true, x: 0, y: 0, width: 5, height: 5, tag: "div", type: "", disabled: false, editable: false, frameOffsetKnown: false };
144 assert.match((await s.actions.act(s.tab, s.request({}), s.verify)).reason ?? "", /cross-origin frame/);
145 assert.equal((await s.actions.act(s.tab, s.request({ action: "explode" }), s.verify)).executed, false);
146 assert.equal((await s.actions.act(s.tab, s.request({ ref: "" }), s.verify)).reason, "this action needs a ref");
147 assert.equal(s.page.inputs.length, 0);
148 });
149
150 test("type clicks to focus, inserts text and submits with Enter", async () => {
151 const s = await setup();
152 s.answers.resolve = { ok: true, x: 0, y: 0, width: 10, height: 10, tag: "input", type: "text", disabled: false, editable: true, frameOffsetKnown: true };
153 const result = await s.actions.act(s.tab, s.request({ action: "type", text: "hello", submit: true }), s.verify);
154 assert.deepEqual(result, { executed: true, documentToken: "tok-2" });
155 assert.deepEqual(s.page.inserted, ["hello"]);
156 assert.deepEqual(s.page.inputs.map((event) => event.type), ["mouseMove", "mouseDown", "mouseUp", "keyDown", "char", "keyUp"]);
157 s.answers.resolve = { ok: true, x: 0, y: 0, width: 10, height: 10, tag: "button", type: "", disabled: false, editable: false, frameOffsetKnown: true };
158 assert.equal((await s.actions.act(s.tab, s.request({ action: "type", text: "x", documentToken: "tok-2" }), s.verify)).reason, "element is not editable");
159 });
160
161 test("press sends chords, scroll sends inverted wheel deltas, select runs in the page", async () => {
162 const s = await setup();
163 assert.deepEqual(await s.actions.act(s.tab, s.request({ action: "press", keys: "Control+a Escape", ref: "" }), s.verify), { executed: true, documentToken: "tok-2" });
164 assert.deepEqual(s.page.inputs.map((event) => `${event.type}:${(event as { keyCode?: string }).keyCode ?? ""}`), ["keyDown:a", "keyUp:a", "keyDown:Escape", "keyUp:Escape"]);
165 assert.match((await s.actions.act(s.tab, s.request({ action: "press", keys: "Control+", ref: "", documentToken: "tok-2" }), s.verify)).reason ?? "", /malformed/);
166
167 s.page.inputs.length = 0;
168 assert.deepEqual(await s.actions.act(s.tab, s.request({ action: "scroll", ref: "", deltaY: 300, documentToken: "tok-2" }), s.verify), { executed: true, documentToken: "tok-3" });
169 assert.deepEqual(s.page.inputs, [{ type: "mouseWheel", x: 400, y: 300, deltaX: -0, deltaY: -300, canScroll: true }]);
170 assert.equal((await s.actions.act(s.tab, s.request({ action: "scroll", ref: "", documentToken: "tok-3" }), s.verify)).reason, "scroll deltas are both zero");
171
172 assert.deepEqual(await s.actions.act(s.tab, s.request({ action: "select", options: ["x"], documentToken: "tok-3" }), s.verify), { executed: true, documentToken: "tok-4" });
173 assert.ok(s.scripts.some((source) => source.startsWith("(function pageSelect") && source.includes('"options":["x"]')));
174 s.answers.select = { ok: false, reason: "stale" };
175 await assert.rejects(s.actions.act(s.tab, s.request({ action: "select", documentToken: "tok-4" }), s.verify), code(BROWSER_ERR_STALE_REFERENCE));
176 });
177
178 test("press focuses a non-editable ref before sending keys", async () => {
179 const s = await setup();
180 s.answers.resolve = { ok: true, x: 0, y: 0, width: 10, height: 10, tag: "button", type: "", disabled: false, editable: false, frameOffsetKnown: true };
181 s.page.run = (source) => source.startsWith("(function pageFocus") ? true : source.startsWith("(function pageIdentity") ? true : s.answers.resolve;
182 const result = await s.actions.act(s.tab, s.request({ action: "press", ref: "e1", keys: "Enter" }), s.verify);
183 assert.equal(result.executed, true);
184 assert.equal(s.page.inputs.filter((event) => event.type === "keyDown").length, 1);
185 });
186
187 test("upload validates files and sets them through the DevTools protocol", async () => {
188 const s = await setup();
189 assert.equal((await s.actions.act(s.tab, s.request({ action: "upload" }), s.verify)).reason, "upload needs files");
190 assert.match((await s.actions.act(s.tab, s.request({ action: "upload", files: ["relative.txt"] }), s.verify)).reason ?? "", /absolute/);
191 assert.match((await s.actions.act(s.tab, s.request({ action: "upload", files: ["/tmp/missing.bin"] }), s.verify)).reason ?? "", /not found/);
192 s.page.debugger.respond = (method) => {
193 if (method === "Runtime.enable") s.page.debugger.emit("Runtime.executionContextCreated", { context: { id: 9, auxData: { isDefault: false } } });
194 return method === "Runtime.callFunctionOn" ? { result: { objectId: "obj-9" } } : {};
195 };
196 const result = await s.actions.act(s.tab, s.request({ action: "upload", files: ["/tmp/a.txt"] }), s.verify);
197 assert.deepEqual(result, { executed: true, documentToken: "tok-2" });
198 assert.deepEqual(s.page.debugger.commands.map((command) => command.method), ["Runtime.enable", "Runtime.callFunctionOn", "Runtime.disable", "DOM.setFileInputFiles", "Runtime.releaseObjectGroup"]);
199 assert.deepEqual(s.page.debugger.commands[3].params, { objectId: "obj-9", files: ["/tmp/a.txt"] });
200 assert.equal(s.page.debugger.attached, false, "the debugger is detached afterwards");
201 s.answers.locate = { ok: true, tag: "input", type: "text", path: "html" };
202 assert.equal((await s.actions.act(s.tab, s.request({ action: "upload", files: ["/tmp/a.txt"], documentToken: "tok-2" }), s.verify)).reason, "element is not a file input");
203 });
204
205 test("takeover after a focus click preserves an unknown receipt and stops typing", async () => {
206 for (const action of ["type", "press"]) {
207 const s = await setup();
208 s.answers.resolve = { ok: true, x: 0, y: 0, width: 10, height: 10, tag: "input", type: "text", disabled: false, editable: true, frameOffsetKnown: true };
209 s.settled.push(() => s.manager.takeover(s.tab.id, "user"));
210 const result = await s.actions.act(s.tab, s.request({ action, text: "hello", keys: "Enter" }), s.verify);
211 assert.equal(result.outcome, "unknown");
212 assert.equal(result.executed, false);
213 assert.equal(s.page.inputs.length, 3);
214 assert.deepEqual(s.page.inserted, []);
215 }
216 });
217
218 test("takeover during insertText cancels the pending submit without a false no-effect receipt", async () => {
219 const s = await setup();
220 s.answers.resolve = { ok: true, x: 0, y: 0, width: 10, height: 10, tag: "input", type: "text", disabled: false, editable: true, frameOffsetKnown: true };
221 s.page.insertText = async () => { s.manager.takeover(s.tab.id, "user"); };
222 const result = await s.actions.act(s.tab, s.request({ action: "type", text: "hello", submit: true }), s.verify);
223 assert.equal(result.outcome, "unknown");
224 assert.equal(s.page.inputs.filter((event) => event.type === "keyDown").length, 0);
225 });
226
227 test("upload verifies takeover and grant after CDP lookup, before attaching files", async () => {
228 for (const invalidate of ["takeover", "revoke", "navigation"]) {
229 const s = await setup();
230 s.page.debugger.respond = (method) => {
231 if (method === "Runtime.enable") s.page.debugger.emit("Runtime.executionContextCreated", { context: { id: 9, auxData: { isDefault: false } } });
232 if (method === "Runtime.callFunctionOn") {
233 if (invalidate === "takeover") s.manager.takeover(s.tab.id, "user");
234 if (invalidate === "revoke") s.revoke();
235 if (invalidate === "navigation") s.view.fire().onNavigate("https://other.test", false);
236 return { result: { objectId: "original-input" } };
237 }
238 return {};
239 };
240 await assert.rejects(s.actions.act(s.tab, s.request({ action: "upload", files: ["/tmp/a.txt"] }), s.verify));
241 assert.equal(s.page.debugger.commands.some((command) => command.method === "DOM.setFileInputFiles"), false);
242 }
243 });
244
245 test("select receipt survives a takeover after its DOM mutation, and lost script replies stay unknown", async () => {
246 const s = await setup();
247 s.page.run = () => { s.manager.takeover(s.tab.id, "user"); return { ok: true, selected: ["x"] }; };
248 assert.deepEqual(await s.actions.act(s.tab, s.request({ action: "select", options: ["x"] }), s.verify), { executed: true });
249 const other = await setup();
250 other.page.run = () => { throw new Error("renderer gone after change"); };
251 assert.equal((await other.actions.act(other.tab, other.request({ action: "select", options: ["x"] }), other.verify)).outcome, "unknown");
252 });
253
253 lines TYPESCRIPT