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