mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 10:17:02 -06:00
385 lines
17 KiB
Rust
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());
|
|
}
|