Rename DrawCraft → VectorCraft: crates (vectorcraft-*), apps, binaries, types, env vars, docs, examples (.vectorcraft); old .drawcraft files and preferences still load. Fix: text bounds exact after opening a file; selection no longer marks a document modified (dirty = content differs from the saved document). Screenshots and renders regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-01 14:30:09 -04:00
co-authored by Claude Opus 5.5
parent deb9df299c
commit 4878f1c5dd
279 changed files with 2526 additions and 2410 deletions
+82
View File
@@ -0,0 +1,82 @@
use std::io::Write;
use std::process::{Command, Stdio};
use serde_json::{Value, json};
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("vectorcraft-cli-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
dir.join(name)
}
#[test]
fn commands_prints_catalogue() {
let out = Command::new(BIN).arg("commands").output().unwrap();
assert!(out.status.success());
let v: Value = serde_json::from_slice(&out.stdout).unwrap();
let ids: Vec<&str> = v.as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect();
assert!(ids.contains(&"shape.rectangle") && ids.contains(&"object.group") && ids.contains(&"file.export"));
}
#[test]
fn run_batch_exports() {
let svg = tmp("batch.svg");
let png = tmp("batch.png");
let dc = tmp("batch.vectorcraft");
let out = Command::new(BIN)
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#])
.args(["--cmd", "shape.ellipse", "--params", r#"{"x":10,"y":10,"width":80,"height":60}"#])
.args(["--cmd", "paint.setFill", "--params", r##"{"color":"#3366ff"}"##])
.args(["--export", svg.to_str().unwrap(), "--export", png.to_str().unwrap(), "--export", dc.to_str().unwrap(), "--scale", "2"])
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(lines.len(), 6);
assert!(std::fs::read_to_string(&svg).unwrap().contains("3366ff"));
let png_bytes = std::fs::read(&png).unwrap();
assert_eq!(&png_bytes[..4], b"\x89PNG");
// 200×100 pt at scale 2 → 400×200 px (IHDR width/height).
assert_eq!(u32::from_be_bytes(png_bytes[16..20].try_into().unwrap()), 400);
// Re-open the native file and inspect.
let out = Command::new(BIN).args(["run", "--in", dc.to_str().unwrap(), "--cmd", "document.inspect"]).output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let last: Value = serde_json::from_str(String::from_utf8(out.stdout).unwrap().lines().last().unwrap()).unwrap();
assert_eq!(last["result"]["artboards"][0]["width"], 200.0);
}
#[test]
fn run_reports_errors() {
let out = Command::new(BIN).args(["run", "--cmd", "nope.nothing"]).output().unwrap();
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("nope.nothing"));
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
assert!(!out.status.success());
}
#[test]
fn mcp_headless_over_stdio() {
let mut child = Command::new(BIN).args(["mcp", "--headless"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
{
let mut stdin = child.stdin.take().unwrap();
for m in [
json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"0"}}}),
json!({"jsonrpc":"2.0","method":"notifications/initialized"}),
json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}),
json!({"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"draw_shape","arguments":{"shape":"polygon","cx":100,"cy":100,"radius":50,"sides":5,"fill":"#00aa00"}}}),
json!({"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"screenshot","arguments":{"scale":0.25}}}),
] {
writeln!(stdin, "{m}").unwrap();
}
}
let out = child.wait_with_output().unwrap();
assert!(out.status.success());
let replies: Vec<Value> = String::from_utf8(out.stdout).unwrap().lines().map(|l| serde_json::from_str(l).unwrap()).collect();
assert_eq!(replies.iter().map(|r| r["id"].as_u64().unwrap()).collect::<Vec<_>>(), [1, 2, 3, 4]);
assert_eq!(replies[0]["result"]["serverInfo"]["name"], "vectorcraft");
assert_eq!(replies[2]["result"]["isError"], false);
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
}
+260
View File
@@ -0,0 +1,260 @@
//! End-to-end MCP over stdio against the built `vectorcraft-cli mcp --headless` binary.
use std::io::{BufRead, BufReader, Write};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use serde_json::{Value, json};
use vectorcraft_testkit::raster::Image;
const BIN: &str = env!("CARGO_BIN_EXE_vectorcraft-cli");
struct Mcp {
child: Child,
stdin: Option<ChildStdin>,
stdout: BufReader<ChildStdout>,
next: u64,
}
impl Mcp {
fn spawn() -> Self {
let mut child = Command::new(BIN)
.args(["mcp", "--headless"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn vectorcraft-cli");
let stdin = child.stdin.take();
let stdout = BufReader::new(child.stdout.take().unwrap());
Self { child, stdin, stdout, next: 1 }
}
fn send(&mut self, v: &Value) {
let w = self.stdin.as_mut().unwrap();
writeln!(w, "{v}").unwrap();
w.flush().unwrap();
}
fn read(&mut self) -> Value {
let mut line = String::new();
let n = self.stdout.read_line(&mut line).unwrap();
assert!(n > 0, "server closed stdout");
serde_json::from_str(&line).unwrap_or_else(|e| panic!("bad JSON line {line}: {e}"))
}
fn request(&mut self, method: &str, params: Value) -> Value {
let id = self.next;
self.next += 1;
self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
let v = self.read();
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], id, "reply to the wrong request: {v}");
v
}
fn notify(&mut self, method: &str) {
self.send(&json!({"jsonrpc": "2.0", "method": method}));
}
/// Call a tool; panics on protocol errors or tool errors.
fn tool(&mut self, name: &str, args: Value) -> Value {
let v = self.request("tools/call", json!({"name": name, "arguments": args}));
assert!(v.get("error").is_none(), "{name}: {v}");
let r = v["result"].clone();
assert_ne!(r["isError"], json!(true), "{name} {args}: {r}");
r
}
fn tool_json(&mut self, name: &str, args: Value) -> Value {
let r = self.tool(name, args);
let text: String =
r["content"].as_array().unwrap().iter().filter(|c| c["type"] == "text").map(|c| c["text"].as_str().unwrap().to_string()).collect();
serde_json::from_str(&text).unwrap_or(Value::String(text))
}
fn run(&mut self, command: &str, params: Value) -> Value {
self.tool_json("run_command", json!({"command": command, "params": params}))
}
fn document(&mut self) -> Value {
let v = self.request("resources/read", json!({"uri": "vectorcraft://document/json"}));
serde_json::from_str(v["result"]["contents"][0]["text"].as_str().unwrap()).unwrap()
}
fn art_count(&mut self) -> usize {
let d: vectorcraft_testkit::doc::Document = serde_json::from_value(self.document()).unwrap();
d.node_count() - d.layers.len()
}
fn initialize(&mut self) {
let v =
self.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "e2e", "version": "0"}}));
let r = &v["result"];
assert_eq!(r["serverInfo"]["name"], "vectorcraft");
assert!(r["protocolVersion"].is_string());
assert!(r["capabilities"]["tools"].is_object());
self.notify("notifications/initialized");
}
fn close(mut self) {
drop(self.stdin.take());
let status = self.child.wait().unwrap();
assert!(status.success(), "server exited with {status}");
}
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
}
}
fn tmp(name: &str) -> String {
vectorcraft_testkit::temp_dir("cli-mcp-e2e").join(name).to_string_lossy().to_string()
}
#[test]
fn initialize_and_list_tools_with_valid_schemas() {
let mut m = Mcp::spawn();
m.initialize();
let v = m.request("tools/list", json!({}));
let tools = v["result"]["tools"].as_array().unwrap().clone();
assert!(tools.len() >= 15);
for t in &tools {
let name = t["name"].as_str().unwrap();
assert!(!name.is_empty() && t["description"].as_str().is_some_and(|d| !d.is_empty()), "{t}");
let s = &t["inputSchema"];
assert_eq!(s["type"], "object", "{name}");
let props = s["properties"].as_object().unwrap_or_else(|| panic!("{name}: no properties"));
for r in s.get("required").and_then(Value::as_array).into_iter().flatten() {
assert!(props.contains_key(r.as_str().unwrap()), "{name}: required {r} missing");
}
}
for want in ["run_command", "draw_shape", "draw_path", "pointer_gesture", "screenshot", "export", "open_file", "undo", "redo", "inspect_document"]
{
assert!(tools.iter().any(|t| t["name"] == want), "missing tool {want}");
}
// ping and an unknown method.
assert_eq!(m.request("ping", json!({}))["result"], json!({}));
assert_eq!(m.request("no/such", json!({}))["error"]["code"], -32601);
m.close();
}
#[test]
fn draw_inspect_batch_undo_redo() {
let mut m = Mcp::spawn();
m.initialize();
let base = m.art_count();
let r =
m.tool_json("draw_shape", json!({"shape": "rectangle", "x": 20, "y": 20, "width": 100, "height": 80, "fill": "#ff0000", "stroke": "none"}));
assert!(r["id"].is_u64(), "{r}");
m.tool_json("draw_path", json!({"points": [[200, 50], [300, 50], [250, 150]], "closed": true, "fill": [0, 0, 1]}));
m.tool_json("draw_path", json!({"d": "M 50 300 C 100 250 150 350 200 300", "stroke": "#00aa00", "strokeWidth": 3}));
m.tool("pointer_gesture", json!({"tool": "ellipse", "events": [
{"kind": "down", "x": 300, "y": 300}, {"kind": "drag", "x": 350, "y": 340}, {"kind": "drag", "x": 400, "y": 380}, {"kind": "up", "x": 400, "y": 380}
]}));
assert_eq!(m.art_count(), base + 4);
let inspect = m.tool_json("inspect_document", json!({}));
let text = inspect.to_string();
assert!(text.contains("Rectangle") || text.contains("rectangle"), "{text}");
// A batch is one undo step.
let before = m.document();
m.run(
"command.batch",
json!({"commands": [
{"command": "shape.star", "params": {"cx": 100, "cy": 500, "radius1": 40, "radius2": 20}},
{"command": "shape.polygon", "params": {"cx": 200, "cy": 500, "radius": 30}},
{"command": "select.all", "params": {}},
{"command": "object.group", "params": {}},
]}),
);
let after = m.document();
assert_ne!(before, after);
m.tool("undo", json!({}));
assert!(vectorcraft_testkit::invariants::json_approx_eq(&m.document(), &before, 1e-12));
m.tool("redo", json!({}));
assert!(vectorcraft_testkit::invariants::json_approx_eq(&m.document(), &after, 1e-12));
// Tool errors are reported in-band, and the server keeps working.
let v = m.request("tools/call", json!({"name": "run_command", "arguments": {"command": "no.such.command"}}));
assert_eq!(v["result"]["isError"], json!(true), "{v}");
let v = m.request("tools/call", json!({"name": "draw_shape", "arguments": {"shape": "hexagram"}}));
assert_eq!(v["result"]["isError"], json!(true));
m.tool("inspect_document", json!({}));
m.close();
}
#[test]
fn screenshot_is_a_decodable_png() {
let mut m = Mcp::spawn();
m.initialize();
m.tool("draw_shape", json!({"shape": "rectangle", "x": 0, "y": 0, "width": 100, "height": 100, "fill": "#ff0000", "stroke": "none"}));
let path = tmp("shot.png");
let r = m.tool("screenshot", json!({"scale": 0.5, "path": path}));
let img = r["content"].as_array().unwrap().iter().find(|c| c["type"] == "image").expect("image content").clone();
assert_eq!(img["mimeType"], "image/png");
let png = vectorcraft_testkit::format::base64_decode(img["data"].as_str().unwrap()).expect("base64");
let decoded = Image::from_png(&png).expect("decodable PNG");
let doc: vectorcraft_testkit::doc::Document = serde_json::from_value(m.document()).unwrap();
let ab = doc.artboards[0].rect;
assert_eq!(decoded.width, (ab.width() * 0.5).round() as u32);
assert_eq!(decoded.height, (ab.height() * 0.5).round() as u32);
// The red square covers the top-left 50×50 px.
let p = decoded.over_white(10, 10);
assert!(p[0] > 240 && p[1] < 20 && p[2] < 20, "{p:?}");
let q = decoded.over_white(decoded.width - 5, decoded.height - 5);
assert_eq!(q, [255, 255, 255]);
// The saved file is the same PNG.
assert_eq!(Image::from_png(&std::fs::read(&path).unwrap()).unwrap(), decoded);
m.close();
}
#[test]
fn export_formats_and_reopen() {
let mut m = Mcp::spawn();
m.initialize();
m.tool("draw_shape", json!({"shape": "ellipse", "x": 50, "y": 50, "width": 200, "height": 120, "fill": "#3366ff"}));
m.tool("draw_shape", json!({"shape": "star", "cx": 300, "cy": 300, "radius1": 80, "radius2": 40, "fill": "#ffcc00"}));
let original = m.document();
let n = m.art_count();
let dc = tmp("e2e.vectorcraft");
let svg = tmp("e2e.svg");
let pdf = tmp("e2e.pdf");
let png = tmp("e2e.png");
m.tool("export", json!({"format": "vectorcraft", "path": dc}));
m.tool("export", json!({"path": svg}));
m.tool("export", json!({"path": png, "scale": 0.25}));
m.run("document.export", json!({"format": "pdf", "path": pdf}));
assert!(std::fs::read(&pdf).unwrap().starts_with(b"%PDF"));
assert!(std::fs::read_to_string(&svg).unwrap().contains("<svg"));
Image::from_png(&std::fs::read(&png).unwrap()).expect("exported PNG decodes");
// Re-open each: .vectorcraft reproduces the document; SVG and PDF bring the art back.
m.tool("open_file", json!({"path": dc}));
assert!(vectorcraft_testkit::invariants::json_approx_eq(&m.document(), &original, 1e-12));
m.tool("open_file", json!({"path": svg}));
assert!(m.art_count() >= n, "svg re-open lost art");
m.run("document.open", json!({"path": pdf}));
assert!(m.art_count() >= n, "pdf re-open lost art");
let docs = m.tool_json("inspect_document", json!({}));
assert!(docs.is_object());
// save_file writes the native format to a new path and it loads.
let saved = tmp("saved.vectorcraft");
m.tool("save_file", json!({"path": saved}));
let d = vectorcraft_testkit::format::load(&std::fs::read(&saved).unwrap()).unwrap();
vectorcraft_testkit::invariants::check_document(&d).unwrap();
m.close();
}
#[test]
fn many_requests_in_one_write_are_answered_in_order() {
let mut m = Mcp::spawn();
m.initialize();
let mut batch = String::new();
for i in 0..50u64 {
batch.push_str(&json!({"jsonrpc": "2.0", "id": 1000 + i, "method": "tools/call", "params": {"name": "draw_shape", "arguments": {"shape": "rectangle", "x": i, "y": i, "width": 5, "height": 5}}}).to_string());
batch.push('\n');
}
let w = m.stdin.as_mut().unwrap();
w.write_all(batch.as_bytes()).unwrap();
w.flush().unwrap();
for i in 0..50u64 {
let v = m.read();
assert_eq!(v["id"], 1000 + i);
assert_ne!(v["result"]["isError"], json!(true));
}
assert!(m.art_count() >= 50);
m.close();
}