| 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 |