mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-29 04:07:12 -06:00
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:
co-authored by
Claude Opus 5.5
parent
deb9df299c
commit
4878f1c5dd
@@ -0,0 +1,29 @@
|
||||
[package]
|
||||
name = "vectorcraft-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
description = "VectorCraft command line: MCP server (stdio) and headless batch runs"
|
||||
|
||||
[[bin]]
|
||||
name = "vectorcraft-cli"
|
||||
path = "src/main.rs"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-engine = { workspace = true }
|
||||
vectorcraft-format = { workspace = true }
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-svg = { workspace = true }
|
||||
vectorcraft-mcp = { workspace = true }
|
||||
vectorcraft-render = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
vectorcraft-testkit = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,212 @@
|
||||
//! `vectorcraft-cli`: VectorCraft from the command line.
|
||||
//!
|
||||
//! ```text
|
||||
//! vectorcraft-cli mcp [--connect 127.0.0.1:7979 | --headless]
|
||||
//! vectorcraft-cli run [--in file.vectorcraft|file.svg] [--cmd id [--params '{json}']]... [--export out.svg|.png|.pdf|.jpg|.webp|.vectorcraft]... [--scale 2]
|
||||
//! vectorcraft-cli commands
|
||||
//! vectorcraft-cli bench FILE [--size 2880x1800] [--iters 5]
|
||||
//! vectorcraft-cli perf [--paths 50000]
|
||||
//! ```
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::process::ExitCode;
|
||||
|
||||
mod perf;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_mcp::{Backend, DEFAULT_ADDR, Headless, Remote, Server};
|
||||
|
||||
const USAGE: &str = "\
|
||||
vectorcraft-cli — VectorCraft automation
|
||||
|
||||
USAGE:
|
||||
vectorcraft-cli mcp [--connect ADDR | --headless]
|
||||
Run the MCP server on stdio. Default: connect to a running app at 127.0.0.1:7979
|
||||
(vectorcraft --control 7979), falling back to a headless in-process session.
|
||||
|
||||
vectorcraft-cli run [--in FILE] [--cmd ID [--params JSON]]... [--export FILE]... [--scale N]
|
||||
Headless batch: open FILE (.vectorcraft/.svg) or start a new document, run commands in
|
||||
order, export (.svg, .png, .pdf, .jpg, .webp, .vectorcraft by extension). Prints one JSON result per step.
|
||||
|
||||
vectorcraft-cli commands
|
||||
Print the command catalogue as JSON.
|
||||
|
||||
vectorcraft-cli bench FILE [--size WxH] [--iters N]
|
||||
Render FILE (.vectorcraft/.svg) fitted to WxH (default 2880x1800) and print ms per frame
|
||||
(warm), multithreaded and single-threaded.
|
||||
|
||||
vectorcraft-cli perf [--paths N]
|
||||
Check the performance budgets (render, pan, hit test, save/load, SVG, Pathfinder) on a
|
||||
synthetic N-path document (default 50000). Exits non-zero if a budget is exceeded.
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let r = match args.first().map(String::as_str) {
|
||||
Some("mcp") => mcp(&args[1..]),
|
||||
Some("run") => run(&args[1..]),
|
||||
Some("commands") => commands(),
|
||||
Some("bench") => bench(&args[1..]),
|
||||
Some("perf") => perf::run(&args[1..]),
|
||||
Some("-h" | "--help" | "help") | None => {
|
||||
print!("{USAGE}");
|
||||
Ok(())
|
||||
}
|
||||
Some("-V" | "--version") => {
|
||||
println!("vectorcraft-cli {}", env!("CARGO_PKG_VERSION"));
|
||||
Ok(())
|
||||
}
|
||||
Some(other) => Err(format!("unknown subcommand `{other}`\n\n{USAGE}")),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("vectorcraft-cli: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp(args: &[String]) -> Result<(), String> {
|
||||
let mut connect: Option<String> = None;
|
||||
let mut headless = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--connect" => connect = Some(it.next().cloned().ok_or("--connect needs an address")?),
|
||||
"--headless" => headless = true,
|
||||
other => return Err(format!("unknown mcp option `{other}`")),
|
||||
}
|
||||
}
|
||||
if headless && connect.is_some() {
|
||||
return Err("use either --connect or --headless".into());
|
||||
}
|
||||
let backend: Box<dyn Backend> = if headless {
|
||||
Box::new(Headless::with_document())
|
||||
} else if let Some(addr) = connect {
|
||||
// Explicit address: fail loudly if the app isn't there.
|
||||
Box::new(Remote::connect(&addr).map_err(|e| format!("cannot connect to {addr}: {e}"))?)
|
||||
} else {
|
||||
match Remote::connect(DEFAULT_ADDR) {
|
||||
Ok(r) => Box::new(r),
|
||||
Err(_) => Box::new(Headless::with_document()),
|
||||
}
|
||||
};
|
||||
eprintln!("vectorcraft-cli: MCP server on stdio ({})", backend.describe());
|
||||
let stdin = std::io::stdin();
|
||||
let stdout = std::io::stdout();
|
||||
Server::new(backend).serve(stdin.lock(), stdout.lock()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn commands() -> Result<(), String> {
|
||||
let mut h = Headless::new();
|
||||
let v = h.call("engine.commands", json!({}))?;
|
||||
println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
enum Step {
|
||||
Cmd(String, Value),
|
||||
Export(String),
|
||||
}
|
||||
|
||||
fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut input: Option<String> = None;
|
||||
let mut steps: Vec<Step> = vec![];
|
||||
let mut scale = 1.0;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
let mut val = |what: &str| it.next().cloned().ok_or_else(|| format!("{a} needs {what}"));
|
||||
match a.as_str() {
|
||||
"--in" | "-i" => input = Some(val("a file")?),
|
||||
"--cmd" | "-c" => steps.push(Step::Cmd(val("a command id")?, json!({}))),
|
||||
"--params" | "-p" => {
|
||||
let raw = val("JSON")?;
|
||||
let p: Value = serde_json::from_str(&raw).map_err(|e| format!("--params {raw}: {e}"))?;
|
||||
if !p.is_object() {
|
||||
return Err(format!("--params must be a JSON object, got {raw}"));
|
||||
}
|
||||
match steps.last_mut() {
|
||||
Some(Step::Cmd(_, params)) => *params = p,
|
||||
_ => return Err("--params must follow a --cmd".into()),
|
||||
}
|
||||
}
|
||||
"--export" | "-o" => steps.push(Step::Export(val("a file")?)),
|
||||
"--scale" | "-s" => {
|
||||
let raw = val("a number")?;
|
||||
scale = raw.parse::<f64>().map_err(|_| format!("--scale {raw}: not a number"))?;
|
||||
}
|
||||
other => return Err(format!("unknown run option `{other}`")),
|
||||
}
|
||||
}
|
||||
|
||||
let mut h = Headless::new();
|
||||
let mut out = std::io::stdout().lock();
|
||||
let mut emit = |v: Value| writeln!(out, "{v}").map_err(|e| e.to_string());
|
||||
if let Some(path) = &input {
|
||||
let r = h.call("app.open", json!({"path": path})).map_err(|e| format!("open {path}: {e}"))?;
|
||||
emit(json!({"step": "open", "path": path, "result": r}))?;
|
||||
} else if !matches!(steps.first(), Some(Step::Cmd(id, _)) if id == "file.new") {
|
||||
h.ensure_document();
|
||||
}
|
||||
for step in steps {
|
||||
match step {
|
||||
Step::Cmd(id, params) => {
|
||||
let r = h.call("engine.execute", json!({"command": id, "params": params})).map_err(|e| format!("{id}: {e}"))?;
|
||||
emit(json!({"step": "cmd", "command": id, "result": r}))?;
|
||||
}
|
||||
Step::Export(path) => {
|
||||
let r = h.call("app.export", json!({"path": path, "scale": scale})).map_err(|e| format!("export {path}: {e}"))?;
|
||||
emit(json!({"step": "export", "result": r}))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn bench(args: &[String]) -> Result<(), String> {
|
||||
let mut file = None;
|
||||
let (mut w, mut h, mut iters) = (2880u32, 1800u32, 5u32);
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--size" => {
|
||||
let v = it.next().ok_or("--size needs WxH")?;
|
||||
let (a, b) = v.split_once('x').ok_or("--size needs WxH")?;
|
||||
w = a.parse().map_err(|_| "bad width")?;
|
||||
h = b.parse().map_err(|_| "bad height")?;
|
||||
}
|
||||
"--iters" => iters = it.next().and_then(|v| v.parse().ok()).ok_or("--iters needs a number")?,
|
||||
f if file.is_none() => file = Some(f.to_string()),
|
||||
other => return Err(format!("unknown bench option `{other}`")),
|
||||
}
|
||||
}
|
||||
let file = file.ok_or("bench needs a FILE")?;
|
||||
let mut hl = Headless::new();
|
||||
hl.call("app.open", json!({ "path": file })).map_err(|e| format!("open {file}: {e}"))?;
|
||||
let doc = hl.session.doc().map_err(|e| e.to_string())?.doc.clone();
|
||||
let b = doc.artboards.first().map(|a| a.rect).ok_or("document has no artboard")?;
|
||||
let z = (w as f64 / b.width()).min(h as f64 / b.height()) * 0.95;
|
||||
let view = vectorcraft_geom::Affine::translate((w as f64 / 2.0, h as f64 / 2.0))
|
||||
* vectorcraft_geom::Affine::scale(z)
|
||||
* vectorcraft_geom::Affine::translate(-b.center().to_vec2());
|
||||
let opts = vectorcraft_render::RenderOptions::default();
|
||||
println!("{file}: {} nodes, {w}x{h}", doc.layers.iter().map(|l| l.count()).sum::<usize>());
|
||||
for threads in [vectorcraft_render::default_threads(), 0] {
|
||||
let mut r = vectorcraft_render::Renderer::new();
|
||||
r.threads = threads;
|
||||
r.render(&doc, w, h, view, &opts);
|
||||
let t = std::time::Instant::now();
|
||||
for _ in 0..iters {
|
||||
r.render(&doc, w, h, view, &opts);
|
||||
}
|
||||
println!(
|
||||
" threads {threads}: {:.1} ms/frame (drawn {}, culled {})",
|
||||
t.elapsed().as_secs_f64() * 1000.0 / iters as f64,
|
||||
r.stats.drawn,
|
||||
r.stats.culled
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
//! `vectorcraft-cli perf`: the performance budgets (plan §4) measured on a synthetic document.
|
||||
//!
|
||||
//! Each row is the median of several runs. Timings are wall-clock: on a busy machine (load
|
||||
//! average above the core count) they are noise, and the report says so.
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
use serde_json::json;
|
||||
use vectorcraft_color::{Color, Paint};
|
||||
use vectorcraft_doc::{Appearance, Document, Node, hit};
|
||||
use vectorcraft_geom::{Affine, Point, Rect, shapes};
|
||||
use vectorcraft_render::{RenderOptions, Renderer};
|
||||
|
||||
/// Deterministic xorshift in 0..1.
|
||||
struct Rng(u64);
|
||||
impl Rng {
|
||||
fn next(&mut self) -> f64 {
|
||||
self.0 ^= self.0 << 13;
|
||||
self.0 ^= self.0 >> 7;
|
||||
self.0 ^= self.0 << 17;
|
||||
(self.0 % 1_000_000) as f64 / 1_000_000.0
|
||||
}
|
||||
}
|
||||
|
||||
/// `n` mixed paths (ellipses with strokes, translucent rectangles, stroked stars) on a 1600×1200 page.
|
||||
pub fn synthetic(n: usize) -> Document {
|
||||
let mut d = Document::new(1600.0, 1200.0);
|
||||
let l = d.layers[0].id;
|
||||
let mut r = Rng(42);
|
||||
for i in 0..n {
|
||||
let (x, y, s) = (r.next() * 1600.0, r.next() * 1200.0, 3.0 + r.next() * 22.0);
|
||||
let c = Color::rgb(r.next() as f32, r.next() as f32, r.next() as f32);
|
||||
let id = d.alloc_id();
|
||||
let mut node = match i % 3 {
|
||||
0 => Node::path(
|
||||
id,
|
||||
shapes::ellipse(Rect::from_center_size(Point::new(x, y), (2.0 * s, 2.0 * s))),
|
||||
Appearance::basic(Paint::solid(c), Paint::solid(Color::BLACK), 0.5),
|
||||
),
|
||||
1 => Node::path(id, shapes::rectangle(Rect::new(x, y, x + 2.0 * s, y + s)), Appearance::basic(Paint::solid(c), Paint::None, 0.0)),
|
||||
_ => Node::path(id, shapes::star(Point::new(x, y), s, s / 2.0, 5, 0.0), Appearance::basic(Paint::None, Paint::solid(c), 2.0)),
|
||||
};
|
||||
if i % 3 == 1 {
|
||||
node.opacity = 0.8;
|
||||
}
|
||||
d.insert(Some(l), usize::MAX, node).expect("insert");
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
fn median_ms(runs: usize, mut f: impl FnMut()) -> f64 {
|
||||
let mut v: Vec<f64> = (0..runs)
|
||||
.map(|_| {
|
||||
let t = Instant::now();
|
||||
f();
|
||||
t.elapsed().as_secs_f64() * 1000.0
|
||||
})
|
||||
.collect();
|
||||
v.sort_by(f64::total_cmp);
|
||||
v[v.len() / 2]
|
||||
}
|
||||
|
||||
/// 1-minute load average (macOS / Linux), if available.
|
||||
fn load_average() -> Option<f64> {
|
||||
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok().filter(|o| o.status.success());
|
||||
let text = match out {
|
||||
Some(o) => String::from_utf8_lossy(&o.stdout).to_string(),
|
||||
None => std::fs::read_to_string("/proc/loadavg").ok()?,
|
||||
};
|
||||
text.split_whitespace().map(|t| t.trim_matches(|c| c == '{' || c == '}')).find_map(|t| t.parse().ok())
|
||||
}
|
||||
|
||||
pub fn run(args: &[String]) -> Result<(), String> {
|
||||
let mut n = 50_000usize;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match a.as_str() {
|
||||
"--paths" => n = it.next().and_then(|v| v.parse().ok()).ok_or("--paths needs a number")?,
|
||||
other => return Err(format!("unknown perf option `{other}`")),
|
||||
}
|
||||
}
|
||||
let cores = std::thread::available_parallelism().map_or(1, |c| c.get());
|
||||
let load = load_average();
|
||||
println!("VectorCraft performance budgets — {n} paths, {cores} cores, load average {}", load.map_or("?".into(), |l| format!("{l:.1}")));
|
||||
let noisy = load.is_some_and(|l| l > cores as f64 * 0.75);
|
||||
if noisy {
|
||||
println!("WARNING: the machine is busy; wall-clock timings below are not trustworthy.");
|
||||
}
|
||||
let t = Instant::now();
|
||||
let doc = synthetic(n);
|
||||
println!(" (built in {:.0} ms)", t.elapsed().as_secs_f64() * 1000.0);
|
||||
|
||||
let mut rows: Vec<(&str, f64, f64)> = vec![];
|
||||
let opts = RenderOptions::default();
|
||||
let mut r = Renderer::new();
|
||||
let fit = Affine::scale(1.6);
|
||||
r.render(&doc, 2880, 1800, fit, &opts);
|
||||
// The canvas never waits for these: while panning/zooming it shows the last frame reprojected
|
||||
// and re-renders in the background, so the budget is for that refresh to land quickly.
|
||||
rows.push(("render: fit page, 2880×1800", median_ms(5, || drop(r.render(&doc, 2880, 1800, fit, &opts))), 100.0));
|
||||
let mut dx = 0.0;
|
||||
rows.push((
|
||||
"render: background refresh after a pan",
|
||||
median_ms(9, || {
|
||||
dx += 7.0;
|
||||
drop(r.render(&doc, 2880, 1800, Affine::translate((dx, 0.0)) * fit, &opts));
|
||||
}),
|
||||
100.0,
|
||||
));
|
||||
let zoom = Affine::scale(6.4) * Affine::translate((-600.0, -400.0));
|
||||
r.render(&doc, 2880, 1800, zoom, &opts);
|
||||
// Zoomed in, culling leaves few objects: this one should fit a frame.
|
||||
rows.push(("render: 400% zoom, 2880×1800", median_ms(5, || drop(r.render(&doc, 2880, 1800, zoom, &opts))), 16.0));
|
||||
|
||||
let mut rng = Rng(7);
|
||||
let points: Vec<Point> = (0..200).map(|_| Point::new(rng.next() * 1600.0, rng.next() * 1200.0)).collect();
|
||||
let hits = median_ms(3, || {
|
||||
for p in &points {
|
||||
drop(hit::hit_test(&doc, *p, hit::HitOptions::default()));
|
||||
}
|
||||
}) / points.len() as f64;
|
||||
rows.push(("hit test (per click)", hits, 2.0));
|
||||
|
||||
let mut bytes = vec![];
|
||||
rows.push(("save .vectorcraft", median_ms(3, || bytes = vectorcraft_format::save_file(&doc)), 300.0));
|
||||
rows.push(("open .vectorcraft", median_ms(3, || drop(vectorcraft_format::load(&bytes).expect("load"))), 300.0));
|
||||
let svg_opts = vectorcraft_svg::ExportOptions::default();
|
||||
rows.push(("export SVG", median_ms(3, || drop(vectorcraft_svg::export(&doc, &svg_opts))), 500.0));
|
||||
|
||||
// Pathfinder Unite on 1,000 overlapping paths.
|
||||
let unite = median_ms(3, || {
|
||||
let mut s = vectorcraft_engine::Session::new();
|
||||
s.add_document(synthetic(1000), None);
|
||||
s.execute("select.all", &json!({})).expect("select");
|
||||
s.execute("object.pathfinder.unite", &json!({})).expect("unite");
|
||||
});
|
||||
rows.push(("Pathfinder Unite, 1,000 paths", unite, 150.0));
|
||||
|
||||
let mut over = 0;
|
||||
println!(" {:<40} {:>10} {:>10}", "", "measured", "budget");
|
||||
for (name, ms, budget) in &rows {
|
||||
let ok = ms <= budget;
|
||||
over += usize::from(!ok);
|
||||
println!(" {name:<40} {ms:>8.2} ms {budget:>7.0} ms {}", if ok { "ok" } else { "OVER" });
|
||||
}
|
||||
match (over, noisy) {
|
||||
(0, _) => Ok(()),
|
||||
(_, true) => {
|
||||
println!("{over} over budget, but the machine is busy: re-run when idle.");
|
||||
Ok(())
|
||||
}
|
||||
_ => Err(format!("{over} budget(s) exceeded")),
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user