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