返回 CodeWhale
browser-cdp.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / browser-cdp.test.mjs
1 // Browser control over CDP, driven by a scripted fake WebSocket: the command
2 // sequence, receipts, refusals and file output are all verified without a real
3 // browser. The live path gets its own smoke on a real Chrome.
4 import { test } from "node:test";
5 import assert from "node:assert/strict";
6 import fs from "node:fs";
7 import os from "node:os";
8 import path from "node:path";
9 import { createBrowser, checkBrowserUrl, findBrowserApp } from "../src/browser-cdp.mjs";
10
11 class FakeWs {
12 constructor(script) { this.listeners = {}; this.sent = []; this.script = script; this.closed = false; }
13 addEventListener(type, fn) { (this.listeners[type] ??= []).push(fn); }
14 emit(type, event = {}) { for (const fn of this.listeners[type] ?? []) fn(event); }
15 close() { if (!this.closed) { this.closed = true; queueMicrotask(() => this.emit("close", {})); } }
16 send(raw) {
17 const msg = JSON.parse(raw);
18 this.sent.push(msg);
19 queueMicrotask(() => {
20 const respond = this.script[msg.method];
21 const result = typeof respond === "function" ? respond(msg.params) : respond;
22 if (result === undefined) return; // fire-and-forget (Input domain)
23 this.emit("message", { data: JSON.stringify({ id: msg.id, ...(msg.sessionId ? { sessionId: msg.sessionId } : {}), result }) });
24 });
25 }
26 event(method, params, sessionId) { this.emit("message", { data: JSON.stringify({ method, params, sessionId }) }); }
27 sentOf(method) { return this.sent.filter((m) => m.method === method); }
28 }
29
30 const pageState = () => ({ url: "about:blank", tabs: ["T1"] });
31 function defaultScript(page) {
32 return {
33 "Target.createTarget": () => { const id = `T${page.tabs.length + 1}`; page.tabs.push(id); return { targetId: id }; },
34 "Target.attachToTarget": { sessionId: "S1" },
35 "Page.enable": {},
36 "Page.navigate": (p) => { page.url = p.url; return { frameId: "F1" }; },
37 "Target.getTargetInfo": () => ({ targetInfo: { url: page.url, title: `t:${page.url}` } }),
38 "Target.getTargets": () => ({ targetInfos: page.tabs.map((id) => ({ type: "page", targetId: id, url: page.url, title: `t:${page.url}` })) }),
39 "Target.closeTarget": (p) => { page.tabs = page.tabs.filter((id) => id !== p.targetId); return {}; },
40 "Browser.close": (p, m) => (m === undefined ? {} : {}),
41 "Page.getLayoutMetrics": { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 }, cssVisualViewport: { scale: 2 } },
42 "Page.captureScreenshot": { data: Buffer.from("abc").toString("base64") },
43 "DOM.enable": {},
44 "DOM.getDocument": { root: { nodeId: 1 } },
45 "DOM.querySelector": { nodeId: 7 },
46 "DOM.scrollIntoViewIfNeeded": {},
47 "DOM.getBoxModel": { model: { content: [10, 20, 30, 20, 30, 40, 10, 40] } },
48 "DOM.focus": {},
49 "Input.insertText": {},
50 "Input.dispatchKeyEvent": {},
51 "Input.dispatchMouseEvent": {},
52 };
53 }
54
55 function harness(t, { script, connectFailures = 0, stateDir } = {}) {
56 const dir = stateDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "cu-browser-"));
57 const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-browser-rec-"));
58 t.after(() => { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(recDir, { recursive: true, force: true }); });
59 const prior = process.env.CODEWHALE_CU_STATE_DIR;
60 process.env.CODEWHALE_CU_STATE_DIR = dir;
61 t.after(() => { if (prior === undefined) delete process.env.CODEWHALE_CU_STATE_DIR; else process.env.CODEWHALE_CU_STATE_DIR = prior; });
62 const page = pageState();
63 const sockets = [];
64 const launches = [];
65 let connects = 0;
66 const browser = createBrowser({
67 findApp: () => "/Applications/Google Chrome.app",
68 launch: async ({ profileDir }) => {
69 launches.push(profileDir);
70 fs.mkdirSync(profileDir, { recursive: true });
71 fs.writeFileSync(path.join(profileDir, "DevToolsActivePort"), "4242\n/devtools/browser/test\n");
72 },
73 connect: async (url) => {
74 connects += 1;
75 if (connects <= connectFailures) throw Object.assign(new Error("connection refused"), { code: "browser_unavailable" });
76 const ws = new FakeWs(script ?? defaultScript(page));
77 sockets.push({ url, ws });
78 return ws;
79 },
80 recordingsDir: () => recDir,
81 });
82 return { browser, sockets, launches, recDir, dir, page };
83 }
84
85 test("browser start launches the self-owned profile and binds one tab per session", async (t) => {
86 const { browser, sockets, launches, page } = harness(t);
87 const start = await browser.start({});
88 assert.equal(start.running, true);
89 assert.equal(start.launched, true);
90 assert.equal(start.reused, false);
91 assert.equal(start.browser, "/Applications/Google Chrome.app");
92 assert.equal(start.tab.id, "T1");
93 assert.match(start.note, /self-owned profile/);
94 assert.equal(launches.length, 1);
95 assert.match(sockets[0].url, /^ws:\/\/127\.0\.0\.1:4242\/devtools\/browser\/test$/);
96 const ws = sockets[0].ws;
97 assert.equal(ws.sentOf("Target.createTarget").length, 0, "adopts the launch's blank tab");
98 assert.equal(ws.sentOf("Target.attachToTarget")[0].params.targetId, "T1");
99 assert.equal(ws.sentOf("Target.attachToTarget")[0].params.flatten, true);
100 page.url = "about:blank";
101
102 const status = await browser.status();
103 assert.equal(status.running, true);
104 assert.equal(status.tabs.length, 1);
105 assert.equal(status.activeTab.id, "T1");
106 });
107
108 test("browser start gives a busy instance a new tab instead of stealing one", async (t) => {
109 const h = harness(t);
110 h.page.tabs = ["T1", "T2"];
111 const start = await h.browser.start({});
112 assert.equal(start.tab.id, "T3");
113 assert.deepEqual(h.page.tabs, ["T1", "T2", "T3"]);
114 assert.equal(h.sockets[0].ws.sentOf("Target.createTarget").length, 1);
115 });
116
117 test("browser navigate waits for load and reports url+title from the target, not from us", async (t) => {
118 const { browser, sockets } = harness(t);
119 await browser.start({});
120 const ws = sockets[0].ws;
121 const nav = browser.navigate({ url: "https://example.com" });
122 await new Promise((r) => setTimeout(r, 5));
123 ws.event("Page.loadEventFired", {}, "S1");
124 const receipt = await nav;
125 assert.equal(receipt.verified, true);
126 assert.equal(receipt.url, "https://example.com/");
127 assert.equal(receipt.title, "t:https://example.com/");
128 assert.equal(ws.sentOf("Page.navigate")[0].params.url, "https://example.com/");
129 assert.equal(ws.sentOf("Page.navigate")[0].sessionId, "S1");
130 });
131
132 test("browser navigate reports a load timeout honestly instead of claiming success", async (t) => {
133 const priorTimeout = process.env.CODEWHALE_CU_BROWSER_LOAD_TIMEOUT_MS;
134 process.env.CODEWHALE_CU_BROWSER_LOAD_TIMEOUT_MS = "120";
135 t.after(() => { if (priorTimeout === undefined) delete process.env.CODEWHALE_CU_BROWSER_LOAD_TIMEOUT_MS; else process.env.CODEWHALE_CU_BROWSER_LOAD_TIMEOUT_MS = priorTimeout; });
136 const { browser } = harness(t);
137 await browser.start({});
138 const receipt = await browser.navigate({ url: "https://slow.test" });
139 assert.equal(receipt.action_sent, true);
140 assert.equal(receipt.verified, false);
141 assert.match(receipt.note, /did not report load completion/);
142 });
143
144 test("browser click resolves a selector through the DOM domain and clicks its box center", async (t) => {
145 const { browser, sockets } = harness(t);
146 await browser.start({});
147 const ws = sockets[0].ws;
148 const receipt = await browser.click({ selector: "#go" });
149 assert.deepEqual(receipt.point, { x: 20, y: 30 });
150 assert.equal(receipt.selector, "#go");
151 assert.equal(receipt.pointer_moved, false);
152 assert.ok(ws.sentOf("DOM.querySelector").some((m) => m.params.selector === "#go" && m.params.nodeId === 1));
153 const clicks = ws.sentOf("Input.dispatchMouseEvent").map((m) => m.params);
154 assert.deepEqual(clicks.map((c) => c.type), ["mouseMoved", "mousePressed", "mouseReleased"]);
155 assert.ok(clicks.every((c) => c.x === 20 && c.y === 30));
156 });
157
158 test("browser click reports selector_not_found and bad_target for an out-of-viewport point", async (t) => {
159 const script = { ...defaultScript(pageState()), "DOM.querySelector": { nodeId: 0 } };
160 const { browser } = harness(t, { script });
161 await browser.start({});
162 await assert.rejects(browser.click({ selector: "#missing" }), (e) => e.code === "selector_not_found" && /#missing/.test(e.message));
163 await assert.rejects(browser.click({ point: { x: 900, y: 5 } }), (e) => e.code === "bad_target" && /800x600/.test(e.message));
164 await assert.rejects(browser.click({}), (e) => e.code === "bad_args");
165 });
166
167 test("browser type focuses an optional selector, inserts text, and can press Enter", async (t) => {
168 const { browser, sockets } = harness(t);
169 await browser.start({});
170 const ws = sockets[0].ws;
171 const receipt = await browser.type({ text: "hi", selector: "#q", enter: true });
172 assert.equal(receipt.chars, 2);
173 assert.equal(receipt.selector, "#q");
174 assert.equal(receipt.entered, true);
175 assert.equal(ws.sentOf("DOM.focus")[0].params.nodeId, 7);
176 assert.equal(ws.sentOf("Input.insertText")[0].params.text, "hi");
177 const keys = ws.sentOf("Input.dispatchKeyEvent").map((m) => m.params);
178 assert.deepEqual(keys.map((k) => k.type), ["keyDown", "keyUp"]);
179 assert.ok(keys.every((k) => k.key === "Enter" && k.windowsVirtualKeyCode === 13));
180 await assert.rejects(browser.type({}), (e) => e.code === "bad_args");
181 });
182
183 test("browser screenshot writes a real file and names the viewport space", async (t) => {
184 const { browser, recDir } = harness(t);
185 await browser.start({});
186 const receipt = await browser.screenshot({});
187 assert.ok(fs.existsSync(receipt.file), receipt.file);
188 assert.equal(fs.readFileSync(receipt.file, "utf8"), "abc");
189 assert.equal(receipt.bytes, 3);
190 assert.deepEqual(receipt.viewport, { w: 800, h: 600 });
191 assert.equal(receipt.scale, 2);
192 assert.equal(receipt.space, "page-viewport");
193 assert.match(receipt.file, new RegExp(recDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
194 });
195
196 test("browser stop closes this session's tab and the browser only when no tabs remain", async (t) => {
197 const { browser, sockets } = harness(t);
198 await browser.start({});
199 const ws = sockets[0].ws;
200 const stop = await browser.stop();
201 assert.equal(stop.closed, true);
202 assert.equal(stop.browser_closed, true);
203 assert.equal(ws.sentOf("Target.closeTarget")[0].params.targetId, "T1");
204 assert.equal(ws.sentOf("Browser.close").length, 1);
205 assert.equal((await browser.status()).running, false);
206 });
207
208 test("browser stop leaves the shared browser up while other sessions' tabs remain", async (t) => {
209 const script = { ...defaultScript(pageState()), "Target.getTargets": { targetInfos: [{ type: "page", targetId: "T2", url: "https://other.test/", title: "other" }] } };
210 const { browser, sockets } = harness(t, { script });
211 await browser.start({});
212 const stop = await browser.stop();
213 assert.equal(stop.browser_closed, false);
214 assert.match(stop.note, /other sessions remain open/);
215 assert.equal(sockets[0].ws.sentOf("Browser.close").length, 0);
216 });
217
218 test("browser reuses a live instance and replaces a stale port file", async (t) => {
219 const shared = fs.mkdtempSync(path.join(os.tmpdir(), "cu-browser-shared-"));
220 t.after(() => fs.rmSync(shared, { recursive: true, force: true }));
221 // A stale file must not shadow a fresh launch: the first connect attempt fails.
222 const first = harness(t, { stateDir: shared, connectFailures: 1 });
223 const profile = path.join(shared, "browser", "profile");
224 fs.mkdirSync(profile, { recursive: true });
225 fs.writeFileSync(path.join(profile, "DevToolsActivePort"), "9999\n/devtools/browser/stale\n");
226 const start = await first.browser.start({});
227 assert.equal(start.launched, true);
228 assert.equal(first.launches.length, 1);
229 // A second session (fresh bridge) sees the live file and reuses the instance.
230 const second = harness(t, { stateDir: shared });
231 const reused = await second.browser.start({});
232 assert.equal(reused.reused, true);
233 assert.equal(reused.launched, false);
234 assert.equal(second.launches.length, 0);
235 });
236
237 test("browser refuses before start, on bad urls, and on old runtimes", async (t) => {
238 const { browser } = harness(t);
239 await assert.rejects(browser.navigate({ url: "https://x.test" }), (e) => e.code === "browser_not_running");
240 assert.equal((await browser.status()).running, false);
241 await assert.rejects(browser.start({ url: "ftp://x.test" }), (e) => e.code === "bad_args");
242 await assert.rejects(browser.start({ url: "javascript:alert(1)" }), (e) => e.code === "bad_args");
243 const prior = globalThis.WebSocket;
244 globalThis.WebSocket = undefined;
245 t.after(() => { globalThis.WebSocket = prior; });
246 await assert.rejects(browser.start({}), (e) => e.code === "unsupported_runtime");
247 });
248
249 test("checkBrowserUrl allows http(s) and about:blank only; findBrowserApp honors overrides", () => {
250 assert.equal(checkBrowserUrl("https://a.test/x"), "https://a.test/x");
251 assert.equal(checkBrowserUrl("http://127.0.0.1:8080/"), "http://127.0.0.1:8080/");
252 assert.equal(checkBrowserUrl("about:blank"), "about:blank");
253 assert.throws(() => checkBrowserUrl("file:///etc/passwd"), /only http/);
254 assert.throws(() => checkBrowserUrl("example.com"), /not a URL/);
255 assert.throws(() => checkBrowserUrl(""), /need a url/);
256 assert.equal(findBrowserApp("darwin", {}, (p) => p === "/Applications/Google Chrome.app"), "/Applications/Google Chrome.app");
257 assert.equal(findBrowserApp("darwin", {}, () => false), null);
258 assert.equal(findBrowserApp("darwin", { CODEWHALE_CU_BROWSER_APP: "/custom/Chromium.app" }, () => false), "/custom/Chromium.app");
259 });
260
261 test('findBrowserApp uses actual Windows vendor folders and executable names', () => {
262 for (const [root, relative] of [
263 ['ProgramFiles', 'Google\\Chrome\\Application\\chrome.exe'],
264 ['ProgramFiles(x86)', 'Microsoft\\Edge\\Application\\msedge.exe'],
265 ['LOCALAPPDATA', 'BraveSoftware\\Brave-Browser\\Application\\brave.exe'],
266 ['LOCALAPPDATA', 'Chromium\\Application\\chrome.exe'],
267 ]) {
268 const expected = `C:\\fixture\\${relative}`;
269 assert.equal(findBrowserApp('win32', { [root]: 'C:\\fixture' }, candidate => candidate === expected), expected);
270 }
271 });
272
272 lines Plain Text