返回 CodeWhale
smoke.mjs
1 // Live smoke test: drives the real MCP server end-to-end on this machine.
2 // Policy: no destructive input (no clicks/typing into the user's session),
3 // no clipboard access, isolated state + recordings dirs.
4 import { spawn } from "node:child_process";
5 import fs from "node:fs";
6 import os from "node:os";
7 import path from "node:path";
8 import url from "node:url";
9
10 const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
11 const ROOT = path.resolve(__dirname, "..");
12 const results = [];
13
14 function log(name, pass, detail = "") {
15 results.push({ name, pass, detail: String(detail).slice(0, 500) });
16 console.log(`${pass ? "PASS" : "FAIL"} ${name}${detail ? ` — ${detail}` : ""}`);
17 }
18
19 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-smoke-state-"));
20 const recDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-smoke-rec-"));
21
22 const server = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], {
23 env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: recDir },
24 stdio: ["pipe", "pipe", "pipe"],
25 });
26 let buf = "";
27 const pending = new Map();
28 let nextId = 1;
29 server.stdout.setEncoding("utf8");
30 server.stdout.on("data", (d) => {
31 buf += d;
32 let i;
33 while ((i = buf.indexOf("\n")) !== -1) {
34 const line = buf.slice(0, i).trim();
35 buf = buf.slice(i + 1);
36 if (!line) continue;
37 try {
38 const msg = JSON.parse(line);
39 if (msg.id && pending.has(msg.id)) {
40 pending.get(msg.id)(msg);
41 pending.delete(msg.id);
42 }
43 } catch {}
44 }
45 });
46 server.stderr.on("data", (d) => process.stderr.write(`[server] ${d}`));
47
48 function rpc(method, params, timeoutMs = 120_000) {
49 const id = nextId++;
50 return new Promise((resolve, reject) => {
51 const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout waiting for ${method}`)); }, timeoutMs);
52 pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
53 server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
54 });
55 }
56
57 async function tool(name, args = {}) {
58 const res = await rpc("tools/call", { name, arguments: args });
59 const text = res.result?.content?.[0]?.text ?? "{}";
60 let parsed = null;
61 try { parsed = JSON.parse(text); } catch {}
62 return { raw: res.result, parsed, isError: res.result?.isError === true };
63 }
64
65 try {
66 // --- protocol ---
67 const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {} });
68 log("initialize", !!init.result?.serverInfo?.name, `server=${init.result?.serverInfo?.name} v${init.result?.serverInfo?.version}`);
69 await rpc("notifications/initialized", undefined, 5_000).catch(() => {});
70 const tl = await rpc("tools/list", {});
71 const tools = tl.result?.tools ?? [];
72 log("tools/list", tools.length >= 37, `${tools.length} tools`);
73 const names = new Set(tools.map((t) => t.name));
74 // tools/list advertises the merged surface; the per-action wire names stay
75 // callable but are not listed, so this check names the merged tools.
76 for (const required of ["screenshot", "recording", "computer", "get_app_state", "click", "type", "key", "scroll", "zoom", "app_script", "stop_computer_control"]) {
77 if (!names.has(required)) log(`schema:${required}`, false, "missing");
78 }
79 log("schema:required-tools-present", true, "all key tools declared");
80
81 // --- registry / switching ---
82 let r = await tool("computer_list");
83 log("computer_list", r.parsed?.ok === true && r.parsed?.computers?.length >= 1, `active=${r.parsed?.active}`);
84
85 r = await tool("computer_register", { computer: "pad", transport: "hdc", label: "Harmony device" });
86 log("computer_register(hdc)", r.parsed?.ok === true, JSON.stringify(r.parsed?.registered ?? r.parsed?.error));
87
88 r = await tool("computer_switch", { computer: "pad" });
89 log("computer_switch", r.parsed?.ok === true && r.parsed?.active === "pad", `active=${r.parsed?.active}`);
90
91 r = await tool("list_apps");
92 log("harmony fail-closed (no hdc device)", r.parsed?.ok === false, `code=${r.parsed?.error?.code}`);
93
94 r = await tool("screenshot", { computer: "local" });
95 log("switch-by-use (computer:local on screenshot)", r.parsed?.ok === true && r.parsed?.switched === true && r.parsed?.computer?.id === "local", `file=${path.basename(r.parsed?.file ?? "")}`);
96
97 // --- local darwin live tools ---
98 r = await tool("request_access");
99 log("request_access/probe", r.parsed?.ok === true, `accessibility=${r.parsed?.permissions?.accessibility} capture=${r.parsed?.permissions?.screen_capture}`);
100
101 r = await tool("list_displays");
102 log("list_displays", r.parsed?.ok === true && r.parsed?.items?.length >= 1, JSON.stringify(r.parsed?.items?.[0] ?? r.parsed?.error));
103
104 r = await tool("list_apps");
105 log("list_apps", r.parsed?.ok === true && r.parsed?.apps?.length > 0, `${r.parsed?.apps?.length} apps`);
106
107 // No backend reports windowCount today, so fall back to the app in front —
108 // it is the one guaranteed to have a window worth observing.
109 const apps = r.parsed?.apps ?? [];
110 const someApp = apps.find((a) => a.windowCount > 0) ?? apps.find((a) => a.frontmost) ?? apps[0];
111 if (someApp) {
112 // The consent ledger gates first app contact on the local computer —
113 // smoke exercises the real flow: refuse, record the user's allow, retry.
114 const gated = await tool("get_app_state", { app_ref: { pid: someApp.pid } });
115 log("consent_required on first app contact", gated.parsed?.error?.code === "consent_required", `code=${gated.parsed?.error?.code}`);
116 const c = await tool("consent", { action: "allow", app: `pid:${someApp.pid}` });
117 log("consent allow", c.parsed?.ok === true, `${someApp.name}: keys=${JSON.stringify(c.parsed?.keys ?? c.parsed?.error)}`);
118 const st = await tool("get_app_state", { app_ref: { pid: someApp.pid } });
119 log("get_app_state", st.parsed?.ok === true && st.parsed?.elements?.length > 0, `${someApp.name}: ${st.parsed?.elements?.length} elements, state_id=${st.parsed?.state_id}`);
120 globalThis.__state = st.parsed;
121 } else {
122 log("get_app_state", false, "list_apps returned no applications to observe");
123 }
124
125 r = await tool("cursor_position");
126 log("cursor_position", r.parsed?.ok === true && Number.isFinite(r.parsed?.x), `x=${r.parsed?.x} y=${r.parsed?.y}`);
127
128 r = await tool("screenshot", {});
129 const shotFile = r.parsed?.file;
130 const shotOk = r.parsed?.ok === true && shotFile && fs.existsSync(shotFile) && fs.statSync(shotFile).size > 0;
131 log("screenshot (real file)", shotOk, `${shotFile ? path.basename(shotFile) : "?"} ${r.parsed?.bytes ?? 0}B scale=${r.parsed?.scale}`);
132
133 if (shotOk) {
134 const disp = r.parsed?.points;
135 const w = Math.min(400, disp?.w ?? 400), h = Math.min(300, disp?.h ?? 300);
136 r = await tool("zoom", { region: [0, 0, w, h] });
137 log("zoom", r.parsed?.ok === true && r.parsed?.file && fs.existsSync(r.parsed.file), `${path.basename(r.parsed?.file ?? "")} ${r.parsed?.bytes ?? 0}B`);
138 }
139
140 // --- recording ---
141 r = await tool("recording_start", {});
142 const recId = r.parsed?.id;
143 const started = r.parsed?.ok === true && !!recId;
144 log("recording_start", started, `id=${recId} mode=${r.parsed?.mode} pid=${r.parsed?.pid}`);
145 if (started) {
146 await new Promise((res) => setTimeout(res, 2500));
147 r = await tool("recording_status", { id: recId });
148 log("recording_status", r.parsed?.ok === true && r.parsed?.running === true, `running=${r.parsed?.running} bytes-so-far=${r.parsed?.bytes} (bytes land at stop)`);
149 r = await tool("recording_stop", { id: recId });
150 const stopped = r.parsed?.ok === true && r.parsed?.file && fs.existsSync(r.parsed.file) && fs.statSync(r.parsed.file).size > 1000;
151 log("recording_stop (real file)", stopped, `${path.basename(r.parsed?.file ?? "")} + mp4:${r.parsed?.mp4 ? "yes" : "no"} bytes=${r.parsed?.bytes}`);
152 }
153 r = await tool("recording_list");
154 log("recording_list", r.parsed?.ok === true && r.parsed?.recordings?.length >= 1, `${r.parsed?.recordings?.length} artifacts in ${r.parsed?.dir}`);
155
156 // --- unknown tool + error shape ---
157 r = await tool("no_such_tool");
158 log("unknown tool fails closed", r.isError === true && r.parsed?.error?.code === "unknown_tool", `code=${r.parsed?.error?.code}`);
159
160 // --- kill switch ---
161 r = await tool("stop_computer_control", { reason: "smoke" });
162 log("stop_computer_control", r.parsed?.ok === true && r.parsed?.stopped === true);
163 r = await tool("list_apps");
164 log("actions refused after kill switch", r.isError === true && r.parsed?.error?.code === "control_stopped", `code=${r.parsed?.error?.code}`);
165 r = await tool("computer_list");
166 log("read-only still allowed after kill switch", r.parsed?.ok === true);
167 } catch (err) {
168 log("smoke-run", false, err.stack ?? err.message);
169 } finally {
170 server.kill("SIGTERM");
171 const receipt = { at: new Date().toISOString(), host: `${process.platform} ${os.release()}`, results };
172 const outDir = path.join(ROOT, "receipts");
173 fs.mkdirSync(outDir, { recursive: true });
174 const out = path.join(outDir, `smoke-${new Date().toISOString().replace(/[:.]/g, "-")}.json`);
175 fs.writeFileSync(out, JSON.stringify(receipt, null, 2));
176 const failed = results.filter((r) => !r.pass);
177 console.log(`\n${results.length - failed.length}/${results.length} passed. Receipt: ${out}`);
178 try { fs.rmSync(stateDir, { recursive: true, force: true }); } catch {}
179 process.exit(failed.length ? 1 : 0);
180 }
181
181 lines Plain Text