返回 CodeWhale
wait-for.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / wait-for.test.mjs
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
261 lines Plain Text