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