返回 CodeWhale
mcp-skills.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / mcp-skills.test.mjs
1 // Skill pack over MCP (resources + skills methods), tool annotations, and the
2 // pure helpers behind list_apps filtering, menu targeting and native error
3 // codes. The protocol part spawns the real server; state is isolated.
4 import { test, before, after } from "node:test";
5 import assert from "node:assert/strict";
6 import { spawn } from "node:child_process";
7 import crypto from "node:crypto";
8 import fs from "node:fs";
9 import os from "node:os";
10 import path from "node:path";
11 import url from "node:url";
12 import { TOOLS } from "../src/tools.mjs";
13 import { pickMenuElement, selectApps, nativeErrorCode } from "../src/backends/darwin.mjs";
14
15 const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
16 const ROOT = path.resolve(__dirname, "..");
17
18 // ---------- pure helpers ----------
19
20 test("every tool carries MCP annotations, and the observation/action split holds", () => {
21 assert.equal(TOOLS.length, TOOLS.filter((t) => t.annotations).length, "a tool without annotations would let a host guess");
22 for (const t of TOOLS) {
23 for (const key of ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"]) {
24 assert.equal(typeof t.annotations[key], "boolean", `${t.name}.annotations.${key} must be a boolean`);
25 }
26 }
27 const byName = Object.fromEntries(TOOLS.map((t) => [t.name, t.annotations]));
28 assert.equal(byName.get_app_state.readOnlyHint, true);
29 assert.equal(byName.screenshot.readOnlyHint, true);
30 assert.equal(byName.request_access.readOnlyHint, true);
31 assert.equal(byName.left_click.readOnlyHint, false);
32 assert.equal(byName.left_click.destructiveHint, true);
33 assert.equal(byName.stop_computer_control.readOnlyHint, false);
34 assert.equal(byName.invoke_menu.readOnlyHint, false);
35 assert.equal(byName.list_apps.readOnlyHint, true);
36 });
37
38 test("invoke_menu and list_apps advertise their new surfaces", () => {
39 const menu = TOOLS.find((t) => t.name === "invoke_menu");
40 assert.ok(menu, "invoke_menu is part of the surface");
41 assert.deepEqual(menu.inputSchema.required, ["path"]);
42 assert.equal(menu.inputSchema.properties.path.maxItems, 3);
43 const apps = TOOLS.find((t) => t.name === "list_apps");
44 assert.equal(apps.inputSchema.properties.all.type, "boolean");
45 });
46
47 test("selectApps keeps regular apps by default, passes everything with all:true, and tolerates an old helper", () => {
48 const apps = [
49 { name: "Finder", activation_policy: "regular" },
50 { name: "Terminal", activation_policy: "regular", frontmost: true },
51 { name: "chmod", activation_policy: "prohibited" },
52 { name: "SwiftBar", activation_policy: "accessory" },
53 ];
54 assert.deepEqual(selectApps(apps, false).map((a) => a.name), ["Finder", "Terminal"]);
55 assert.equal(selectApps(apps, true).length, 4);
56 // A pre-0.6.2 helper does not report the field: return the list whole
57 // rather than hiding every app.
58 const legacy = [{ name: "Finder" }, { name: "chmod" }];
59 assert.equal(selectApps(legacy, false).length, 2);
60 });
61
62 test("pickMenuElement matches exact titles and roles only", () => {
63 const els = [
64 { role: "AXMenuBarItem", label: "File", path: [3] },
65 { role: "AXMenuItem", label: "New Window", path: [3, 0, 1] },
66 { role: "AXMenuItem", label: "New", path: [3, 0, 0] },
67 ];
68 assert.equal(pickMenuElement(els, "File", true)?.label, "File");
69 assert.equal(pickMenuElement(els, "New", false)?.path[2], 0, "exact match must not take \u201cNew Window\u201d");
70 assert.equal(pickMenuElement(els, "Open\u2026", true), null, "no fuzzy matches");
71 assert.equal(pickMenuElement(els, "File", false), null, "role must match the level");
72 });
73
74 test("nativeErrorCode maps refusal reasons to stable codes", () => {
75 assert.equal(nativeErrorCode("the selected window is ambiguous; observe the app windows again"), "window_ambiguous");
76 assert.equal(nativeErrorCode("the selected app window is not capturable; observe the app windows again"), "window_not_capturable");
77 assert.equal(nativeErrorCode("application not found"), "app_not_found");
78 assert.equal(nativeErrorCode("no running application with pid 42"), "app_not_found");
79 assert.equal(nativeErrorCode("accessibility action failed: -25205"), null);
80 });
81
82 // ---------- protocol: the bundled skill pack ----------
83
84 const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "cu-skill-state-"));
85 let server;
86 let buf = "";
87 const pending = new Map();
88 let nextId = 1;
89
90 function rpc(method, params) {
91 const id = nextId++;
92 return new Promise((resolve, reject) => {
93 const t = setTimeout(() => { pending.delete(id); reject(new Error(`timeout: ${method}`)); }, 15_000);
94 pending.set(id, (msg) => { clearTimeout(t); resolve(msg); });
95 server.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n");
96 });
97 }
98
99 before(() => {
100 server = spawn("node", [path.join(ROOT, "mcp", "server.mjs")], {
101 env: { ...process.env, CODEWHALE_CU_STATE_DIR: stateDir, CODEWHALE_CU_RECORDINGS_DIR: path.join(stateDir, "rec") },
102 stdio: ["pipe", "pipe", "pipe"],
103 });
104 server.stdout.on("data", (c) => {
105 buf += c.toString();
106 let i;
107 while ((i = buf.indexOf("\n")) !== -1) {
108 const line = buf.slice(0, i).trim();
109 buf = buf.slice(i + 1);
110 if (!line) continue;
111 const msg = JSON.parse(line);
112 if (msg.id != null && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); }
113 }
114 });
115 });
116
117 after(() => { try { server.stdin.end(); } catch {} server?.kill("SIGTERM"); });
118
119 test("initialize advertises resources and the skills extension", async () => {
120 const init = await rpc("initialize", { protocolVersion: "2025-06-18", capabilities: {} });
121 assert.equal(init.result.capabilities.resources.listChanged, false);
122 assert.ok(init.result.capabilities.experimental["io.modelcontextprotocol/skills"], "the skills extension is advertised");
123 });
124
125 test("resources/list names the pack; resources/read returns exact bytes with hashes", async () => {
126 const list = await rpc("resources/list", {});
127 const uris = list.result.resources.map((r) => r.uri);
128 assert.ok(uris.includes("skill://codewhale-cu/SKILL.md"));
129 assert.ok(uris.includes("skill://codewhale-cu/references/quick-reference.md"));
130 assert.ok(uris.includes("skill://codewhale-cu/references/refusal-codes.md"));
131
132 for (const uri of uris) {
133 const read = await rpc("resources/read", { uri });
134 const text = read.result.contents[0].text;
135 const rel = uri.replace("skill://codewhale-cu/", "");
136 const onDisk = fs.readFileSync(path.join(ROOT, "skills", "computer-use", rel), "utf8");
137 assert.equal(text, onDisk, `${uri} must serve exactly the file on disk`);
138 }
139 });
140
141 test("resources/read refuses unknown URIs with invalid-params, never a traversal", async () => {
142 const bad = await rpc("resources/read", { uri: "skill://codewhale-cu/../../../etc/passwd" });
143 assert.equal(bad.error.code, -32602);
144 const alsoBad = await rpc("resources/read", { uri: "file:///etc/passwd" });
145 assert.equal(alsoBad.error.code, -32602);
146 });
147
148 test("skills/list and skills/get carry the manifest with matching sha256 digests", async () => {
149 const skills = await rpc("skills/list", {});
150 const entry = skills.result.skills[0];
151 assert.equal(entry.name, "computer-use");
152 assert.ok(entry.description.length > 40, "the description comes from SKILL.md frontmatter");
153 assert.equal(entry.files.length, 3);
154
155 const got = await rpc("skills/get", { uri: "skill://codewhale-cu/SKILL.md" });
156 assert.equal(got.result.skill.frontmatter.name, "computer-use");
157 for (const file of got.result.manifest) {
158 const rel = file.uri.replace("skill://codewhale-cu/", "");
159 const bytes = fs.readFileSync(path.join(ROOT, "skills", "computer-use", rel));
160 const digest = crypto.createHash("sha256").update(bytes).digest("hex");
161 assert.equal(file.sha256, digest, `${rel} sha256 must match the bytes`);
162 assert.equal(file.bytes, bytes.length);
163 }
164 });
165
166 test("tools/list still answers after the resource methods (no dispatch regressions)", async () => {
167 const tools = await rpc("tools/list", {});
168 const names = tools.result.tools.map((t) => t.name);
169 assert.ok(names.includes("invoke_menu"));
170 assert.equal(new Set(names).size, names.length, "tool names stay unique");
171 });
172
172 lines Plain Text