返回 DeepSeek-Reasonix
hostCalls.test.ts
根目录 / desktop / electron / src / main / browser / hostCalls.test.ts
1 import assert from "node:assert/strict";
2 import { test } from "node:test";
3 import { RpcError } from "../rpc.js";
4 import { ActionExecutor } from "./actions.js";
5 import { DocumentRegistry } from "./documents.js";
6 import { DownloadTracker } from "./downloads.js";
7 import { BROWSER_ERR_NO_GRANT, BROWSER_ERR_STALE_REFERENCE, BROWSER_ERR_TAKEN_OVER } from "./errors.js";
8 import { FakeViewFactory, silentLog } from "./fakeGuestViews.js";
9 import { GrantRegistry } from "./grants.js";
10 import { buildBrowserHostCalls, type HostBrowserTab, type BrowserHostDeps } from "./hostCalls.js";
11 import { BrowserSurfaceManager } from "./surfaceManager.js";
12
13 const code = (value: number) => (error: unknown) => error instanceof RpcError && error.code === value;
14
15 async function setup(overrides: Partial<BrowserHostDeps> = {}) {
16 const factory = new FakeViewFactory();
17 const surfaces = new BrowserSurfaceManager({ views: factory, contentSize: () => null, onTakeover() {}, onCrash() {}, log: silentLog, openWaitMs: 5 });
18 const grants = new GrantRegistry({ generation: () => "gen-1" });
19 let tokens = 0;
20 const documents = new DocumentRegistry(() => `tok-${++tokens}`);
21 const actions = new ActionExecutor({ surfaces, documents });
22 const directories = new Map<string, string>();
23 const downloads = new DownloadTracker({
24 tabForWebContents: () => undefined,
25 defaultDirectory: (taskId) => `/dl/${taskId}`,
26 onUpdate() {},
27 log: silentLog,
28 });
29 const table = buildBrowserHostCalls({
30 surfaces,
31 grants,
32 documents,
33 actions,
34 downloads,
35 snapshot: async (tab, selector) => ({ tabId: tab.id, selector }) as never,
36 screenshot: async (tab, request) => ({ tabId: tab.id, ...request }) as never,
37 ...overrides,
38 });
39 const call = async (method: keyof typeof table, params: Record<string, unknown> = {}): Promise<unknown> => table[method](params);
40 const setDirs = downloads.setTaskDirectory.bind(downloads);
41 downloads.setTaskDirectory = (taskId, directory) => {
42 directories.set(taskId, directory);
43 setDirs(taskId, directory);
44 };
45 return { surfaces, grants, documents, downloads, directories, table, call };
46 }
47
48 test("grant, list and open are scoped to the grant's task", async () => {
49 const s = await setup();
50 await assert.rejects(s.call("host/browser.tabs.list", { grantId: "g" }), code(BROWSER_ERR_NO_GRANT));
51 await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "s1" });
52 await s.call("host/browser.grant", { grantId: "h", tabId: "task-2", sessionId: "s2" });
53
54 const opened = (await s.call("host/browser.tabs.open", { grantId: "g", url: "https://a.test" })) as HostBrowserTab;
55 assert.equal(opened.url, "https://a.test/");
56 assert.equal(s.surfaces.require(opened.id).taskId, "task-1", "the tab belongs to the grant's task");
57 await s.surfaces.open("https://b.test", { taskId: "task-2", temporary: false });
58
59 const listed = (await s.call("host/browser.tabs.list", { grantId: "g" })) as { tabs: HostBrowserTab[] };
60 assert.deepEqual(listed.tabs.map((tab) => tab.id), [opened.id], "another task's tabs are invisible");
61 });
62
63 test("revoking a grant reclaims a pending open without closing a user-taken-over page", async () => {
64 for (const takeover of [false, true]) {
65 const s = await setup();
66 await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "session" });
67 const originalOpen = s.surfaces.open.bind(s.surfaces);
68 s.surfaces.open = async (...args) => {
69 const promise = originalOpen(...args);
70 const tab = s.surfaces.all()[0];
71 if (takeover) s.surfaces.takeover(tab.id, "user click");
72 await s.call("host/browser.revoke", { grantId: "g" });
73 return promise;
74 };
75 await assert.rejects(s.call("host/browser.tabs.open", { grantId: "g", url: "https://a.test", requestId: "pending" }),
76 /outcome is unknown/);
77 assert.equal(s.surfaces.all().length, takeover ? 1 : 0);
78 s.surfaces.destroyAll();
79 }
80 });
81
82 test("an open that finishes just before grant revocation cannot leave an unclaimed agent page", async () => {
83 const s = await setup();
84 await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "session" });
85 const originalOpen = s.surfaces.open.bind(s.surfaces);
86 s.surfaces.open = async (...args) => {
87 const tab = await originalOpen(...args);
88 await s.call("host/browser.revoke", { grantId: "g" });
89 return tab;
90 };
91 await assert.rejects(s.call("host/browser.tabs.open", { grantId: "g", url: "https://a.test" }),
92 /outcome is unknown/);
93 assert.equal(s.surfaces.all().length, 0);
94 });
95
96 test("cancel and takeover release a capture lease before a late capture reply", async () => {
97 for (const reason of ["cancel", "takeover", "revoke", "resize", "close"]) {
98 let respond!: (value: never) => void;
99 let entered!: () => void;
100 const started = new Promise<void>(resolve => { entered = resolve; });
101 const s = await setup({ screenshot: async () => { entered(); return new Promise(resolve => { respond = resolve; }); } });
102 await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "s" });
103 const tab = await s.surfaces.open("https://test.example", { taskId: "task", sessionId: "s", temporary: false });
104 let released = 0;
105 tab.view.prepareCapture = async () => () => { released++; };
106 const request = s.call("host/browser.screenshot", { grantId: "g", tabId: tab.id, requestId: "r1" });
107 await started;
108 if (reason === "cancel") await s.call("host/browser.cancel", { grantId: "g", requestId: "r1" });
109 if (reason === "takeover") s.surfaces.takeover(tab.id, "user");
110 if (reason === "revoke") s.grants.revoke("g");
111 if (reason === "resize") s.surfaces.setLayout({ x: 0, y: 0, width: 700, height: 500 });
112 if (reason === "close") s.surfaces.close(tab.id);
113 await assert.rejects(request);
114 assert.equal(released, 1, reason);
115 respond({ path: "late.png" } as never);
116 await Promise.resolve();
117 assert.equal(released, 1, "late completion cannot release another request's lease");
118 }
119 });
120
121 test("operation trace records failure phase and timing without page contents or credentials", async () => {
122 const trace: unknown[] = [];
123 const s = await setup({ trace: event => trace.push(event), snapshot: async () => { throw new RpcError(-32013, "password=DO_NOT_LOG https://example.test/?token=DO_NOT_LOG", { kind: "script_runtime_error" }); } });
124 await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "s" });
125 const tab = await s.surfaces.open("https://example.test/?token=DO_NOT_LOG", { taskId: "task", sessionId: "s", temporary: false });
126 await assert.rejects(s.call("host/browser.snapshot", { grantId: "g", tabId: tab.id, requestId: "trace" }));
127 const final = trace.at(-1) as { phase: string; elapsedMs: number; errorKind: string };
128 assert.equal(final.phase, "failed"); assert.equal(final.errorKind, "script_runtime_error"); assert.ok(final.elapsedMs >= 0);
129 assert.equal(JSON.stringify(trace).includes("DO_NOT_LOG"), false);
130 assert.equal(JSON.stringify(trace).includes("example.test"), false);
131 });
132
133 test("panel resize preserves fixed CSS observations but invalidates natural viewport observations", async () => {
134 for (const fixed of [true, false]) {
135 let entered!: () => void, finish!: (value: never) => void;
136 const started = new Promise<void>(resolve => { entered = resolve; });
137 const s = await setup({ snapshot: async () => { entered(); return new Promise(resolve => { finish = resolve; }); } });
138 await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "s" });
139 const tab = await s.surfaces.open("https://example.test", { taskId: "task", sessionId: "s", temporary: true });
140 if (!fixed) tab.viewport = null;
141 const before = tab.viewportRevision;
142 const request = s.call("host/browser.snapshot", { grantId: "g", tabId: tab.id });
143 const outcome = request.then(value => ({ value }), error => ({ error }));
144 await started;
145 s.surfaces.setLayout({ x: 0, y: 0, width: 700, height: 500 });
146 finish({ tree: "fixture" } as never);
147 const result = await outcome;
148 if (fixed) { assert.deepEqual(result, { value: { tree: "fixture" } }); assert.equal(tab.viewportRevision, before); }
149 else { assert.ok("error" in result); assert.ok(tab.viewportRevision > before); }
150 s.surfaces.destroyAll();
151 }
152 });
153
154 test("cancel releases a navigation waiter without replaying the dispatched navigation", async () => {
155 const s = await setup();
156 await s.call("host/browser.grant", { grantId: "g", tabId: "task", sessionId: "s" });
157 const tab = await s.surfaces.open("https://test.example", { taskId: "task", sessionId: "s", temporary: false });
158 let finish!: () => void;
159 const page = tab.view.page as import("./fakeGuestViews.js").FakePage;
160 page.loadResult = new Promise(resolve => { finish = resolve; });
161 const request = s.call("host/browser.tabs.navigate", { grantId: "g", tabId: tab.id, requestId: "nav", url: "https://next.example" });
162 await s.call("host/browser.cancel", { grantId: "g", requestId: "nav" });
163 await assert.rejects(request, /outcome is unknown/);
164 assert.equal(page.getURL(), "https://next.example/");
165 finish();
166 await Promise.resolve();
167 assert.equal(s.surfaces.tabsForSession("task", "s").length, 1);
168 });
169
170 test("a new session grant cannot list or control the task's earlier session tabs", async () => {
171 const s = await setup();
172 await s.call("host/browser.grant", { grantId: "old", tabId: "task-1", sessionId: "session-1" });
173 const old = (await s.call("host/browser.tabs.open", { grantId: "old", url: "https://old.test" })) as HostBrowserTab;
174 await s.call("host/browser.grant", { grantId: "next", tabId: "task-1", sessionId: "session-2" });
175 const listed = (await s.call("host/browser.tabs.list", { grantId: "next" })) as { tabs: HostBrowserTab[] };
176 assert.deepEqual(listed.tabs, []);
177 await assert.rejects(s.call("host/browser.snapshot", { grantId: "next", tabId: old.id }), code(BROWSER_ERR_NO_GRANT));
178 });
179
180 test("tab calls verify the grant and refuse tabs of another task", async () => {
181 const s = await setup();
182 await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" });
183 const other = await s.surfaces.open("https://b.test", { taskId: "task-2", temporary: false });
184 await assert.rejects(s.call("host/browser.tabs.navigate", { grantId: "g", tabId: other.id, url: "https://c.test" }), code(BROWSER_ERR_NO_GRANT));
185 await assert.rejects(s.call("host/browser.tabs.close", { grantId: "g", tabId: other.id }), code(BROWSER_ERR_NO_GRANT));
186 await assert.rejects(s.call("host/browser.tabs.navigate", { grantId: "g", tabId: "tab-99", url: "https://c.test" }), code(BROWSER_ERR_NO_GRANT));
187 });
188
189 test("reads and writes refuse a tab the user has taken over", async () => {
190 const s = await setup();
191 await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" });
192 const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false });
193 assert.deepEqual(await s.call("host/browser.snapshot", { grantId: "g", tabId: tab.id, selector: "" }), { tabId: tab.id, selector: "" });
194 s.surfaces.takeover(tab.id, "user mousedown");
195 await assert.rejects(s.call("host/browser.snapshot", { grantId: "g", tabId: tab.id }), code(BROWSER_ERR_TAKEN_OVER));
196 await assert.rejects(s.call("host/browser.tabs.navigate", { grantId: "g", tabId: tab.id, url: "https://c.test" }), code(BROWSER_ERR_TAKEN_OVER));
197 const refreshed = (await s.call("host/browser.tabs.navigate", {
198 grantId: "g", tabId: tab.id, url: "https://refreshed.test", allowHuman: true,
199 })) as HostBrowserTab;
200 assert.equal(refreshed.url, "https://refreshed.test/");
201 assert.equal(s.surfaces.require(tab.id).mode, "human", "an explicit user refresh preserves takeover");
202 await assert.rejects(s.call("host/browser.screenshot", { grantId: "g", tabId: tab.id, ref: "", directory: "" }), code(BROWSER_ERR_TAKEN_OVER));
203 const closed = await s.call("host/browser.tabs.close", { grantId: "g", tabId: tab.id });
204 assert.deepEqual(closed, {}, "closing is still allowed so the task can clean up");
205 assert.equal(s.surfaces.get(tab.id), undefined);
206 });
207
208 test("act re-verifies the grant and rejects a stale document token", async () => {
209 const s = await setup();
210 await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" });
211 const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false });
212 await assert.rejects(
213 s.call("host/browser.act", { grantId: "g", tabId: tab.id, documentToken: "nope", action: "click", ref: "e1" }),
214 code(BROWSER_ERR_STALE_REFERENCE),
215 );
216 await s.call("host/browser.revoke", { grantId: "g" });
217 await assert.rejects(
218 s.call("host/browser.act", { grantId: "g", tabId: tab.id, documentToken: "nope", action: "click", ref: "e1" }),
219 code(BROWSER_ERR_NO_GRANT),
220 );
221 });
222
223 test("act and screenshot register the scratch directory for downloads", async () => {
224 const s = await setup();
225 await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" });
226 const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false });
227 await s.call("host/browser.screenshot", { grantId: "g", tabId: tab.id, ref: "", directory: "/scratch/one" });
228 assert.equal(s.directories.get("task-1"), "/scratch/one");
229 await s.call("host/browser.screenshot", { grantId: "g", tabId: tab.id, ref: "", directory: "" });
230 assert.equal(s.directories.get("task-1"), "/scratch/one", "an empty directory keeps the previous mapping");
231 });
232
233 test("downloads waits are served per tab and a zero wait answers at once", async () => {
234 const s = await setup();
235 await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" });
236 const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false });
237 assert.deepEqual(await s.call("host/browser.downloads", { grantId: "g", tabId: tab.id, waitForMs: 0 }), { downloads: [] });
238 });
239
240 test("revoke invalidates the task's document tokens", async () => {
241 const s = await setup();
242 await s.call("host/browser.grant", { grantId: "g", tabId: "task-1", sessionId: "" });
243 const tab = await s.surfaces.open("https://a.test", { taskId: "task-1", temporary: false });
244 const token = s.documents.issue({ tabId: tab.id, epoch: tab.epoch, snapshotId: "snap", frames: [] });
245 assert.ok(s.documents.lookup(token));
246 await s.call("host/browser.revoke", { grantId: "g" });
247 assert.equal(s.documents.lookup(token), undefined);
248 });
249
249 lines TYPESCRIPT