返回 CodeWhale
server-targets.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / server-targets.test.mjs
1 // Target-pipeline tests: real MCP server over stdio with an injected fake
2 // backend (CODEWHALE_CU_TEST_BACKEND) so raster math and element
3 // revalidation can be asserted against the exact args the backend receives.
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
13 const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
14 const ROOT = path.resolve(__dirname, "..");
15
16 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-tgt-state-"));
17 const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-tgt-rec-"));
18 const callsFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "cu-tgt-")), "calls.jsonl");
19 const controlFile = callsFile + ".control.json";
20
21 let server;
22 let buf = "";
23 const pending = new Map();
24 let nextId = 1;
25
26 function rpc(method, params, timeoutMs = 30_000) {
27 const id = nextId++;
28 return new Promise((resolve, reject) => {
29 const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, timeoutMs);
30 pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
31 server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
32 });
33 }
34
35 function rpcId(method, params) {
36 const id = nextId++;
37 const p = new Promise((resolve) => pending.set(id, resolve));
38 server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
39 return { id, p };
40 }
41
42 function notify(method, params) {
43 server.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
44 }
45
46 async function tool(name, args = {}) {
47 const res = await rpc("tools/call", { name, arguments: args });
48 assert.ok(res.result, `${name}: protocol error ${JSON.stringify(res.error ?? {})}`);
49 return JSON.parse(res.result.content[0].text);
50 }
51
52 function calls(method) {
53 if (!fs.existsSync(callsFile)) return [];
54 return fs.readFileSync(callsFile, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)).filter((c) => c.method === method);
55 }
56
57 function setControl(obj) {
58 if (obj == null) fs.rmSync(controlFile, { force: true });
59 else fs.writeFileSync(controlFile, JSON.stringify(obj));
60 }
61
62 before(async () => {
63 server = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], {
64 env: {
65 ...process.env,
66 CODEWHALE_CU_APP: "off",
67 CODEWHALE_CU_STATE_DIR: stateDir,
68 CODEWHALE_CU_RECORDINGS_DIR: recDir,
69 CODEWHALE_CU_TEST_BACKEND: path.join(__dirname, "fixtures", "fake-backend.mjs"),
70 FAKE_BACKEND_CALLS: callsFile,
71 FAKE_BACKEND_CONTROL: controlFile,
72 },
73 stdio: ["pipe", "pipe", "pipe"],
74 });
75 server.stdin.write(hostKeysLine());
76 server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`));
77 server.stdout.setEncoding("utf8");
78 server.stdout.on("data", (d) => {
79 buf += d;
80 let i;
81 while ((i = buf.indexOf("\n")) !== -1) {
82 const line = buf.slice(0, i).trim();
83 buf = buf.slice(i + 1);
84 if (!line) continue;
85 try {
86 const msg = JSON.parse(line);
87 if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
88 } catch {}
89 }
90 });
91 const init = await rpc("initialize", { protocolVersion: "2025-06-18" });
92 assert.equal(init.result.serverInfo.name, "codewhale-cu");
93 // The local consent ledger gates every app-targeted call; record the user's
94 // decisions for the fixture apps up front, as a real session would.
95 for (const app of ["FakeApp", "OCR unavailable", "OtherApp"]) {
96 const c = await tool("consent", { action: "allow", app });
97 assert.equal(c.ok, true, JSON.stringify(c));
98 }
99 });
100
101 after(() => {
102 server?.kill("SIGTERM");
103 for (const d of [stateDir, recDir, path.dirname(callsFile)]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
104 });
105
106 test("app-state arguments distinguish an omitted reference from an explicit null", async () => {
107 await tool("get_app_state", {});
108 assert.equal(Object.hasOwn(calls("get_app_state").at(-1).args, "app_ref"), false);
109 await tool("get_app_state", { app_ref: null });
110 assert.equal(calls("get_app_state").at(-1).args.app_ref, null);
111 });
112
113 test("summary preserves readable UI and original target indices while full retains tree structure", async () => {
114 for (const detail of [undefined, "summary"]) {
115 const state = await tool("get_app_state", { detail });
116 assert.equal(state.detail, "summary");
117 assert.deepEqual(state.elements.map(e => e.index), [0, 1, 2, 3, 6, 7, 8]);
118 assert.ok(state.elements.every(e => !("path" in e) && !("windowIndex" in e)));
119 const field = state.elements.find(e => e.index === 8);
120 assert.deepEqual(field, { index: 8, role: "AXTextField", value: "Fixture text", focused: true, enabled: true, actions: ["AXConfirm"], position: { x: 10, y: 60 }, size: { w: 150, h: 25 } });
121 const action = await tool("perform_action", { target: { type: "element", state_id: state.state_id, index: 1 }, action: "AXPress" });
122 assert.equal(action.ok, true, JSON.stringify(action));
123 assert.deepEqual(calls("perform_action").at(-1).args.target.path, [0, 1]);
124 assert.equal(calls("perform_action").at(-1).args.target.windowIndex, 0);
125 }
126 const full = await tool("get_app_state", { detail: "full" });
127 assert.equal(full.elements.length, 9);
128 assert.deepEqual(full.elements.find(e => e.label === "Save").path, [0, 0, 0]);
129 assert.equal(full.elements.find(e => e.label === "Save").windowIndex, -1);
130 const summary = await tool("get_app_state", {});
131 setControl({ found: true, element: { role: "AXTextField", position: { x: 10, y: 60 }, size: { w: 150, h: 25 } } });
132 try {
133 const changed = await tool("set_value", { target: { type: "element", state_id: summary.state_id, index: 8 }, value: "Changed" });
134 assert.equal(changed.ok, true, JSON.stringify(changed));
135 assert.deepEqual(calls("set_value").at(-1).args.target.path, [0, 2], "sparse public index still addresses the original cached text field");
136 } finally { setControl(null); }
137 assert.equal((await tool("get_app_state", { detail: "guess" })).error.code, "bad_args");
138 assert.equal((await tool("get_app_state", { window_id: -1 })).error.code, "bad_args");
139 assert.equal((await tool("get_app_state", { window_id: 0.5 })).error.code, "bad_args");
140 assert.equal((await tool("get_app_state", { include_ocr: "yes" })).error.code, "bad_args");
141 });
142
143 test("compact and query return a filterable page instead of the whole tree", async () => {
144 const compact = await tool("get_app_state", { detail: "compact" });
145 assert.equal(compact.detail, "compact");
146 assert.equal(compact.ocr, undefined);
147 const field = compact.elements.find(e => e.index === 8);
148 assert.equal(field.role, "AXTextField");
149 assert.equal(field.value, "Fixture text");
150 assert.equal(field.position, undefined);
151 const found = await tool("find_elements", { query: "whale", state_id: compact.state_id });
152 assert.equal(found.ok, true);
153 assert.equal(found.matched, 0);
154 const buttons = await tool("find_elements", { role: "AXButton", state_id: compact.state_id });
155 assert.equal(buttons.matched, 1);
156 assert.equal(buttons.elements[0].label, "OK");
157 const paged = await tool("get_app_state", { limit: 2, offset: 0 });
158 assert.equal(paged.returned, 2);
159 assert.equal(paged.matched, 7);
160 assert.equal(paged.truncated, true);
161 });
162
163 test("type treats newlines and press_enter as Return rather than unicode", async () => {
164 const typed = await tool("type", { text: "hello\nworld", press_enter: true });
165 assert.equal(typed.ok, true, JSON.stringify(typed.error));
166 assert.equal(typed.newlines_as_return, true);
167 const typeCalls = calls("type");
168 const keyCalls = calls("key");
169 assert.deepEqual(typeCalls.slice(-2).map(c => c.args.text), ["hello", "world"]);
170 assert.ok(keyCalls.filter(c => c.args.text === "return").length >= 2);
171 });
172
173 test("screen-space coordinates skip raster conversion", async () => {
174 await tool("screenshot");
175 const r = await tool("left_click", { target: { type: "coordinate", x: 400, y: 300, space: "screen" } });
176 assert.equal(r.ok, true, JSON.stringify(r.error));
177 const last = calls("left_click").at(-1);
178 assert.deepEqual({ x: last.args.target.x, y: last.args.target.y }, { x: 400, y: 300 });
179 assert.equal(last.args.target.coordinate_space, "screen");
180 });
181
182 test("run_actions sequences click, type and key then stops on failure", async () => {
183 const shot = await tool("screenshot");
184 assert.equal(shot.ok, true);
185 const batch = await tool("run_actions", { steps: [
186 { tool: "type", arguments: { text: "hi" } },
187 { tool: "key", arguments: { text: "return" } },
188 { tool: "wait", arguments: { seconds: 0 } },
189 ] });
190 assert.equal(batch.ok, true, JSON.stringify(batch.error));
191 assert.equal(batch.steps.length, 3);
192 const nested = await tool("run_actions", { steps: [{ tool: "run_actions", arguments: { steps: [] } }] });
193 assert.equal(nested.ok, false);
194 });
195
196 test("optional OCR binds its exact raster for coordinate actions while preserving AX state", async () => {
197 const state = await tool("get_app_state", { include_ocr: true });
198 assert.equal(state.ok, true);
199 assert.equal(state.ocr.status, "ok");
200 assert.ok(state.elements.some(e => e.index === 8 && e.value === "Fixture text"));
201 assert.equal(state.ocr.blocks[0].role, undefined, "recognized text is not a fabricated semantic element");
202 const clicked = await tool("left_click", { target: state.ocr.blocks[0].target });
203 assert.equal(clicked.ok, true);
204 assert.deepEqual(calls("left_click").at(-1).args.target, { type: "coordinate", x: 140, y: 70, strategy: "event", coordinate_space: "raster" });
205 assert.equal((await tool("left_click", { target: { type: "coordinate", x: 400, y: 0 } })).error.code, "target_outside_raster");
206 const unavailable = await tool("get_app_state", { include_ocr: true, app_ref: { name: "OCR unavailable" } });
207 assert.equal(unavailable.ok, true);
208 assert.equal(unavailable.ocr.status, "unavailable");
209 assert.ok(unavailable.elements.length > 0);
210 assert.equal((await tool("get_app_state", {})).ocr, undefined, "normal observations do not request OCR");
211 });
212
213 test("coordinate targets map raster pixels through the bound scale", async () => {
214 const shot = await tool("screenshot");
215 assert.equal(shot.ok, true);
216 assert.deepEqual(shot.pixels, { w: 1600, h: 1200 });
217 const r = await tool("left_click", { target: { type: "coordinate", x: 400, y: 300 } });
218 assert.equal(r.ok, true, JSON.stringify(r.error));
219 const last = calls("left_click").at(-1);
220 assert.deepEqual({ x: last.args.target.x, y: last.args.target.y }, { x: 200, y: 150 });
221 });
222
223 test("region screenshots bind the region origin for later coordinates", async () => {
224 const shot = await tool("screenshot", { region: [50, 40, 400, 200] });
225 assert.equal(shot.ok, true);
226 const r = await tool("left_click", { target: { type: "coordinate", x: 100, y: 60 } });
227 assert.equal(r.ok, true, JSON.stringify(r.error));
228 const last = calls("left_click").at(-1);
229 // origin (50,40) + pixel (100,60) / scale 2 -> (100, 70)
230 assert.deepEqual({ x: last.args.target.x, y: last.args.target.y }, { x: 100, y: 70 });
231 });
232
233 test("zoom binds a child raster that keeps parent scale and shifted origin", async () => {
234 await tool("screenshot"); // rebind the full 1600x1200 @ scale 2 raster
235 const z = await tool("zoom", { region: [100, 100, 200, 200] });
236 assert.equal(z.ok, true, JSON.stringify(z.error));
237 const r = await tool("left_click", { target: { type: "coordinate", x: 10, y: 10 } });
238 assert.equal(r.ok, true, JSON.stringify(r.error));
239 const last = calls("left_click").at(-1);
240 // origin 0 + (100 + 10) / 2 = 55
241 assert.deepEqual({ x: last.args.target.x, y: last.args.target.y }, { x: 55, y: 55 });
242 });
243
244 test("screenshot and zoom never forward a caller-named source file", async () => {
245 await tool("screenshot", { source: "/etc/hosts" });
246 assert.equal(Object.hasOwn(calls("screenshot").at(-1).args, "source"), false);
247 const z = await tool("zoom", { region: [0, 0, 10, 10], source: "/etc/hosts" });
248 assert.equal(z.ok, true, JSON.stringify(z.error));
249 assert.equal(Object.hasOwn(calls("zoom").at(-1).args, "source"), false);
250 });
251
252 test("zoom without a bound raster fails with no_raster", async () => {
253 // Fresh computer id has no raster — hdc "pad" registered with no state.
254 const reg = await tool("computer_register", { computer: "pad", transport: "hdc" });
255 assert.equal(reg.ok, true);
256 const z = await tool("zoom", { region: [0, 0, 10, 10], computer: "pad" });
257 assert.equal(z.ok, false);
258 assert.equal(z.error.code, "no_raster");
259 await tool("computer_remove", { computer: "pad" });
260 });
261
262 test("coordinate outside the bound raster fails with target_outside_raster", async () => {
263 await tool("screenshot"); // 1600x1200 bound
264 const r = await tool("left_click", { target: { type: "coordinate", x: 2000, y: 10 } });
265 assert.equal(r.ok, false);
266 assert.equal(r.error.code, "target_outside_raster");
267 assert.match(r.error.message, /1600x1200/);
268 assert.equal(calls("left_click").filter((c) => c.args.target.x === 2000).length, 0, "backend must not be called");
269 });
270
271 async function freshState() {
272 const st = await tool("get_app_state", { app_ref: { name: "FakeApp" } });
273 assert.equal(st.ok, true, JSON.stringify(st.error));
274 assert.ok(st.state_id);
275 return st;
276 }
277
278 test("element targets are revalidated; moved geometry re-aims and marks the receipt", async () => {
279 const st = await freshState();
280 setControl({ found: true, element: { role: "AXButton", label: "OK", position: { x: 100, y: 200 }, size: { w: 60, h: 30 } }, reason: null });
281 try {
282 const r = await tool("left_click", { target: { type: "element", state_id: st.state_id, index: 1 } });
283 assert.equal(r.ok, true, JSON.stringify(r.error));
284 assert.equal(r.target_reacquired, true);
285 const last = calls("left_click").at(-1);
286 assert.deepEqual({ x: last.args.target.x, y: last.args.target.y }, { x: 130, y: 215 }); // fresh center
287 assert.deepEqual(last.args.target.path, [0, 1], "pointer dispatch retains the original element path");
288 assert.equal(last.args.target.windowIndex, 0);
289 assert.equal(last.args.target.label, "OK");
290 } finally {
291 setControl(null);
292 }
293 });
294
295 test("unmoved element geometry does not mark the receipt reacquired", async () => {
296 const st = await freshState();
297 setControl(null); // fake returns the cached geometry for element 1
298 const r = await tool("left_click", { target: { type: "element", state_id: st.state_id, index: 1 } });
299 assert.equal(r.ok, true, JSON.stringify(r.error));
300 assert.notEqual(r.target_reacquired, true);
301 const last = calls("left_click").at(-1);
302 assert.deepEqual({ x: last.args.target.x, y: last.args.target.y }, { x: 40, y: 35 });
303 });
304
305 test("an element target without state_id binds the computer's latest observation", async () => {
306 const st = await freshState();
307 setControl(null);
308 const r = await tool("left_click", { target: { type: "element", index: 1 } });
309 assert.equal(r.ok, true, JSON.stringify(r.error));
310 const last = calls("left_click").at(-1);
311 assert.deepEqual({ x: last.args.target.x, y: last.args.target.y }, { x: 40, y: 35 });
312 assert.notEqual(st.state_id, undefined);
313 });
314
315 test("a bare element index follows the newest observation; an explicit state_id pins the older one", async () => {
316 const first = await freshState();
317 const second = await freshState();
318 assert.notEqual(first.state_id, second.state_id);
319 setControl(null);
320 // index 1 in the fresh state is the same fixture button in both states.
321 const latest = await tool("left_click", { target: { type: "element", index: 1 } });
322 assert.equal(latest.ok, true, JSON.stringify(latest.error));
323 const pinned = await tool("left_click", { target: { type: "element", state_id: first.state_id, index: 1 } });
324 assert.equal(pinned.ok, true, JSON.stringify(pinned.error));
325 });
326
327 test("stale element fails element_stale without touching the pointer", async () => {
328 const st = await freshState();
329 setControl({ found: false, element: null, reason: "element_gone" });
330 const before = calls("left_click").length;
331 try {
332 const r = await tool("left_click", { target: { type: "element", state_id: st.state_id, index: 1 } });
333 assert.equal(r.ok, false);
334 assert.equal(r.error.code, "element_stale");
335 assert.match(r.error.message, /element 1/);
336 assert.equal(calls("left_click").length, before, "backend pointer must not be called");
337 } finally {
338 setControl(null);
339 }
340 });
341
342 test("in-place replacement (same geometry, different label) fails element_stale", async () => {
343 const st = await freshState();
344 setControl({ found: true, element: { role: "AXButton", label: "Confirm", position: { x: 10, y: 20 }, size: { w: 60, h: 30 } }, reason: null });
345 const before = calls("left_click").length;
346 try {
347 const r = await tool("left_click", { target: { type: "element", state_id: st.state_id, index: 1 } });
348 assert.equal(r.ok, false);
349 assert.equal(r.error.code, "element_stale");
350 assert.match(r.error.message, /changed label \(OK → Confirm\)/);
351 assert.equal(calls("left_click").length, before, "backend pointer must not be called");
352 } finally {
353 setControl(null);
354 }
355 });
356
357 test("an element losing its label or role is stale even if the geometry matches", async () => {
358 const st = await freshState();
359 const before = calls("left_click").length;
360 try {
361 for (const identity of [{ role: "AXButton", label: "" }, { role: "AXButton" }, { label: "OK" }]) {
362 setControl({ found: true, element: { ...identity, position: { x: 10, y: 20 }, size: { w: 60, h: 30 } } });
363 const r = await tool("left_click", { target: { type: "element", state_id: st.state_id, index: 1 } });
364 assert.equal(r.ok, false);
365 assert.equal(r.error.code, "element_stale");
366 }
367 assert.equal(calls("left_click").length, before);
368 } finally { setControl(null); }
369 });
370
371 test("a state_id issued on another computer fails state_wrong_computer", async () => {
372 const st = await freshState(); // bound to "local"
373 const reg = await tool("computer_register", { computer: "other-pad", transport: "hdc" });
374 assert.equal(reg.ok, true);
375 try {
376 const r = await tool("left_click", { computer: "other-pad", target: { type: "element", state_id: st.state_id, index: 1 } });
377 assert.equal(r.ok, false);
378 assert.equal(r.error.code, "state_wrong_computer");
379 } finally {
380 await tool("computer_remove", { computer: "other-pad" });
381 await tool("computer_switch", { computer: "local" });
382 }
383 });
384
385 test("missing required arguments fail bad_args before any backend call", async () => {
386 const counted = ["left_mouse_down", "select_text", "key", "set_value"];
387 const before = counted.reduce((n, m) => n + calls(m).length, 0);
388 for (const [name, args, field] of [
389 ["left_mouse_down", {}, "target"],
390 ["left_click", {}, "target"],
391 ["select_text", {}, "target"],
392 ["key", {}, "text"],
393 ["set_value", { value: "x" }, "target"],
394 ["hold_key", { text: "a" }, "duration"],
395 ]) {
396 const r = await tool(name, args);
397 assert.equal(r.ok, false, `${name} must refuse missing args`);
398 assert.equal(r.error.code, "bad_args", `${name}: ${JSON.stringify(r.error)}`);
399 assert.match(r.error.message, new RegExp(field));
400 }
401 const after = counted.reduce((n, m) => n + calls(m).length, 0);
402 assert.equal(after, before, "no request may reach the backend");
403 });
404
405 test("element-only tools refuse coordinate or malformed targets with bad_target", async () => {
406 for (const [name, args] of [
407 ["set_value", { target: { x: 10, y: 10 }, value: "x" }],
408 ["set_value", { target: { type: "coordinate", space: "screen", x: 1, y: 2 }, value: "x" }],
409 ["select_text", { target: { type: "coordinate", space: "screen", x: 1, y: 2 } }],
410 ["perform_action", { target: { type: "coordinate", space: "screen", x: 1, y: 2 }, action: "AXPress" }],
411 ["left_click", { target: "5,5" }],
412 ["left_click", { target: { type: "nonsense" } }],
413 ["left_click", { target: 42 }],
414 ]) {
415 const r = await tool(name, args);
416 assert.equal(r.ok, false, `${name}: ${JSON.stringify(r)}`);
417 assert.equal(r.error.code, "bad_target", `${name}: ${JSON.stringify(r.error)}`);
418 }
419 });
420
421 test("a stale element error names the resolved state and app, not 'undefined'", async () => {
422 const st = await freshState();
423 setControl({ found: false, element: null, reason: "window_not_found" });
424 try {
425 // Bare index binds the latest observation — the message must say which.
426 const r = await tool("left_click", { target: { type: "element", index: 1 } });
427 assert.equal(r.ok, false);
428 assert.equal(r.error.code, "element_stale");
429 assert.match(r.error.message, new RegExp(`state ${st.state_id}`));
430 assert.match(r.error.message, /FakeApp/);
431 assert.doesNotMatch(r.error.message, /undefined/);
432 } finally {
433 setControl(null);
434 }
435 });
436
437 test("binding a different app retires bare element indices but keeps pinned states", async () => {
438 const st = await freshState();
439 const opened = await tool("open_application", { name: "OtherApp" });
440 assert.equal(opened.ok, true, JSON.stringify(opened.error));
441 assert.match(opened.note ?? "", /different app/);
442 const bare = await tool("left_click", { target: { type: "element", index: 1 } });
443 assert.equal(bare.ok, false);
444 assert.equal(bare.error.code, "unknown_state");
445 // An explicit state_id still resolves through its own pinned observation.
446 const pinned = await tool("left_click", { target: { type: "element", state_id: st.state_id, index: 1 } });
447 assert.equal(pinned.ok, true, JSON.stringify(pinned.error));
448 });
449
450 test("notifications/cancelled drops the in-flight response but not the server", async () => {
451 const { id, p } = rpcId("tools/call", { name: "wait", arguments: { seconds: 3 } });
452 await new Promise((r) => setTimeout(r, 200));
453 notify("notifications/cancelled", { requestId: id });
454 const winner = await Promise.race([
455 p.then((m) => ({ got: true, m })),
456 new Promise((r) => setTimeout(() => r({ got: false }), 4_000)),
457 ]);
458 assert.equal(winner.got, false, "cancelled request must not produce a response");
459 const ping = await rpc("ping", {});
460 assert.deepEqual(ping.result, {});
461 });
462
462 lines Plain Text