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 = 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("(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::(&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>) { 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()); }