返回 CodeWhale
server-protocol.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / server-protocol.test.mjs
1 // Server protocol tests: real MCP server process over stdio, isolated state.
2 // The ssh/scp shims stand in for a remote machine, proving the full remote
3 // agent loop (install -> platform probe -> tool dispatch) without real ssh.
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
12 const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
13 const ROOT = path.resolve(__dirname, "..");
14
15 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-proto-state-"));
16 const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-proto-rec-"));
17 const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), "cu-proto-home-"));
18 const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-proto-bin-"));
19
20 // Portable command fixtures still run the real transferred remote agent.
21 fs.writeFileSync(path.join(binDir, "ssh.cjs"), `
22 const fs = require('node:fs'), path = require('node:path');
23 const args = process.argv.slice(2);
24 const remote = args.slice(args.findIndex(a => a.includes('@')) + 1);
25 if (remote[0] === 'mkdir') fs.mkdirSync(path.join(process.env.FAKE_HOME, remote.at(-1)), {recursive:true});
26 else if (remote[0] === 'node') {
27 const agent = path.join(process.env.FAKE_HOME, remote[1]);
28 process.argv = [process.execPath, agent, ...remote.slice(2)];
29 import(require('node:url').pathToFileURL(agent).href);
30 } else process.exit(1);
31 `);
32 fs.writeFileSync(path.join(binDir, "scp.cjs"), `
33 const fs = require('node:fs'), path = require('node:path');
34 const [source, remote] = process.argv.slice(-2);
35 const dest = path.join(process.env.FAKE_HOME, remote.slice(remote.indexOf(':') + 1));
36 fs.mkdirSync(path.dirname(dest), {recursive:true}); fs.copyFileSync(source, dest);
37 `);
38
39 let server;
40 let buf = "";
41 const pending = new Map();
42 let nextId = 1;
43
44 function rpc(method, params, timeoutMs = 90_000) {
45 const id = nextId++;
46 return new Promise((resolve, reject) => {
47 const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, timeoutMs);
48 pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
49 server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
50 });
51 }
52
53 async function tool(name, args = {}) {
54 const res = await rpc("tools/call", { name, arguments: args });
55 assert.ok(res.result, `${name}: protocol error ${JSON.stringify(res.error ?? {})}`);
56 return JSON.parse(res.result.content[0].text);
57 }
58
59 before(async () => {
60 server = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], {
61 env: {
62 ...process.env,
63 PATH: `${binDir}${path.delimiter}${process.env.PATH}`,
64 CU_COMMAND_FIXTURES: binDir,
65 NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --import=${new URL("./fixtures/command-shims.mjs", import.meta.url).href}`,
66 FAKE_HOME: fakeHome,
67 CODEWHALE_CU_STATE_DIR: stateDir,
68 CODEWHALE_CU_RECORDINGS_DIR: recDir,
69 },
70 stdio: ["pipe", "pipe", "pipe"],
71 });
72 server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`));
73 server.stdout.setEncoding("utf8");
74 server.stdout.on("data", (d) => {
75 buf += d;
76 let i;
77 while ((i = buf.indexOf("\n")) !== -1) {
78 const line = buf.slice(0, i).trim();
79 buf = buf.slice(i + 1);
80 if (!line) continue;
81 try {
82 const msg = JSON.parse(line);
83 if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
84 } catch {}
85 }
86 });
87 const init = await rpc("initialize", { protocolVersion: "2025-06-18" });
88 assert.equal(init.result.serverInfo.name, "codewhale-cu");
89 assert.equal(init.result.serverInfo.version, JSON.parse(fs.readFileSync(new URL("../plugin.json", import.meta.url), "utf8")).version);
90 });
91
92 after(() => {
93 server?.kill("SIGTERM");
94 for (const d of [stateDir, recDir, fakeHome]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
95 });
96
97 test("tools/list advertises the merged surface with valid schemas; wire names stay aliases", async () => {
98 const res = await rpc("tools/list", {});
99 const tools = res.result.tools;
100 assert.ok(tools.length >= 30, `${tools.length} tools`);
101 assert.ok(tools.every((t) => t.hidden !== true), "hidden aliases must not be listed");
102 for (const t of tools) {
103 assert.ok(t.name && t.description && t.inputSchema, `schema incomplete for ${t.name}`);
104 }
105 const names = new Set(tools.map((t) => t.name));
106 for (const required of ["screenshot", "zoom", "click", "pointer", "left_click_drag", "scroll", "type", "key",
107 "set_value", "focus", "get_value", "find_elements", "run_actions", "select_text", "perform_action", "get_app_state", "list_apps", "list_windows", "list_displays",
108 "switch_display", "open_application", "clipboard", "cursor_position", "wait",
109 "recording", "computer", "request_access", "stop_computer_control", "invoke_menu", "preview", "wait_for"]) {
110 assert.ok(names.has(required), `missing tool ${required}`);
111 }
112 for (const hidden of ["left_click", "double_click", "right_click", "hold_key", "mouse_move", "read_clipboard",
113 "recording_start", "computer_list", "computer_switch"]) {
114 assert.ok(!names.has(hidden), `${hidden} is an alias, not advertised`);
115 }
116 });
117
118 test("computer registry round-trip over the protocol", async () => {
119 let r = await tool("computer_list");
120 assert.equal(r.ok, true);
121 assert.equal(r.active, "local");
122 r = await tool("computer_register", { computer: "pad", transport: "hdc" });
123 assert.equal(r.registered.platform, "harmonyos");
124 r = await tool("computer_switch", { computer: "pad" });
125 assert.equal(r.active, "pad");
126 r = await tool("computer_remove", { computer: "pad" });
127 assert.equal(r.active, "local");
128 });
129
130 test("registering an ssh computer installs the agent and probes the platform", async () => {
131 const r = await tool("computer_register", { computer: "box", transport: "ssh", host: "box.test", user: "me" });
132 assert.equal(r.ok, true, JSON.stringify(r.error ?? {}));
133 assert.equal(r.agentInstall.remotePlatform, process.platform, "platform probed via agent");
134 assert.ok(fs.existsSync(path.join(fakeHome, ".codewhale-cu", "agent", "agent.mjs")), "agent pushed");
135 assert.ok(fs.existsSync(path.join(fakeHome, ".codewhale-cu", "agent", "src", "backends", "darwin.mjs")), "src tree pushed");
136 // dispatch a real tool to the "remote" computer. A headless Linux host
137 // (CI) has no window manager tooling, so the remote backend fails closed
138 // with its named reason; that error still proves the round trip.
139 const apps = await tool("list_apps", { computer: "box" });
140 if (apps.ok) {
141 assert.equal(apps.computer.id, "box");
142 assert.ok(Array.isArray(apps.apps), "an app list came back over the wire");
143 // An empty list is a real answer, not a broken one: the Linux box in
144 // docker/ runs a live X session with nothing on it. Only a login session
145 // is guaranteed to have an application in it.
146 if (process.platform === "darwin") {
147 assert.ok(apps.apps.length > 0, "apps returned over the wire");
148 }
149 } else {
150 assert.equal(process.platform, "linux", JSON.stringify(apps.error ?? {}));
151 // A headless CI host fails closed with either shape: the modern
152 // no_session (no $DISPLAY/$WAYLAND_DISPLAY visible to the process)
153 // or the older tool_error naming the missing window-manager tool.
154 // Either answer proves the ssh round trip reached the remote
155 // backend and came back.
156 assert.ok(
157 apps.error.code === "no_session" ||
158 (apps.error.code === "tool_error" &&
159 /wmctrl|swaymsg|hyprctl/u.test(apps.error.message)),
160 JSON.stringify(apps.error ?? {}),
161 );
162 }
163 });
164
165 test("unknown computer fails closed with a named error", async () => {
166 const r = await tool("screenshot", { computer: "ghost" });
167 assert.equal(r.ok, false);
168 assert.equal(r.error.code, "unknown_computer");
169 });
170
171 test("kill switch refuses mutating tools but keeps read-only probes", async () => {
172 let r = await tool("stop_computer_control", { reason: "protocol-test" });
173 assert.equal(r.stopped, true);
174 r = await tool("screenshot");
175 assert.equal(r.error.code, "control_stopped");
176 r = await tool("computer_list");
177 assert.equal(r.ok, true);
178 });
179
179 lines Plain Text