返回 CodeWhale
tool-merge.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / tool-merge.test.mjs
1 // Slice D: the advertised surface is merged; the wire names stay callable as
2 // aliases. Unit tests pin the expansion rules; a spawned server proves the
3 // advertised list and that both merged and alias calls route identically.
4 import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
5 import { test, before, after } from "node:test";
6 import assert from "node:assert/strict";
7 import { spawn } from "node:child_process";
8 import fs from "node:fs";
9 import os from "node:os";
10 import path from "node:path";
11 import url from "node:url";
12 import { TOOLS, TOOL_NAMES, resolveTool, parseGrant } from "../src/tools.mjs";
13
14 const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
15 const ROOT = path.resolve(__dirname, "..");
16
17 // ---------- unit: expansion rules ----------
18
19 test("click expands by button and clicks; unsupported shapes are bad_args", () => {
20 const t = { type: "element", index: 3 };
21 assert.deepEqual(resolveTool("click", { target: t }), { name: "left_click", args: { target: t } });
22 assert.deepEqual(resolveTool("click", { target: t, clicks: 2 }), { name: "double_click", args: { target: t } });
23 assert.deepEqual(resolveTool("click", { target: t, clicks: 3 }), { name: "triple_click", args: { target: t } });
24 assert.deepEqual(resolveTool("click", { target: t, button: "right" }), { name: "right_click", args: { target: t } });
25 assert.deepEqual(resolveTool("click", { target: t, button: "middle" }), { name: "middle_click", args: { target: t } });
26 // strategy is an a11y left-click concept; it must not leak to right/middle.
27 assert.deepEqual(resolveTool("click", { target: t, strategy: "a11y" }), { name: "left_click", args: { target: t, strategy: "a11y" } });
28 assert.deepEqual(resolveTool("click", { target: t, button: "right", strategy: "a11y" }), { name: "right_click", args: { target: t } });
29 assert.throws(() => resolveTool("click", { target: t, clicks: 4 }), /1-3 clicks/);
30 assert.throws(() => resolveTool("click", { target: t, button: "right", clicks: 2 }), /right x1/);
31 });
32
33 test("pointer, clipboard, recording and computer expand with their requirements enforced", () => {
34 const t = { type: "element", index: 0 };
35 assert.deepEqual(resolveTool("pointer", { action: "move", target: t }), { name: "mouse_move", args: { target: t } });
36 assert.deepEqual(resolveTool("pointer", { action: "up" }), { name: "left_mouse_up", args: {} });
37 assert.throws(() => resolveTool("pointer", { action: "tap", target: t }), /"move", "down" or "up"/);
38 assert.throws(() => resolveTool("pointer", {}), /pointer action/);
39
40 assert.deepEqual(resolveTool("clipboard", { action: "read" }), { name: "read_clipboard", args: { computer: undefined } });
41 assert.deepEqual(resolveTool("clipboard", { action: "write", text: "x" }), { name: "write_clipboard", args: { text: "x", computer: undefined } });
42 assert.throws(() => resolveTool("clipboard", { action: "write" }), /requires text/);
43 assert.throws(() => resolveTool("clipboard", { action: "paste" }), /"read" or "write"/);
44
45 assert.deepEqual(resolveTool("recording", { action: "list" }), { name: "recording_list", args: {} });
46 assert.deepEqual(resolveTool("recording", { action: "status", id: "r1" }), { name: "recording_status", args: { id: "r1" } });
47 assert.throws(() => resolveTool("recording", { action: "stop" }), /requires id/);
48 assert.throws(() => resolveTool("recording", { action: "pause" }), /start, stop, status or list/);
49
50 assert.deepEqual(resolveTool("computer", { action: "list" }), { name: "computer_list", args: {} });
51 assert.deepEqual(resolveTool("computer", { action: "switch", id: "mac2" }), { name: "computer_switch", args: { computer: "mac2" } });
52 assert.deepEqual(resolveTool("computer", { action: "register", id: "box", transport: "ssh", host: "h" }), { name: "computer_register", args: { transport: "ssh", host: "h", computer: "box" } });
53 assert.deepEqual(resolveTool("computer", { action: "spawn", id: "task-x", transport: "docker" }), { name: "computer_spawn", args: { transport: "docker", computer: "task-x" } });
54 assert.throws(() => resolveTool("computer", { action: "switch" }), /requires id/);
55 assert.throws(() => resolveTool("computer", { action: "reset" }), /list, switch, register, spawn or remove/);
56 });
57
58 test("consent expands by action; app identity or foreground scope required", () => {
59 assert.deepEqual(resolveTool("consent", { action: "status" }), { name: "consent_status", args: { computer: undefined } });
60 assert.deepEqual(resolveTool("consent", { action: "allow", app: "Safari", remember: true }), { name: "consent_allow", args: { app: "Safari", remember: true } });
61 assert.deepEqual(resolveTool("consent", { action: "deny", scope: "foreground" }), { name: "consent_deny", args: { scope: "foreground" } });
62 assert.deepEqual(resolveTool("consent", { action: "revoke", bundle_id: "com.apple.Safari" }), { name: "consent_revoke", args: { bundle_id: "com.apple.Safari" } });
63 assert.throws(() => resolveTool("consent", { action: "allow" }), /needs an app/);
64 assert.throws(() => resolveTool("consent", { action: "ponder" }), /status, allow, deny or revoke/);
65 });
66
67 test("key with duration routes to hold semantics; conflicts are bad_args", () => {
68 assert.deepEqual(resolveTool("key", { text: "a" }), { name: "key", args: { text: "a" } });
69 assert.deepEqual(resolveTool("key", { text: "shift", duration: 1.5 }), { name: "hold_key", args: { text: "shift", duration: 1.5 } });
70 assert.throws(() => resolveTool("key", { text: "a", duration: 1, repeat: 2 }), /cannot be combined/);
71 assert.throws(() => resolveTool("key", { text: "a", duration: 99 }), /0.05..30/);
72 });
73
74 test("trajectory actions expand to their wire tools; misuse fails as bad_args", () => {
75 assert.deepEqual(resolveTool("trajectory", { action: "start" }), { name: "trajectory_start", args: {} });
76 assert.deepEqual(resolveTool("trajectory", { action: "replay", id: "traj-x.jsonl", dry_run: true }), { name: "trajectory_replay", args: { id: "traj-x.jsonl", dry_run: true } });
77 assert.throws(() => resolveTool("trajectory", { action: "wat" }), (e) => e.code === "bad_args" && /start, stop, status or replay/.test(e.message));
78 });
79
80 test("parseGrant expands read-only and merged names into wire sets", () => {
81 assert.equal(parseGrant(undefined), null);
82 assert.equal(parseGrant(" "), null);
83 const ro = parseGrant("read-only");
84 assert.ok(ro.has("wait") && ro.has("list_apps") && ro.has("get_value") && ro.has("computer_list"));
85 assert.ok(!ro.has("left_click") && !ro.has("kill_app") && !ro.has("trajectory_start") && !ro.has("type"));
86 const mixed = parseGrant("click, list_apps ,browser_status");
87 for (const wire of ["left_click", "double_click", "middle_click", "list_apps", "browser_status"]) assert.ok(mixed.has(wire), wire);
88 assert.ok(mixed.has("right_click"), "click expansion admits every button");
89 });
90
91 test("browser actions expand to their wire tools; misuse fails as bad_args naming browser", () => {
92 assert.deepEqual(resolveTool("browser", { action: "status" }), { name: "browser_status", args: {} });
93 assert.deepEqual(resolveTool("browser", { action: "navigate", url: "https://a.test" }), { name: "browser_navigate", args: { url: "https://a.test" } });
94 assert.deepEqual(resolveTool("browser", { action: "click", selector: "#x" }), { name: "browser_click", args: { selector: "#x" } });
95 assert.throws(() => resolveTool("browser", { action: "click", selector: "#x", point: { x: 1, y: 2 } }), (e) => e.code === "bad_args" && /not both/.test(e.message));
96 assert.deepEqual(resolveTool("browser", { action: "type", text: "hi", enter: true }), { name: "browser_type", args: { text: "hi", enter: true } });
97 assert.throws(() => resolveTool("browser", { action: "navigate" }), (e) => e.code === "bad_args" && /requires url/.test(e.message));
98 assert.throws(() => resolveTool("browser", { action: "click" }), (e) => e.code === "bad_args" && /needs selector/.test(e.message));
99 assert.throws(() => resolveTool("browser", { action: "type" }), (e) => e.code === "bad_args" && /requires text/.test(e.message));
100 assert.throws(() => resolveTool("browser", { action: "fly" }), (e) => e.code === "bad_args" && /browser action must be start, status, navigate/.test(e.message));
101 });
102
103 test("the advertised list is the merged surface; aliases are not listed", () => {
104 const advertised = TOOLS.filter((t) => t.hidden !== true).map((t) => t.name);
105 const hidden = TOOLS.filter((t) => t.hidden === true).map((t) => t.name);
106 assert.equal(advertised.length, 38, `advertised surface is ${advertised.length}`);
107 assert.equal(hidden.length, 35, `hidden aliases are ${hidden.length}`);
108 for (const merged of ["click", "pointer", "clipboard", "recording", "computer", "browser", "trajectory", "consent"]) assert.ok(advertised.includes(merged), merged);
109 for (const straight of ["list_sessions", "kill_app", "set_window_frame"]) assert.ok(advertised.includes(straight), straight);
110 for (const gone of ["left_click", "double_click", "triple_click", "right_click", "middle_click", "mouse_move",
111 "left_mouse_down", "left_mouse_up", "read_clipboard", "write_clipboard",
112 "recording_start", "recording_stop", "recording_status", "recording_list",
113 "computer_list", "computer_switch", "computer_register", "computer_spawn", "computer_remove", "hold_key",
114 "consent_status", "consent_allow", "consent_deny", "consent_revoke",
115 "browser_start", "browser_status", "browser_navigate", "browser_click", "browser_type", "browser_screenshot", "browser_stop",
116 "trajectory_start", "trajectory_stop", "trajectory_status", "trajectory_replay"]) {
117 assert.ok(!advertised.includes(gone), `${gone} must not be advertised`);
118 assert.ok(TOOL_NAMES.has(gone), `${gone} must stay callable as an alias`);
119 }
120 assert.equal(advertised.length + hidden.length, TOOLS.length);
121 });
122
123 // ---------- protocol: both surfaces over the wire ----------
124
125 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-merge-state-"));
126 let server;
127 let buf = "";
128 const pending = new Map();
129 let nextId = 1;
130
131 function rpc(method, params) {
132 const id = nextId++;
133 return new Promise((resolve, reject) => {
134 const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 20_000);
135 pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
136 server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
137 });
138 }
139
140 async function tool(name, args = {}) {
141 const res = await rpc("tools/call", { name, arguments: args });
142 return JSON.parse(res.result.content[0].text);
143 }
144
145 before(() => {
146 server = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], {
147 env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: path.join(stateDir, "rec") },
148 stdio: ["pipe", "pipe", "pipe"],
149 });
150 server.stdin.write(hostKeysLine());
151 server.stdout.on("data", (c) => {
152 buf += c.toString();
153 let i;
154 while ((i = buf.indexOf("\n")) !== -1) {
155 const line = buf.slice(0, i).trim();
156 buf = buf.slice(i + 1);
157 if (!line) continue;
158 const msg = JSON.parse(line);
159 if (msg.id != null && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
160 }
161 });
162 });
163
164 after(() => { try { server.stdin.end(); } catch {} server?.kill("SIGTERM"); });
165
166 test("tools/list serves exactly the advertised union, validated shapes included", async () => {
167 const res = await rpc("tools/list", {});
168 const names = res.result.tools.map((t) => t.name);
169 assert.equal(names.length, 38);
170 assert.ok(names.includes("click") && names.includes("pointer") && names.includes("clipboard") && names.includes("recording") && names.includes("computer") && names.includes("consent"));
171 assert.ok(names.includes("list_sessions") && names.includes("kill_app") && names.includes("browser") && names.includes("set_window_frame") && names.includes("trajectory"));
172 assert.ok(!names.includes("left_click") && !names.includes("hold_key") && !names.includes("read_clipboard") && !names.includes("consent_allow"));
173 });
174
175 test("a merged call and its wire alias route to the same place (no dispatch without a raster)", async () => {
176 const target = { type: "coordinate", x: 5, y: 5 };
177 const merged = await tool("click", { target });
178 const alias = await tool("left_click", { target });
179 assert.equal(merged.ok, false);
180 assert.equal(merged.error.code, "no_raster", "click must reach the coordinate path, which fails closed without a screenshot");
181 assert.equal(alias.error.code, merged.error.code);
182 assert.equal(alias.error.message, merged.error.message);
183 });
184
185 test("merged-call validation failures name the requested tool and never dispatch", async () => {
186 const badClicks = await tool("click", { target: { type: "coordinate", x: 5, y: 5 }, clicks: 4 });
187 assert.equal(badClicks.error.code, "bad_args");
188 assert.match(badClicks.error.message, /^click supports/);
189
190 const missingTarget = await tool("click", {});
191 assert.equal(missingTarget.error.code, "bad_args");
192 assert.match(missingTarget.error.message, /^click requires "target"/);
193
194 const pointer = await tool("pointer", { action: "tap", target: { type: "coordinate", x: 5, y: 5 } });
195 assert.equal(pointer.error.code, "bad_args");
196 assert.match(pointer.error.message, /pointer action/);
197
198 const clip = await tool("clipboard", { action: "write" });
199 assert.equal(clip.error.code, "bad_args");
200 assert.match(clip.error.message, /^clipboard action "write" requires text/);
201
202 const rec = await tool("recording", { action: "stop" });
203 assert.equal(rec.error.code, "bad_args");
204 assert.match(rec.error.message, /^recording action "stop" requires id/);
205
206 const comp = await tool("computer", { action: "switch" });
207 assert.equal(comp.error.code, "bad_args");
208 assert.match(comp.error.message, /^computer action "switch" requires id/);
209
210 const hold = await tool("key", { text: "a", duration: 1, repeat: 2 });
211 assert.equal(hold.error.code, "bad_args");
212 assert.match(hold.error.message, /cannot be combined/);
213 });
214
215 test("the merged computer tool drives the registry exactly like its wire names", async () => {
216 const listed = await tool("computer", { action: "list" });
217 assert.equal(listed.ok, true);
218 assert.equal(listed.active, "local");
219 assert.ok(Array.isArray(listed.computers));
220
221 const switched = await tool("computer", { action: "switch", id: "local" });
222 assert.equal(switched.ok, true);
223 assert.equal(switched.active, "local");
224 });
225
225 lines Plain Text