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