Files
vectorcraft/crates/mcp/src/tests.rs
T
bflatastic 8b8fd1476b Fix #1471: making and restyling type reports text that doesn't fit its frame
text.create, text.createInPath, text.setText and text.setStyle answer with
overflow: [id…] for the area type and type on a path whose text doesn't fit (left
out when all fits), and MCP add_text with overflow: true. add_text takes area
type's fit and fitMinPercent.
2026-10-13 03:27:13 +07:00

747 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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");
// A depth-0 inspect is the skeleton: top layers with counts, nothing below them;
// a bad slice is a tool error, not a full dump.
let r = call(&mut s, 41, "inspect_document", json!({"depth": 0}));
let skel: Value = serde_json::from_str(&text_of(&r)).unwrap();
assert!(skel["layers"][0].get("children").is_none(), "{skel}");
assert_eq!(skel["layers"][0]["childCount"], layer["children"].as_array().unwrap().len());
assert_eq!(skel["artboards"], doc["artboards"]);
assert_eq!(call(&mut s, 42, "inspect_document", json!({"depth": -1}))["isError"], true);
// 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}");
// PDF and PDF-compatible .ai open headless too.
let pdf_path = tmp("out.pdf");
assert_eq!(call(&mut s, 20, "export", json!({"path": pdf_path.to_str().unwrap()}))["isError"], false);
let ai_path = tmp("out.ai");
std::fs::copy(&pdf_path, &ai_path).unwrap();
for (id, p) in [(21, &pdf_path), (22, &ai_path)] {
let r = call(&mut s, id, "open_file", json!({"path": p.to_str().unwrap()}));
assert_eq!(r["isError"], false, "{r}");
let doc: Value = serde_json::from_str(&text_of(&call(&mut s, id + 10, "inspect_document", json!({})))).unwrap();
assert!(doc["objects"].as_u64().unwrap() >= 2, "{doc}");
}
// Resources.
let v = rpc(&mut s, 10, "resources/list", json!({}));
assert_eq!(v["result"]["resources"].as_array().unwrap().len(), 3);
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");
}
/// inspect_document reads back the paint type's characters show, as add_text and set_paint give it.
#[test]
fn inspect_document_reports_the_paint_of_type() {
let mut s = server();
let r = call(&mut s, 2, "add_text", json!({"text": "HI", "x": 50, "y": 60, "size": 24, "color": "#ff0000"}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
let node = |s: &mut Server, n: u64| {
let v: Value = serde_json::from_str(&text_of(&call(s, n, "inspect_document", json!({})))).unwrap();
v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).cloned().unwrap()
};
let t = node(&mut s, 3);
assert_eq!((t["fill"].as_str(), t["stroke"].as_str()), (Some("#ff0000"), Some("None")), "{t}");
let r = call(&mut s, 4, "set_paint", json!({"fill": "#0000ff", "stroke": "#00ff00", "strokeWidth": 3, "ids": [id]}));
assert_eq!(r["isError"], false, "{r}");
let t = node(&mut s, 5);
assert_eq!((t["fill"].as_str(), t["stroke"].as_str(), t["strokeWidth"].as_f64()), (Some("#0000ff"), Some("#00ff00"), Some(3.0)), "{t}");
}
/// #1003: type made on or in a path takes add_text's `color` too, over the current fill.
#[test]
fn add_text_on_or_in_a_path_takes_its_color() {
for mode in ["onPath", "area"] {
let mut s = server();
let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 40, "y": 40, "width": 180, "height": 60, "fill": "#0000ff"}));
let path = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
let r = call(&mut s, 3, "add_text", json!({"text": "Hello", "path": path, "mode": mode, "color": "#ff0000"}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].clone();
let v: Value = serde_json::from_str(&text_of(&call(&mut s, 4, "inspect_document", json!({})))).unwrap();
let t = v["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).cloned().unwrap();
assert_eq!(t["fill"].as_str(), Some("#ff0000"), "{mode}: {t}");
}
}
/// save_file says it saves in the document's own format or the one the path's extension picks.
#[test]
fn save_file_describes_the_formats_it_writes() {
let tools = tool_definitions();
let d = tools.iter().find(|t| t["name"] == "save_file").and_then(|t| t["description"].as_str()).unwrap();
for ext in [".vectorcraft", ".vctemplate", ".pdf", ".svg", ".svgz", ".ai"] {
assert!(d.contains(ext), "{ext}: {d}");
}
assert!(d.contains("its own format"), "{d}");
}
#[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.dwg"})),
("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_does_not_replay_when_edit_reply_is_lost() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let addr = listener.local_addr().unwrap().to_string();
let h = std::thread::spawn(move || {
let timeout = std::time::Duration::from_secs(5);
let mut backend = Headless::with_document();
let base = backend.call("document.inspect", json!({})).unwrap()["objects"].as_u64().unwrap();
for connection in 0..2 {
let deadline = std::time::Instant::now() + timeout;
let stream = loop {
match listener.accept() {
Ok((stream, _)) => break stream,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock && std::time::Instant::now() < deadline => std::thread::yield_now(),
Err(e) => panic!("accept failed: {e}"),
}
};
stream.set_nonblocking(false).unwrap();
stream.set_read_timeout(Some(timeout)).unwrap();
stream.set_write_timeout(Some(timeout)).unwrap();
let mut out = stream.try_clone().unwrap();
let mut reader = BufReader::new(stream);
for _ in 0..2 {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
let msg: Value = serde_json::from_str(&line).unwrap();
let method = msg["method"].as_str().unwrap();
let result = backend.call(method, msg["params"].clone()).unwrap();
if connection == 0 {
assert_eq!(method, "engine.execute");
break;
}
let reply = json!({"id": msg["id"], "ok": true, "result": result});
writeln!(out, "{reply}").unwrap();
if method == "document.inspect" {
return result["objects"].as_u64().unwrap() - base;
}
assert_eq!(method, "engine.execute");
}
}
panic!("document.inspect was not received");
});
let mut remote = Remote::connect(&addr).unwrap();
let first = remote.call("engine.execute", json!({"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 20, "height": 20}}));
let next = remote.call("document.inspect", json!({}));
let edits = h.join().unwrap();
assert_eq!(edits, 1, "a lost reply must not duplicate the edit");
let error = first.expect_err("the lost reply must report uncertainty");
assert!(error.contains("operation may have completed"), "{error}");
assert!(next.is_ok(), "the next call must reconnect: {next:?}");
}
#[test]
fn remote_connect_fails_fast() {
// A port this test holds, bound but not listening, so connecting to it is refused. (A port
// bound and dropped could be taken by another process or test before the connect.)
let held = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None).unwrap();
held.bind(&std::net::SocketAddr::from(([127, 0, 0, 1], 0)).into()).unwrap();
let port = held.local_addr().unwrap().as_socket().unwrap().port();
let start = std::time::Instant::now();
assert!(Remote::connect(&format!("127.0.0.1:{port}")).is_err());
assert!(start.elapsed() < std::time::Duration::from_secs(5), "took {:?}", start.elapsed());
// Nothing else could listen there meanwhile.
assert!(std::net::TcpListener::bind(format!("127.0.0.1:{port}")).is_err());
drop(held);
}
#[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());
}
// ---------- workflow tools ----------
fn id_of(result: &Value) -> u64 {
let v: Value = serde_json::from_str(&text_of(result)).unwrap();
v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}"))
}
fn doc_json(s: &mut Server) -> Value {
let r = call(s, 900, "run_command", json!({"command": "document.json"}));
serde_json::from_str(&text_of(&r)).unwrap()
}
#[test]
fn text_effect_pathfinder_transform_graph_and_wrap_tools() {
let mut s = server();
// Text: point, area and on a path with an effect.
let t = id_of(&call(&mut s, 1, "add_text", json!({"text": "Hello", "x": 50, "y": 60, "size": 24})));
assert!(t > 0);
let area = id_of(&call(&mut s, 2, "add_text", json!({"text": "word ".repeat(80), "x": 40, "y": 100, "width": 300, "height": 200})));
let p = call(&mut s, 3, "draw_path", json!({"d": "M40 400 C150 300 250 500 360 400"}));
let pid = id_of(&p);
let ot = id_of(&call(&mut s, 4, "add_text", json!({"text": "On a wave", "path": pid, "mode": "onPath", "pathEffect": "skew"})));
let d = doc_json(&mut s);
assert!(d.to_string().contains("\"pathEffect\":\"skew\""), "{ot}");
// Effects: catalogue, then a drop shadow on the selection.
let cat = call(&mut s, 5, "apply_effect", json!({}));
assert!(text_of(&cat).contains("stylize.dropShadow"));
let r1 = id_of(&call(&mut s, 6, "draw_shape", json!({"shape": "rectangle", "x": 400, "y": 50, "width": 100, "height": 100})));
let r2 = id_of(&call(&mut s, 7, "draw_shape", json!({"shape": "rectangle", "x": 450, "y": 100, "width": 100, "height": 100})));
let fx = call(&mut s, 8, "apply_effect", json!({"effect": "stylize.dropShadow", "params": {"x": 4}, "ids": [r2]}));
assert!(fx.get("isError").is_none_or(|e| e == false), "{fx}");
// Pathfinder unite over two ids.
let u = call(&mut s, 9, "pathfinder", json!({"operation": "unite", "ids": [r1, r2]}));
assert!(text_of(&u).contains("ids"), "{u}");
// Transform: move + rotate on the selection.
let tr = call(&mut s, 10, "transform", json!({"dx": 10, "rotate": 45}));
assert!(text_of(&tr).contains("rotate"));
let bad = call(&mut s, 11, "transform", json!({}));
assert_eq!(bad["isError"], true);
// Graph.
let g = id_of(&call(&mut s, 12, "create_graph", json!({"type": "pie", "x": 50, "y": 450, "width": 200, "height": 150, "csv": ",A,B\nX,1,3"})));
assert!(doc_json(&mut s).to_string().contains("\"kind\":\"pie\""), "{g}");
// Text wrap: a circle over the area type.
let c = id_of(&call(&mut s, 13, "draw_shape", json!({"shape": "ellipse", "x": 40, "y": 120, "width": 120, "height": 120})));
let w = call(&mut s, 14, "text_wrap", json!({"ids": [c], "offset": 8}));
assert!(w.get("isError").is_none_or(|e| e == false), "{w}");
let d = doc_json(&mut s);
let ds = d.to_string();
// The wrap object carries the options and the area type resolved its shape.
assert!(ds.contains("\"wrap\":{\"invert\":false,\"offset\":8.0}") && ds.contains("\"wrap\":[{\"invert\":false,\"offset\":8.0"), "{area}");
call(&mut s, 15, "text_wrap", json!({"ids": [c], "release": true}));
assert!(!doc_json(&mut s).to_string().contains("\"offset\":8.0"));
}
#[test]
fn oversized_screenshot_and_png_export_are_errors() {
// 16383 pt at the maximum scale (16) is 262 128 px a side: an error result, not a crashed server.
let mut s = server();
let r = call(&mut s, 1, "run_command", json!({"command": "file.new", "params": {"width": 16383, "height": 16383}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 2, "screenshot", json!({"scale": 16}));
assert_eq!(r["isError"], true, "{r}");
assert!(text_of(&r).contains("pixels"), "{r}");
let path = tmp("huge.png");
let r = call(&mut s, 3, "export", json!({"path": path.to_str().unwrap(), "scale": 16}));
assert_eq!(r["isError"], true, "{r}");
assert!(!path.exists());
// The session is still alive and a small screenshot works.
let r = call(&mut s, 4, "screenshot", json!({"scale": 0.01}));
assert_eq!(r["isError"], false, "{r}");
}
/// Preferences reach agents' gestures (#394): `prefs.set` Object Selection by Path Only, then a
/// click inside a filled square selects nothing; Command Click to Select Objects Behind, a
/// Cmd-click selects the square underneath.
#[test]
fn selection_preferences_apply_to_pointer_gestures() {
let mut s = server();
let square = |s: &mut Server, id: u64, x: u64| {
let r = call(s, id, "draw_shape", json!({"shape": "rectangle", "x": x, "y": 100, "width": 100, "height": 100, "fill": "#ff0000"}));
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].as_u64().unwrap()
};
let (back, front) = (square(&mut s, 1, 100), square(&mut s, 2, 150));
let click = |s: &mut Server, id: u64, x: f64, mods: Value| {
let events = json!([{"kind": "down", "x": x, "y": 125}, {"kind": "up", "x": x, "y": 125}]);
let r = call(s, id, "pointer_gesture", json!({"tool": "selection", "events": events, "mods": mods}));
assert_eq!(r["isError"], false, "{r}");
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"].clone()
};
assert_eq!(click(&mut s, 3, 175.0, json!({})), json!([front]));
assert_eq!(click(&mut s, 4, 175.0, json!({"cmd": true})), json!([back]), "Cmd-click selects behind");
let r = call(&mut s, 5, "run_command", json!({"command": "prefs.set", "params": {"key": "objectSelectionByPathOnly", "value": true}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(click(&mut s, 6, 225.0, json!({})), json!([]), "path only: the fill doesn't select");
assert_eq!(click(&mut s, 7, 250.0, json!({})), json!([front]), "the path does");
}
/// A Shift-drag marquee reaches agents (#483): `pointer_gesture` with `mods.shift` toggles the
/// objects it reaches, so a selected one leaves the selection and an unselected one joins it.
#[test]
fn shift_marquee_gesture_toggles_the_selection() {
let mut s = server();
let square = |s: &mut Server, id: u64, x: u64| {
let r = call(s, id, "draw_shape", json!({"shape": "rectangle", "x": x, "y": 100, "width": 50, "height": 50}));
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].as_u64().unwrap()
};
let (a, b, c) = (square(&mut s, 1, 100), square(&mut s, 2, 200), square(&mut s, 3, 300));
let r = call(&mut s, 4, "run_command", json!({"command": "select.set", "params": {"ids": [a, b]}}));
assert_eq!(r["isError"], false, "{r}");
let events = json!([{"kind": "down", "x": 180, "y": 80}, {"kind": "drag", "x": 300, "y": 200}, {"kind": "up", "x": 380, "y": 200}]);
for tool in ["selection", "directSelection"] {
let r = call(&mut s, 5, "pointer_gesture", json!({"tool": tool, "events": events, "mods": {"shift": true}}));
assert_eq!(r["isError"], false, "{r}");
let sel = &serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"];
// The Selection tool takes b out and adds c; Direct Selection's marquee then toggles their
// anchors back: b's are selected again and c, whole, leaves.
let want = if tool == "selection" { json!([a, c]) } else { json!([a, b]) };
assert_eq!(*sel, want, "{tool}");
}
}
/// A click on one of two selected objects makes it the key object: `pointer_gesture` replies
/// with it as `key` and `inspect_document` lists it, and a click on the key again lets it go.
#[test]
fn pointer_gestures_report_the_key_object() {
let mut s = server();
let square = |s: &mut Server, id: u64, x: u64| {
let r = call(s, id, "draw_shape", json!({"shape": "rectangle", "x": x, "y": 100, "width": 50, "height": 50, "fill": "#ff0000"}));
serde_json::from_str::<Value>(&text_of(&r)).unwrap()["id"].as_u64().unwrap()
};
let (a, b) = (square(&mut s, 1, 100), square(&mut s, 2, 200));
let r = call(&mut s, 3, "run_command", json!({"command": "select.set", "params": {"ids": [a, b]}}));
assert_eq!(r["isError"], false, "{r}");
let click = |s: &mut Server, id: u64| {
let events = json!([{"kind": "down", "x": 225, "y": 125}, {"kind": "up", "x": 225, "y": 125}]);
let r = call(s, id, "pointer_gesture", json!({"tool": "selection", "events": events}));
assert_eq!(r["isError"], false, "{r}");
serde_json::from_str::<Value>(&text_of(&r)).unwrap().get("key").cloned()
};
assert_eq!(click(&mut s, 4), Some(json!(b)), "a click on a selected object makes it the key");
let doc: Value = serde_json::from_str(&text_of(&call(&mut s, 5, "inspect_document", json!({})))).unwrap();
assert_eq!(doc.get("key"), Some(&json!(b)));
assert_eq!(click(&mut s, 6), Some(Value::Null), "a click on the key again lets it go");
}
/// Preferences › Type › Show Font Names in English (#394): agents set it with `prefs.set`;
/// `text.fontList` stays the English (canonical) family names used by `text.setStyle`, not the
/// native UI labels.
#[test]
fn show_font_names_in_english_does_not_change_font_list_for_agents() {
let mut s = server();
let r = call(&mut s, 1, "run_command", json!({"command": "prefs.set", "params": {"key": "fontNamesInEnglish", "value": false}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 2, "run_command", json!({"command": "prefs.get", "params": {"key": "fontNamesInEnglish"}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(serde_json::from_str::<Value>(&text_of(&r)).unwrap(), json!(false));
let r = call(&mut s, 3, "run_command", json!({"command": "text.fontList", "params": {}}));
assert_eq!(r["isError"], false, "{r}");
let families = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["families"].as_array().unwrap().clone();
// Canonical English name for commands, whatever the UI labels show with the preference off.
assert!(families.iter().any(|f| f.as_str() == Some("Source Sans 3")), "{families:?}");
}
/// Type preferences reach agents (#394): type the Type tool places starts with placeholder text,
/// selected; Alt+→ tracks it by Tracking and Cmd+Shift+. steps its size by Size/Leading.
#[test]
fn type_preferences_apply_to_agents() {
let mut s = server();
let events = json!([{"kind": "down", "x": 100, "y": 100}, {"kind": "up", "x": 100, "y": 100}]);
let r = call(&mut s, 1, "pointer_gesture", json!({"tool": "type", "events": events}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"][0].as_u64().unwrap();
let style = |s: &mut Server, n: u64| {
let r = call(s, n, "run_command", json!({"command": "text.getRange", "params": {"id": id}}));
let v = serde_json::from_str::<Value>(&text_of(&r)).unwrap();
assert!(v["text"].as_str().unwrap().len() > 10, "placeholder text: {v}");
let st = &v["runs"][0]["style"];
(st["size"].as_f64().unwrap(), st["tracking"].as_f64().unwrap())
};
let (size, tracking) = style(&mut s, 2);
let r = call(&mut s, 3, "run_command", json!({"command": "prefs.set", "params": {"values": {"typeSizeIncrement": 4, "trackingIncrement": 50}}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 4, "press_key", json!({"key": "Right", "mods": {"alt": true}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 5, "press_key", json!({"key": ".", "mods": {"cmd": true, "shift": true}}));
assert_eq!(r["isError"], false, "{r}");
assert_eq!(style(&mut s, 6), (size + 4.0, tracking + 50.0));
}
/// The drawing tools snap to Smart Guides over MCP as with the mouse (#506): a Rectangle drawn
/// from near another object's corner starts on it, and a Pen anchor placed beside that object's
/// centre lines up with it.
#[test]
fn drawing_gestures_snap_to_smart_guides() {
let mut s = server();
let r = call(&mut s, 1, "run_command", json!({"command": "file.new", "params": {"width": 800, "height": 600}}));
assert_eq!(r["isError"], false, "{r}");
let r = call(&mut s, 2, "draw_shape", json!({"shape": "rectangle", "x": 100, "y": 100, "width": 100, "height": 100}));
assert_eq!(r["isError"], false, "{r}");
let bounds = |s: &mut Server, n: u64, events: Value, tool: &str| {
let r = call(s, n, "pointer_gesture", json!({"tool": tool, "events": events}));
assert_eq!(r["isError"], false, "{r}");
let id = serde_json::from_str::<Value>(&text_of(&r)).unwrap()["selection"][0].clone();
let doc: Value = serde_json::from_str(&text_of(&call(s, n + 1, "inspect_document", json!({})))).unwrap();
let made = doc["layers"][0]["children"].as_array().unwrap().iter().find(|c| c["id"] == id).expect("drawn").clone();
let b = &made["bounds"];
[&b["x"], &b["y"], &b["width"], &b["height"]].map(|v| v.as_f64().unwrap())
};
let events = json!([{"kind": "move", "x": 202, "y": 203}, {"kind": "down", "x": 202, "y": 203}, {"kind": "drag", "x": 330, "y": 341}, {"kind": "up", "x": 330, "y": 341}]);
assert_eq!(bounds(&mut s, 3, events, "rectangle"), [200.0, 200.0, 130.0, 141.0]);
let r = call(&mut s, 5, "run_command", json!({"command": "select.none", "params": {}}));
assert_eq!(r["isError"], false, "{r}");
let events = json!([{"kind": "down", "x": 300, "y": 420}, {"kind": "up", "x": 300, "y": 420}, {"kind": "move", "x": 151.5, "y": 431}, {"kind": "down", "x": 151.5, "y": 431}, {"kind": "up", "x": 151.5, "y": 431}]);
assert_eq!(bounds(&mut s, 6, events, "pen"), [150.0, 420.0, 150.0, 11.0]);
}
/// #1471: add_text says when the text doesn't fit its frame, and takes area type's `fit`.
#[test]
fn add_text_reports_overflow_and_takes_a_fit() {
let mut s = server();
let made = |r: &Value| serde_json::from_str::<Value>(&text_of(r)).unwrap();
let headline = json!({"text": "NEON DRIVE", "x": 0, "y": 20, "width": 400, "height": 60, "size": 60});
let low = made(&call(&mut s, 1, "add_text", headline.clone()));
assert_eq!(low["overflow"], true, "{low}");
let mut auto = headline;
auto["fit"] = json!("autoHeight");
let auto = made(&call(&mut s, 2, "add_text", auto));
assert!(auto["id"].is_u64() && auto.get("overflow").is_none(), "{auto}");
let point = made(&call(&mut s, 3, "add_text", json!({"text": "NEON DRIVE", "x": 0, "y": 300, "size": 60})));
assert!(point.get("overflow").is_none(), "{point}");
}