返回 CodeWhale
server-wire-targets.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / server-wire-targets.test.mjs
1 // The out-of-process route — the desktop app on macOS, the ssh agent on a
2 // remote computer — is the DEFAULT route for the local computer whenever the
3 // app is installed, yet it used to prepare arguments differently from the
4 // in-process route. These tests drive the real server over stdio with that
5 // route forced (CODEWHALE_CU_TEST_REMOTE) and assert that a coordinate target
6 // reaches the backend as screen points, with the raster's refusals intact.
7 import { hostKeysLine, attest, attestParams } from "./fixtures/host-decision.mjs";
8 import { test, before, after } from "node:test";
9 import assert from "node:assert/strict";
10 import { spawn } from "node:child_process";
11 import fs from "node:fs";
12 import os from "node:os";
13 import path from "node:path";
14 import url from "node:url";
15
16 const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
17 const ROOT = path.resolve(__dirname, "..");
18
19 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-wire-state-"));
20 const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-wire-rec-"));
21 const callsFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "cu-wire-")), "calls.jsonl");
22
23 let server;
24 let buf = "";
25 const pending = new Map();
26 let nextId = 1;
27
28 function rpc(method, params, timeoutMs = 30_000) {
29 const id = nextId++;
30 return new Promise((resolve, reject) => {
31 const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, timeoutMs);
32 pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
33 server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params: attestParams(method, params) }) + "\n");
34 });
35 }
36
37 async function tool(name, args = {}) {
38 const res = await rpc("tools/call", { name, arguments: args });
39 return JSON.parse(res.result.content[0].text);
40 }
41
42 function calls() {
43 if (!fs.existsSync(callsFile)) return [];
44 return fs.readFileSync(callsFile, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l));
45 }
46
47 before(async () => {
48 server = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], {
49 env: {
50 ...process.env,
51 CODEWHALE_CU_TEST_REMOTE: "1",
52 CODEWHALE_CU_STATE_DIR: stateDir,
53 CODEWHALE_CU_RECORDINGS_DIR: recDir,
54 CODEWHALE_CU_TEST_BACKEND: path.join(__dirname, "fixtures", "fake-backend.mjs"),
55 FAKE_BACKEND_CALLS: callsFile,
56 FAKE_BACKEND_CONTROL: callsFile + ".control.json",
57 },
58 stdio: ["pipe", "pipe", "pipe"],
59 });
60 server.stdin.write(hostKeysLine());
61 server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`));
62 server.stdout.setEncoding("utf8");
63 server.stdout.on("data", (d) => {
64 buf += d;
65 let i;
66 while ((i = buf.indexOf("\n")) !== -1) {
67 const line = buf.slice(0, i).trim();
68 buf = buf.slice(i + 1);
69 if (!line) continue;
70 try {
71 const msg = JSON.parse(line);
72 if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
73 } catch {}
74 }
75 });
76 const init = await rpc("initialize", { protocolVersion: "2025-06-18" });
77 assert.equal(init.result.serverInfo.name, "codewhale-cu");
78 // The local consent ledger gates app-targeted calls; record the fixture
79 // app's decision up front, as a real session would.
80 const c = await tool("consent", { action: "allow", app: "FakeApp" });
81 assert.equal(c.ok, true, JSON.stringify(c));
82 });
83
84 after(() => {
85 server?.kill("SIGTERM");
86 for (const d of [stateDir, recDir, path.dirname(callsFile)]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
87 });
88
89 test("coordinate targets reach an out-of-process backend as screen points, not raster pixels", async () => {
90 // The fake backend reports a 2x raster whose origin is (100, 50) in points.
91 const shot = await tool("screenshot", { region: [100, 50, 200, 100] });
92 assert.equal(shot.ok, true);
93 assert.equal(shot.scale, 2);
94 assert.deepEqual({ x: shot.points.x, y: shot.points.y }, { x: 100, y: 50 });
95
96 const click = await tool("left_click", { target: { type: "coordinate", x: 80, y: 40 } });
97 assert.equal(click.ok, true, JSON.stringify(click));
98 const sent = calls().filter((c) => c.method === "left_click").at(-1);
99 assert.deepEqual({ x: sent.args.target.x, y: sent.args.target.y }, { x: 140, y: 70 },
100 "80/2 + 100 = 140, 40/2 + 50 = 70 — the backend only ever knows the screen");
101 });
102
103 test("out-of-process coordinate actions keep the raster refusals", async () => {
104 const outside = await tool("left_click", { target: { type: "coordinate", x: 5000, y: 5000 } });
105 assert.equal(outside.ok, false);
106 assert.equal(outside.error.code, "target_outside_raster");
107
108 const stale = await tool("left_click", { target: { type: "element", state_id: "s-999", index: 0 } });
109 assert.equal(stale.ok, false);
110 assert.equal(stale.error.code, "unknown_state");
111
112 const before = calls().filter((c) => c.method === "left_click").length;
113 await tool("left_click", { target: { type: "coordinate", x: 1, y: 1 } });
114 assert.equal(calls().filter((c) => c.method === "left_click").length, before + 1,
115 "refusals never reach the backend; the valid click does");
116 });
117
118 test("element targets retain their identity and AX path on the out-of-process pointer route", async () => {
119 const state = await tool("get_app_state", {});
120 assert.equal(state.ok, true);
121 assert.equal(state.detail, "summary");
122 assert.ok(state.elements.every(e => !("path" in e) && !("windowIndex" in e)));
123 assert.ok(!state.elements.some(e => e.label === "Save"));
124 assert.equal(state.elements.find(e => e.index === 8).value, "Fixture text");
125 const target = { type: "element", state_id: state.state_id, index: 1 };
126
127 const click = await tool("left_click", { target });
128 assert.equal(click.ok, true, JSON.stringify(click));
129 const clicked = calls().filter((c) => c.method === "left_click").at(-1);
130 assert.deepEqual({ x: clicked.args.target.x, y: clicked.args.target.y }, { x: 40, y: 35 }, "element center in points");
131 assert.deepEqual(clicked.args.target.path, [0, 1]);
132 assert.equal(clicked.args.target.windowIndex, 0);
133 assert.equal(clicked.args.target.role, "AXButton");
134 assert.equal(clicked.args.target.label, "OK");
135
136 const pressed = await tool("perform_action", { target, action: "AXPress" });
137 assert.equal(pressed.ok, true, JSON.stringify(pressed));
138 const semantic = calls().filter((c) => c.method === "perform_action").at(-1);
139 assert.deepEqual(semantic.args.target.path, [0, 1], "semantic actions address the element, not a point");
140 assert.equal(semantic.args.target.windowIndex, 0);
141
142 const full = await tool("get_app_state", { detail: "full" });
143 assert.equal(full.elements.length, 9);
144 assert.deepEqual(full.elements.find(e => e.label === "Save").path, [0, 0, 0]);
145 });
146
147 test("out-of-process OCR observation binds the raster that its text targets use", async () => {
148 const state = await tool("get_app_state", { include_ocr: true });
149 assert.equal(state.ocr.status, "ok");
150 const clicked = await tool("left_click", { target: state.ocr.blocks[0].target });
151 assert.equal(clicked.ok, true);
152 const target = calls().filter(c => c.method === "left_click").at(-1).args.target;
153 assert.deepEqual({ x: target.x, y: target.y }, { x: 140, y: 70 });
154 assert.equal((await tool("left_click", { target: { type: "coordinate", x: 400, y: 0 } })).error.code, "target_outside_raster");
155 });
156
156 lines Plain Text