Files
vectorcraft/crates/mcp/src/tests.rs
T

385 lines
17 KiB
Rust

use std::io::{BufRead, BufReader, Write};
use serde_json::{Value, json};
use crate::{Backend, Headless, PROTOCOL_VERSION, Remote, Server, tool_definitions};
fn server() -> Server {
Server::new(Box::new(Headless::with_document()))
}
fn rpc(s: &mut Server, id: u64, method: &str, params: Value) -> Value {
let line = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string();
let reply = s.handle_line(&line).expect("reply");
let v: Value = serde_json::from_str(&reply).expect("reply is JSON");
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], id);
v
}
fn call(s: &mut Server, id: u64, name: &str, args: Value) -> Value {
let v = rpc(s, id, "tools/call", json!({"name": name, "arguments": args}));
assert!(v.get("error").is_none(), "{v}");
v["result"].clone()
}
fn text_of(result: &Value) -> String {
result["content"].as_array().unwrap().iter().filter(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).collect()
}
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-mcp-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
// ---------- framing ----------
#[test]
fn framing_basics() {
let mut s = server();
// Blank lines and notifications produce no output.
assert_eq!(s.handle_line(" "), None);
assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#), None);
assert!(s.is_initialized());
// Parse error → -32700 with null id.
let v: Value = serde_json::from_str(&s.handle_line("{not json").unwrap()).unwrap();
assert_eq!(v["error"]["code"], -32700);
assert_eq!(v["id"], Value::Null);
// Unknown method → -32601, id echoed (string ids too).
let v: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":"abc","method":"nope"}"#).unwrap()).unwrap();
assert_eq!(v["error"]["code"], -32601);
assert_eq!(v["id"], "abc");
// Missing method → invalid request.
let v: Value = serde_json::from_str(&s.handle_line(r#"{"jsonrpc":"2.0","id":3}"#).unwrap()).unwrap();
assert_eq!(v["error"]["code"], -32600);
// Responses from the client are ignored.
assert_eq!(s.handle_line(r#"{"jsonrpc":"2.0","id":9,"result":{}}"#), None);
// ping
assert_eq!(rpc(&mut s, 4, "ping", json!({}))["result"], json!({}));
// Batch (legacy clients).
let v: Value = serde_json::from_str(
&s.handle_line(r#"[{"jsonrpc":"2.0","id":1,"method":"ping"},{"jsonrpc":"2.0","method":"notifications/initialized"}]"#).unwrap(),
)
.unwrap();
assert_eq!(v.as_array().unwrap().len(), 1);
}
#[test]
fn serve_loop_writes_one_line_per_request() {
let mut s = server();
let input = [
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":PROTOCOL_VERSION,"capabilities":{},"clientInfo":{"name":"t","version":"0"}}}).to_string(),
json!({"jsonrpc":"2.0","method":"notifications/initialized"}).to_string(),
json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}).to_string(),
]
.join("\n");
let mut out = Vec::new();
s.serve(input.as_bytes(), &mut out).unwrap();
let lines: Vec<Value> = String::from_utf8(out).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["result"]["protocolVersion"], PROTOCOL_VERSION);
assert_eq!(lines[0]["result"]["serverInfo"]["name"], "vectorcraft");
assert!(lines[0]["result"]["capabilities"]["tools"].is_object());
assert!(lines[0]["result"]["capabilities"]["resources"].is_object());
assert!(lines[1]["result"]["tools"].as_array().unwrap().len() >= 19);
}
#[test]
fn initialize_negotiates_version() {
let mut s = server();
let v = rpc(&mut s, 1, "initialize", json!({"protocolVersion": "2025-03-26"}));
assert_eq!(v["result"]["protocolVersion"], "2025-03-26");
let v = rpc(&mut s, 2, "initialize", json!({"protocolVersion": "1999-01-01"}));
assert_eq!(v["result"]["protocolVersion"], PROTOCOL_VERSION);
}
// ---------- tools/list ----------
#[test]
fn tool_schemas_are_valid() {
let tools = tool_definitions();
let mut names = std::collections::HashSet::new();
for t in &tools {
let name = t["name"].as_str().expect("name");
assert!(!name.is_empty() && name.len() <= 64 && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'), "{name}");
assert!(names.insert(name), "duplicate tool {name}");
assert!(t["description"].as_str().is_some_and(|d| d.len() > 10), "{name} description");
let schema = &t["inputSchema"];
assert_eq!(schema["type"], "object", "{name}");
let props = schema["properties"].as_object().unwrap_or_else(|| panic!("{name} properties"));
for r in schema.get("required").and_then(Value::as_array).into_iter().flatten() {
assert!(props.contains_key(r.as_str().unwrap()), "{name}: required {r} not in properties");
}
for (k, p) in props {
assert!(p.is_object(), "{name}.{k}");
assert!(p.get("type").is_some() || p.get("anyOf").is_some(), "{name}.{k} has no type");
}
}
for want in [
"list_commands",
"run_command",
"inspect_document",
"inspect_ui",
"select_tool",
"pointer_gesture",
"draw_path",
"draw_shape",
"set_paint",
"press_key",
"invoke_menu",
"open_panel",
"screenshot",
"open_file",
"save_file",
"export",
"undo",
"redo",
] {
assert!(names.contains(want), "missing tool {want}");
}
}
// ---------- headless end-to-end ----------
#[test]
fn headless_end_to_end() {
let mut s = server();
rpc(&mut s, 1, "initialize", json!({"protocolVersion": PROTOCOL_VERSION}));
s.handle_line(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#);
let r = call(
&mut s,
2,
"draw_shape",
json!({"shape": "rectangle", "x": 100, "y": 100, "width": 200, "height": 120, "fill": "#ff0000", "stroke": "none"}),
);
assert_eq!(r["isError"], false, "{r}");
let created: Value = serde_json::from_str(&text_of(&r)).unwrap();
let id = created["id"].as_u64().expect("id");
let r = call(
&mut s,
3,
"draw_shape",
json!({"shape": "star", "cx": 400, "cy": 400, "radius1": 80, "radius2": 40, "fill": [0, 0, 1], "strokeWidth": 3}),
);
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 4, "inspect_document", json!({}));
let doc: Value = serde_json::from_str(&text_of(&r)).unwrap();
assert!(doc["objects"].as_u64().unwrap() >= 3, "{doc}");
let layer = &doc["layers"][0];
let rect = layer["children"].as_array().unwrap().iter().find(|c| c["id"] == id).expect("rect in layer");
assert_eq!(rect["bounds"]["width"], 200.0);
assert_eq!(rect["fill"], "#ff0000", "{rect}");
assert_eq!(rect["stroke"], "None");
// Screenshot returns image content (base64 PNG) and text.
let shot_path = tmp("shot.png");
let r = call(&mut s, 5, "screenshot", json!({"path": shot_path.to_str().unwrap()}));
assert_eq!(r["isError"], false, "{r}");
let img = r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").expect("image content");
assert_eq!(img["mimeType"], "image/png");
let png = vectorcraft_format::base64_decode(img["data"].as_str().unwrap()).unwrap();
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
assert_eq!(std::fs::read(&shot_path).unwrap(), png);
// Export SVG.
let svg_path = tmp("out.svg");
let r = call(&mut s, 6, "export", json!({"path": svg_path.to_str().unwrap()}));
assert_eq!(r["isError"], false, "{r}");
let svg = std::fs::read_to_string(&svg_path).unwrap();
assert!(svg.contains("<svg") && (svg.contains("<rect") || svg.contains("<path")), "{svg}");
// Save native, open it back.
let dc = tmp("out.vectorcraft");
assert_eq!(call(&mut s, 7, "save_file", json!({"path": dc.to_str().unwrap()}))["isError"], false);
let r = call(&mut s, 8, "open_file", json!({"path": dc.to_str().unwrap()}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 9, "open_file", json!({"path": svg_path.to_str().unwrap()}));
assert_eq!(r["isError"], false, "{r}");
// Resources.
let v = rpc(&mut s, 10, "resources/list", json!({}));
assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 2);
let v = rpc(&mut s, 11, "resources/read", json!({"uri": "vectorcraft://document"}));
let text = v["result"]["contents"][0]["text"].as_str().unwrap();
assert!(serde_json::from_str::<Value>(text).unwrap()["layers"].is_array());
let v = rpc(&mut s, 12, "resources/read", json!({"uri": "vectorcraft://document/json"}));
assert!(v["result"]["contents"][0]["text"].as_str().unwrap().len() > 10);
let v = rpc(&mut s, 13, "resources/read", json!({"uri": "vectorcraft://nope"}));
assert_eq!(v["error"]["code"], -32002);
}
#[test]
fn headless_path_gesture_undo() {
let mut s = server();
let count = |s: &mut Server| -> u64 {
let r = call(s, 90, "inspect_document", json!({}));
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["objects"].as_u64().unwrap()
};
let base = count(&mut s);
let r = call(&mut s, 1, "draw_path", json!({"points": [[10, 10], [100, 10], [50, 90]], "closed": true, "fill": "#00ff00"}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 2, "draw_path", json!({"d": "M0 0 C 10 10 20 10 30 0", "stroke": "#000000", "strokeWidth": 2}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(count(&mut s), base + 2);
// Drag out an ellipse with the ellipse tool.
let r = call(
&mut s,
3,
"pointer_gesture",
json!({"tool": "ellipse", "events": [{"kind": "down", "x": 200, "y": 200}, {"kind": "drag", "x": 260, "y": 240}, {"kind": "up", "x": 300, "y": 280}]}),
);
assert_eq!(r["isError"], false, "{r}");
assert_eq!(count(&mut s), base + 3);
assert_eq!(call(&mut s, 4, "undo", json!({}))["isError"], false);
assert_eq!(count(&mut s), base + 2);
assert_eq!(call(&mut s, 5, "redo", json!({}))["isError"], false);
assert_eq!(count(&mut s), base + 3);
// Keyboard shortcut headless: Cmd+Z undoes.
let r = call(&mut s, 6, "press_key", json!({"key": "Z", "mods": {"cmd": true}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(count(&mut s), base + 2);
// Long tail through run_command + list_commands filter.
let r = call(&mut s, 7, "run_command", json!({"command": "select.all"}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 8, "run_command", json!({"command": "object.group"}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 9, "list_commands", json!({"filter": "group"}));
let v: Value = serde_json::from_str(&text_of(&r)).unwrap();
assert!(v["commands"].as_array().unwrap().iter().any(|c| c["id"] == "object.group"));
assert!(
v["commands"]
.as_array()
.unwrap()
.iter()
.all(|c| c["id"].as_str().unwrap().contains("group") || c["label"].as_str().unwrap().to_lowercase().contains("group"))
);
// set_paint on the selection.
let r = call(&mut s, 10, "set_paint", json!({"fill": "#123456", "strokeWidth": 4}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 11, "select_tool", json!({"tool": "pen"}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 12, "export", json!({"path": tmp("x.png").to_str().unwrap(), "scale": 0.5}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(&std::fs::read(tmp("x.png")).unwrap()[..4], b"\x89PNG");
}
#[test]
fn errors_are_tool_results_not_crashes() {
let mut s = server();
let cases = [
("no_such_tool", json!({})),
("draw_shape", json!({"shape": "hexagon"})),
("draw_shape", json!({"shape": "rectangle", "x": "left"})),
("draw_shape", json!({})),
("draw_path", json!({"points": [[1]]})),
("draw_path", json!({})),
("run_command", json!({"command": "does.not.exist"})),
("run_command", json!({"command": "object.move", "params": 5})),
("run_command", json!({})),
("pointer_gesture", json!({"events": [{"kind": "wiggle", "x": 0, "y": 0}]})),
("pointer_gesture", json!({"events": []})),
("select_tool", json!({"tool": "laser"})),
("set_paint", json!({})),
("set_paint", json!({"fill": "#zzzzzz"})),
("inspect_ui", json!({})),
("open_panel", json!({"panel": "Layers"})),
("type_text", json!({"text": "hi"})),
("screenshot", json!({"window": true})),
("open_file", json!({"path": "/definitely/not/here.svg"})),
("export", json!({"path": "/tmp/out.bmp"})),
("redo", json!({})),
("press_key", json!({"key": "F13"})),
];
for (i, (name, args)) in cases.iter().enumerate() {
let r = call(&mut s, i as u64, name, args.clone());
assert_eq!(r["isError"], true, "{name} {args} → {r}");
assert!(!text_of(&r).is_empty());
}
// Arguments that aren't an object.
let v = rpc(&mut s, 100, "tools/call", json!({"name": "undo", "arguments": [1, 2]}));
assert_eq!(v["result"]["isError"], true);
// Missing name is a protocol error.
let v = rpc(&mut s, 101, "tools/call", json!({}));
assert_eq!(v["error"]["code"], -32602);
// The server still works afterwards.
let r = call(&mut s, 102, "inspect_document", json!({}));
assert_eq!(r["isError"], false);
}
// ---------- remote ----------
/// A fake control server: answers `document.inspect`, echoes `engine.execute`, errors otherwise.
fn fake_app() -> (String, std::thread::JoinHandle<Vec<Value>>) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap().to_string();
let h = std::thread::spawn(move || {
let (stream, _) = listener.accept().unwrap();
let mut out = stream.try_clone().unwrap();
let mut seen = vec![];
for line in BufReader::new(stream).lines() {
let msg: Value = serde_json::from_str(&line.unwrap()).unwrap();
let reply = match msg["method"].as_str().unwrap() {
"document.inspect" => json!({"ok": true, "result": {"title": "Remote", "layers": []}}),
"engine.execute" => json!({"ok": true, "result": {"echo": msg["params"]}}),
"ui.inspect" => json!({"ok": true, "result": {"tool": "selection"}}),
_ => json!({"ok": false, "error": "nope"}),
};
let mut reply = reply;
reply["id"] = msg["id"].clone();
writeln!(out, "{reply}").unwrap();
seen.push(msg);
}
seen
});
(addr, h)
}
#[test]
fn remote_forwards_methods() {
let (addr, h) = fake_app();
let mut s = Server::new(Box::new(Remote::connect(&addr).unwrap()));
let r = call(&mut s, 1, "inspect_document", json!({}));
assert!(text_of(&r).contains("Remote"));
let r = call(&mut s, 2, "run_command", json!({"command": "object.group"}));
assert!(text_of(&r).contains("object.group"), "{r}");
let r = call(&mut s, 3, "inspect_ui", json!({}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 4, "open_panel", json!({"panel": "Layers"}));
assert_eq!(r["isError"], true);
assert!(text_of(&r).contains("nope"));
drop(s);
let seen = h.join().unwrap();
let methods: Vec<&str> = seen.iter().map(|m| m["method"].as_str().unwrap()).collect();
assert_eq!(methods, ["document.inspect", "engine.execute", "ui.inspect", "ui.set"]);
assert_eq!(seen[1]["params"], json!({"command": "object.group", "params": {}}));
}
#[test]
fn remote_connect_fails_fast() {
// Bind then drop to get a port that's (almost certainly) closed.
let port = std::net::TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port();
assert!(Remote::connect(&format!("127.0.0.1:{port}")).is_err());
}
#[test]
fn headless_backend_methods() {
let mut h = Headless::new();
assert!(h.call("document.inspect", json!({})).is_err(), "no document yet");
h.call("engine.execute", json!({"command": "file.new", "params": {"width": 300, "height": 200}})).unwrap();
let r = h.call("ui.render", json!({"scale": 2})).unwrap();
assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(600), Some(400)));
let cmds = h.call("engine.commands", json!({})).unwrap();
assert!(cmds.as_array().unwrap().iter().any(|c| c["id"] == "file.export"));
assert!(h.call("ui.tool.list", json!({})).unwrap().is_array());
assert!(h.call("ui.resize", json!({})).is_err());
}