| 1 | // wait_for polling, element-targeted type/key, and the persistent agent |
| 2 | // channel: real MCP server over stdio with the injected fake backend, plus |
| 3 | // the remote agent's --serve mode driven as a local child process. |
| 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 | import { ensureSshChannel, channelRequest, b64 } from "../src/transport.mjs"; |
| 12 | |
| 13 | const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); |
| 14 | const ROOT = path.resolve(__dirname, ".."); |
| 15 | const FAKE = path.join(__dirname, "fixtures", "fake-backend.mjs"); |
| 16 | |
| 17 | const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-wait-state-")); |
| 18 | const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-wait-rec-")); |
| 19 | const callsFile = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "cu-wait-")), "calls.jsonl"); |
| 20 | const controlFile = callsFile + ".control.json"; |
| 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 | assert.ok(res.result, `${name}: protocol error ${JSON.stringify(res.error ?? {})}`); |
| 39 | return JSON.parse(res.result.content[0].text); |
| 40 | } |
| 41 | |
| 42 | function calls(method) { |
| 43 | if (!fs.existsSync(callsFile)) return []; |
| 44 | return fs.readFileSync(callsFile, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l)).filter((c) => c.method === method); |
| 45 | } |
| 46 | |
| 47 | function setControl(obj) { |
| 48 | if (obj == null) fs.rmSync(controlFile, { force: true }); |
| 49 | else fs.writeFileSync(controlFile, JSON.stringify(obj)); |
| 50 | } |
| 51 | |
| 52 | before(async () => { |
| 53 | server = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], { |
| 54 | env: { |
| 55 | ...process.env, |
| 56 | CODEWHALE_CU_APP: "off", |
| 57 | CODEWHALE_CU_STATE_DIR: stateDir, |
| 58 | CODEWHALE_CU_RECORDINGS_DIR: recDir, |
| 59 | CODEWHALE_CU_TEST_BACKEND: FAKE, |
| 60 | FAKE_BACKEND_CALLS: callsFile, |
| 61 | FAKE_BACKEND_CONTROL: controlFile, |
| 62 | }, |
| 63 | stdio: ["pipe", "pipe", "pipe"], |
| 64 | }); |
| 65 | server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`)); |
| 66 | server.stdout.setEncoding("utf8"); |
| 67 | server.stdout.on("data", (d) => { |
| 68 | buf += d; |
| 69 | let i; |
| 70 | while ((i = buf.indexOf("\n")) !== -1) { |
| 71 | const line = buf.slice(0, i).trim(); |
| 72 | buf = buf.slice(i + 1); |
| 73 | if (!line) continue; |
| 74 | try { |
| 75 | const msg = JSON.parse(line); |
| 76 | if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); } |
| 77 | } catch {} |
| 78 | } |
| 79 | }); |
| 80 | const init = await rpc("initialize", { protocolVersion: "2025-06-18" }); |
| 81 | assert.equal(init.result.serverInfo.name, "codewhale-cu"); |
| 82 | // The local consent ledger gates app-targeted calls; record the fixture |
| 83 | // app's decision up front, as a real session would. |
| 84 | const c = await tool("consent", { action: "allow", app: "FakeApp" }); |
| 85 | assert.equal(c.ok, true, JSON.stringify(c)); |
| 86 | }); |
| 87 | |
| 88 | after(() => { |
| 89 | server?.kill("SIGTERM"); |
| 90 | for (const d of [stateDir, recDir, path.dirname(callsFile)]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } |
| 91 | }); |
| 92 | |
| 93 | test("wait_for returns matched elements bound to a fresh targetable state_id", async () => { |
| 94 | const r = await tool("wait_for", { query: "OK", role: "AXButton", timeout: 5 }); |
| 95 | assert.equal(r.ok, true, JSON.stringify(r)); |
| 96 | assert.equal(r.matched, true); |
| 97 | assert.equal(r.matched_count, 1); |
| 98 | assert.equal(r.elements[0].label, "OK"); |
| 99 | assert.ok(r.state_id, "the satisfying observation is bound"); |
| 100 | // The returned state_id is targetable: the button resolves and presses. |
| 101 | const press = await tool("perform_action", { target: { type: "element", state_id: r.state_id, index: r.elements[0].index }, action: "AXPress" }); |
| 102 | assert.equal(press.ok, true, JSON.stringify(press.error)); |
| 103 | }); |
| 104 | |
| 105 | test("wait_for absent is satisfied immediately when nothing matches", async () => { |
| 106 | const r = await tool("wait_for", { query: "no such element anywhere", state: "absent", timeout: 5 }); |
| 107 | assert.equal(r.ok, true, JSON.stringify(r)); |
| 108 | assert.equal(r.matched, true); |
| 109 | assert.equal(r.matched_count, 0); |
| 110 | assert.equal(r.timed_out, undefined); |
| 111 | }); |
| 112 | |
| 113 | test("wait_for times out honestly when the predicate never holds", async () => { |
| 114 | const before = calls("get_app_state").length; |
| 115 | const r = await tool("wait_for", { query: "never-present-label", timeout: 0.6, interval: 150 }); |
| 116 | assert.equal(r.ok, true, JSON.stringify(r)); |
| 117 | assert.equal(r.matched, false); |
| 118 | assert.equal(r.timed_out, true); |
| 119 | assert.ok(r.polls >= 2, `expected several polls, got ${r.polls}`); |
| 120 | assert.equal(r.state_id, undefined, "a timed-out wait binds nothing"); |
| 121 | assert.ok(calls("get_app_state").length - before >= r.polls - 1, "ephemeral polls reached the backend"); |
| 122 | }); |
| 123 | |
| 124 | test("ephemeral wait_for polls do not evict earlier states", async () => { |
| 125 | const st = await tool("get_app_state", { app_ref: { name: "FakeApp" } }); |
| 126 | assert.ok(st.state_id); |
| 127 | // ~30 polls: more than the 24-state cache cap. If polls were cached, st |
| 128 | // would be evicted; ephemeral polling keeps it targetable. |
| 129 | const w = await tool("wait_for", { query: "never-present-label", timeout: 3, interval: 100 }); |
| 130 | assert.equal(w.timed_out, true); |
| 131 | assert.ok(w.polls > 24, `expected >24 polls to prove non-caching, got ${w.polls}`); |
| 132 | const focus = await tool("focus", { target: { type: "element", state_id: st.state_id, index: 1 } }); |
| 133 | assert.equal(focus.ok, true, JSON.stringify(focus.error)); |
| 134 | }); |
| 135 | |
| 136 | test("wait_for validates its arguments", async () => { |
| 137 | const bad = async (args, match) => { |
| 138 | const res = await rpc("tools/call", { name: "wait_for", arguments: args }); |
| 139 | assert.match(res.error?.message ?? "", match, JSON.stringify(res)); |
| 140 | }; |
| 141 | await bad({}, /needs a query and\/or role/); |
| 142 | await bad({ query: "x", state: "bogus" }, /state must be "present" or "absent"/); |
| 143 | await bad({ query: "x", timeout: 999 }, /timeout/); |
| 144 | await bad({ query: "x", interval: 5 }, /interval/); |
| 145 | }); |
| 146 | |
| 147 | test("type with an element target focuses first, then types", async () => { |
| 148 | const st = await tool("get_app_state", { app_ref: { name: "FakeApp" } }); |
| 149 | const beforeFocus = calls("focus").length; |
| 150 | const beforeType = calls("type").length; |
| 151 | setControl({ found: true, element: { role: "AXTextField", position: { x: 10, y: 60 }, size: { w: 150, h: 25 } }, reason: null }); |
| 152 | try { |
| 153 | const r = await tool("type", { text: "hello", target: { type: "element", state_id: st.state_id, index: 8 } }); |
| 154 | assert.equal(r.ok, true, JSON.stringify(r.error)); |
| 155 | const focusCalls = calls("focus"); |
| 156 | const typeCalls = calls("type"); |
| 157 | assert.equal(focusCalls.length, beforeFocus + 1); |
| 158 | assert.equal(typeCalls.length, beforeType + 1); |
| 159 | assert.deepEqual(focusCalls.at(-1).args.target.path, [0, 2], "focus received the semantic element target"); |
| 160 | assert.equal(typeCalls.at(-1).args.text, "hello"); |
| 161 | } finally { setControl(null); } |
| 162 | }); |
| 163 | |
| 164 | test("key with an element target focuses first; a coordinate target is refused", async () => { |
| 165 | const st = await tool("get_app_state", { app_ref: { name: "FakeApp" } }); |
| 166 | setControl({ found: true, element: { role: "AXTextField", position: { x: 10, y: 60 }, size: { w: 150, h: 25 } }, reason: null }); |
| 167 | try { |
| 168 | const r = await tool("key", { text: "return", target: { type: "element", state_id: st.state_id, index: 8 } }); |
| 169 | assert.equal(r.ok, true, JSON.stringify(r.error)); |
| 170 | } finally { setControl(null); } |
| 171 | const refused = await tool("type", { text: "x", target: { type: "coordinate", x: 1, y: 1 } }); |
| 172 | assert.equal(refused.ok, false); |
| 173 | assert.equal(refused.error.code, "bad_target"); |
| 174 | }); |
| 175 | |
| 176 | test("type target fails closed when the element went stale before any text is sent", async () => { |
| 177 | const st = await tool("get_app_state", { app_ref: { name: "FakeApp" } }); |
| 178 | setControl({ found: false, element: null, reason: "element_gone" }); |
| 179 | const before = calls("type").length; |
| 180 | try { |
| 181 | const r = await tool("type", { text: "should never land", target: { type: "element", state_id: st.state_id, index: 1 } }); |
| 182 | assert.equal(r.ok, false); |
| 183 | assert.equal(r.error.code, "element_stale"); |
| 184 | assert.equal(r.stage, "focus"); |
| 185 | assert.equal(calls("type").length, before, "no keystrokes after the failed focus"); |
| 186 | } finally { |
| 187 | setControl(null); |
| 188 | } |
| 189 | }); |
| 190 | |
| 191 | // ---------- persistent remote agent ---------- |
| 192 | |
| 193 | test("agent --serve keeps its backend binding across requests", async t => { |
| 194 | const child = spawn("node", [path.join(ROOT, "agent.mjs"), "--serve"], { |
| 195 | env: { ...process.env, CODEWHALE_CU_TEST_BACKEND: FAKE, FAKE_BACKEND_CALLS: callsFile }, |
| 196 | stdio: ["pipe", "pipe", "pipe"], |
| 197 | }); |
| 198 | t.after(() => child.kill("SIGKILL")); |
| 199 | const replies = new Map(); |
| 200 | let rbuf = ""; |
| 201 | child.stdout.setEncoding("utf8").on("data", (d) => { |
| 202 | rbuf += d; |
| 203 | let i; |
| 204 | while ((i = rbuf.indexOf("\n")) !== -1) { |
| 205 | const msg = JSON.parse(rbuf.slice(0, i)); |
| 206 | rbuf = rbuf.slice(i + 1); |
| 207 | replies.set(msg.id, msg); |
| 208 | } |
| 209 | }); |
| 210 | const send = async (id, toolName, args = {}) => { |
| 211 | child.stdin.write(b64({ id, tool: toolName, args }) + "\n"); |
| 212 | const deadline = Date.now() + 10_000; |
| 213 | while (!replies.has(id) && Date.now() < deadline) await new Promise((r) => setTimeout(r, 10)); |
| 214 | assert.ok(replies.has(id), `no reply for ${toolName}`); |
| 215 | return replies.get(id); |
| 216 | }; |
| 217 | assert.equal((await send(1, "platform")).ok, true); |
| 218 | const open = await send(2, "open_application", { name: "FakeApp" }); |
| 219 | assert.equal(open.ok, true, JSON.stringify(open)); |
| 220 | const typed = await send(3, "type", { text: "hi" }); |
| 221 | assert.equal(typed.ok, true); |
| 222 | assert.equal(typed.data.bound_app, "FakeApp", "the second request reused the first request's binding"); |
| 223 | // Held input is no longer blanket-refused in persistent mode: it reaches |
| 224 | // the backend, which the fixture answers. |
| 225 | const held = await send(4, "left_mouse_down", { target: { type: "coordinate", x: 5, y: 5 } }); |
| 226 | assert.equal(held.ok, true, JSON.stringify(held)); |
| 227 | const refused = await send(5, "shell_escape"); |
| 228 | assert.equal(refused.ok, false); |
| 229 | assert.equal(refused.error.code, "tool_not_allowed"); |
| 230 | }); |
| 231 | |
| 232 | test("one-shot agent still refuses operations that need a persistent session", async () => { |
| 233 | const out = spawn("node", [path.join(ROOT, "agent.mjs"), b64({ tool: "left_mouse_down", args: {} })], { |
| 234 | env: { ...process.env, CODEWHALE_CU_TEST_BACKEND: FAKE }, |
| 235 | stdio: ["ignore", "pipe", "pipe"], |
| 236 | }); |
| 237 | let stdout = ""; |
| 238 | out.stdout.setEncoding("utf8").on("data", (d) => { stdout += d; }); |
| 239 | await new Promise((resolve) => out.once("close", resolve)); |
| 240 | const reply = JSON.parse(stdout.trim().split("\n").pop()); |
| 241 | assert.equal(reply.ok, false); |
| 242 | assert.equal(reply.error.code, "persistent_session_required"); |
| 243 | }); |
| 244 | |
| 245 | test("channelRequest correlates replies and survives out-of-order completion", async t => { |
| 246 | const binding = {}; |
| 247 | const ch = ensureSshChannel(binding, ["node", path.join(ROOT, "agent.mjs"), "--serve"]); |
| 248 | t.after(() => { try { ch.proc?.kill("SIGKILL"); } catch {} }); |
| 249 | // Override spawn is not needed here: the channel factory takes argv, so a |
| 250 | // local agent process stands in for `ssh host node agent --serve`. |
| 251 | assert.equal(ch.alive, true); |
| 252 | const p1 = channelRequest(ch, { tool: "platform" }, 10_000); |
| 253 | const p2 = channelRequest(ch, { tool: "not_a_tool" }, 10_000); |
| 254 | const [r1, r2] = await Promise.all([p1, p2]); |
| 255 | assert.equal(r1.ok, true); |
| 256 | assert.equal(r2.ok, false); |
| 257 | assert.equal(r2.error.code, "tool_not_allowed"); |
| 258 | ch.proc.kill("SIGKILL"); |
| 259 | await assert.rejects(channelRequest(ch, { tool: "platform" }, 500), (e) => e.code === "remote_session_lost"); |
| 260 | }); |
| 261 |