返回 CodeWhale
browser-attach.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / browser-attach.test.mjs
1 // Attach mode: the plugin drives the computer's one shared Chromium through a
2 // NUL-framed CDP Unix socket (the cw-cdp-bridge of codewhale-computing). It
3 // never launches, never closes a tab, never closes the browser.
4 import { test, after } from "node:test";
5 import assert from "node:assert/strict";
6 import fs from "node:fs";
7 import net from "node:net";
8 import os from "node:os";
9 import path from "node:path";
10 import { createBrowser, connectPipeSocket, attachSocket } from "../src/browser-cdp.mjs";
11 // These transports are Unix sockets inside the Linux Sprite; Windows cannot bind the path.
12 const UNIX_SOCKETS = { skip: process.platform === "win32" && "Unix-socket transport (Sprite/Linux only)" };
13
14 const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-attach-"));
15 after(() => fs.rmSync(dir, { recursive: true, force: true }));
16
17 /** A fake bridge: one client at a time, NUL framing, a tiny CDP browser. */
18 function fakeBridge(sock, { tabs = [] } = {}) {
19 const calls = [];
20 const targets = [...tabs];
21 let client = null;
22 let n = 0;
23 const server = net.createServer((s) => {
24 if (client && !client.destroyed) { s.end('{"error":"cdp_busy"}\0'); return; }
25 client = s;
26 let buf = Buffer.alloc(0);
27 const send = (obj) => s.write(`${JSON.stringify(obj)}\0`);
28 s.on("data", (chunk) => {
29 buf = Buffer.concat([buf, chunk]);
30 let i;
31 while ((i = buf.indexOf(0)) >= 0) {
32 const msg = JSON.parse(buf.subarray(0, i).toString());
33 buf = buf.subarray(i + 1);
34 calls.push(msg.method);
35 const reply = (result) => send({ id: msg.id, result, ...(msg.sessionId ? { sessionId: msg.sessionId } : {}) });
36 switch (msg.method) {
37 case "Browser.getVersion": reply({ product: "Chrome/150.0.7871.100" }); break;
38 case "Target.getTargets": reply({ targetInfos: targets.map((t) => ({ ...t, type: "page" })) }); break;
39 case "Target.createTarget": { const t = { targetId: `agent-${++n}`, url: "about:blank", title: "" }; targets.push(t); reply({ targetId: t.targetId }); break; }
40 case "Target.attachToTarget": reply({ sessionId: `s-${msg.params.targetId}` }); break;
41 case "Target.getTargetInfo": { const t = targets.find((x) => x.targetId === msg.params.targetId); reply({ targetInfo: { ...t, type: "page" } }); break; }
42 case "Page.navigate": {
43 const t = targets.find((x) => `s-${x.targetId}` === msg.sessionId);
44 t.url = msg.params.url; t.title = "Example";
45 reply({ frameId: "f" });
46 setTimeout(() => send({ method: "Page.loadEventFired", params: {}, sessionId: msg.sessionId }), 5);
47 break;
48 }
49 default: reply({});
50 }
51 }
52 });
53 s.on("close", () => { if (client === s) client = null; });
54 });
55 return new Promise((resolve) => server.listen(sock, () => resolve({ server, calls, targets })));
56 }
57
58 test("attachSocket reads CODEWHALE_CU_BROWSER_ATTACH", () => {
59 assert.equal(attachSocket({}), null);
60 assert.equal(attachSocket({ CODEWHALE_CU_BROWSER_ATTACH: " /run/cw/cdp.sock " }), "/run/cw/cdp.sock");
61 });
62
63 test("attach: opens a visible tab beside the person's, lists their tabs, stop only detaches", UNIX_SOCKETS, async (t) => {
64 const sock = path.join(dir, "a.sock");
65 const bridge = await fakeBridge(sock, { tabs: [{ targetId: "human-1", url: "https://news.example/", title: "News" }] });
66 t.after(() => bridge.server.close());
67 const launched = [];
68 const browser = createBrowser({ attach: sock, launch: (x) => launched.push(x), findApp: () => { throw new Error("must not look for an app"); }, recordingsDir: () => dir });
69 const started = await browser.start({ url: "https://example.com/" });
70 assert.equal(started.attached, true);
71 assert.equal(started.shared, true);
72 assert.equal(started.browser, "Chrome/150.0.7871.100");
73 assert.equal(started.tab.url, "https://example.com/");
74 assert.equal(started.verified, true);
75 assert.equal(launched.length, 0, "attach mode launches nothing");
76 assert.ok(bridge.calls.includes("Target.createTarget"), "a person's tab is never taken implicitly");
77 assert.ok(bridge.calls.includes("Target.activateTarget"), "the agent's tab is brought to the front");
78
79 // A tab the person opens shows up in the agent's view.
80 bridge.targets.push({ targetId: "human-2", url: "https://mail.example/", title: "Mail" });
81 const status = await browser.status();
82 assert.deepEqual(status.tabs.map((x) => x.id).sort(), ["agent-1", "human-1", "human-2"]);
83 assert.equal(status.tabs.find((x) => x.id === "agent-1").agent, true);
84
85 // Moving to a named tab of the person's is explicit.
86 const moved = await browser.start({ tab: "human-2" });
87 assert.equal(moved.switched_tab, true);
88 assert.equal(moved.activeTab.id, "human-2");
89
90 const stopped = await browser.stop();
91 assert.equal(stopped.detached, true);
92 assert.equal(stopped.browser_closed, false);
93 assert.ok(!bridge.calls.includes("Target.closeTarget"), "no tab is closed");
94 assert.ok(!bridge.calls.includes("Browser.close"), "the shared browser is never closed");
95 assert.ok(bridge.calls.includes("Target.detachFromTarget"));
96 });
97
98 test("attach: adopts a lone blank tab instead of stacking a second", UNIX_SOCKETS, async (t) => {
99 const sock = path.join(dir, "b.sock");
100 const bridge = await fakeBridge(sock, { tabs: [{ targetId: "blank", url: "chrome://newtab/", title: "New Tab" }] });
101 t.after(() => bridge.server.close());
102 const browser = createBrowser({ attach: sock, recordingsDir: () => dir });
103 const started = await browser.start({});
104 assert.equal(started.tab.id, "blank");
105 assert.ok(!bridge.calls.includes("Target.createTarget"));
106 await browser.close();
107 });
108
109 test("attach: a second controller gets browser_busy; a missing bridge gets browser_unavailable", UNIX_SOCKETS, async (t) => {
110 const sock = path.join(dir, "c.sock");
111 const bridge = await fakeBridge(sock, { tabs: [] });
112 t.after(() => bridge.server.close());
113 const holder = await connectPipeSocket(sock);
114 t.after(() => holder.close());
115 await new Promise((r) => setTimeout(r, 20));
116 const browser = createBrowser({ attach: sock, recordingsDir: () => dir });
117 await assert.rejects(browser.start({}), (e) => e.code === "browser_busy");
118 const missing = createBrowser({ attach: path.join(dir, "nope.sock"), recordingsDir: () => dir });
119 await assert.rejects(missing.start({}), (e) => e.code === "browser_unavailable");
120 await assert.rejects(createBrowser({ recordingsDir: () => dir, attach: null }).start({ tab: "x" }), (e) => e.code === "bad_args");
121 });
122
122 lines Plain Text