| 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 |