effectcraft/apps/effectcraft-cli/tests/cli.rs

134 lines
6.0 KiB
Rust

//! End-to-end tests of the agent CLI: JSON output, project round-trips, rendering and MCP over stdio.
use std::io::Write;
use std::path::PathBuf;
use std::process::{Command, Stdio};
use serde_json::{Value, json};
fn bin() -> Command {
Command::new(env!("CARGO_BIN_EXE_effectcraft-cli"))
}
/// Run with `--json`; returns (exit code, parsed stdout).
fn run_json(args: &[&str]) -> (i32, Value) {
let out = bin().args(args).arg("--json").output().unwrap();
let stdout = String::from_utf8(out.stdout).unwrap();
let v =
serde_json::from_str(stdout.trim()).unwrap_or_else(|e| panic!("{args:?}: not JSON ({e}): {stdout}\nstderr: {}", String::from_utf8_lossy(&out.stderr)));
(out.status.code().unwrap_or(-1), v)
}
fn ok_json(args: &[&str]) -> Value {
let (code, v) = run_json(args);
assert_eq!(code, 0, "{args:?}: {v}");
v
}
fn tmp(name: &str) -> PathBuf {
let d = std::env::temp_dir().join(format!("ec-cli-test-{}", std::process::id()));
std::fs::create_dir_all(&d).unwrap();
d.join(name)
}
#[test]
fn info_and_commands() {
let v = ok_json(&["info"]);
assert_eq!(v["mode"], "headless");
assert!(v["commands"].as_u64().unwrap() > 50);
assert!(v["activeComp"]["layers"].as_array().unwrap().len() > 1, "demo project by default");
let v = ok_json(&["commands", "--filter", "layer.new"]);
let ids: Vec<&str> = v.as_array().unwrap().iter().map(|c| c["id"].as_str().unwrap()).collect();
assert!(ids.contains(&"layer.newSolid") && ids.contains(&"layer.newText"));
assert!(ids.iter().all(|i| i.contains("layer.new") || !i.is_empty()));
}
#[test]
fn exec_set_get_with_saved_project() {
let proj = tmp("edit.ecproj");
let p = proj.to_str().unwrap();
let v = ok_json(&["exec", "comp.new", "--params", r#"{"name":"Main","width":320,"height":180,"duration":3}"#, "--empty", "--save-as", p]);
assert_eq!(v["saved"], p);
// Positional params + positional project + in-place save.
let v = ok_json(&["exec", "layer.newSolid", r##"{"color":"#00ff00","name":"Green"}"##, p, "--save"]);
assert!(v["result"]["layer"].is_u64());
let v = ok_json(&["set", "Main", "Green", "transform/opacity", "25", "--project", p, "--save"]);
assert_eq!(v["result"]["value"], 25.0);
ok_json(&["set", "Main", "#1", "transform/position", "[0,90]", "--time", "0", p, "--save"]);
ok_json(&["set", "Main", "#1", "transform/position", "[320,90]", "--time", "2", p, "--save"]);
let v = ok_json(&["get", "Main", "#1", "transform/position", "--time", "1", p]);
assert_eq!(v["keys"].as_array().unwrap().len(), 2);
assert!((v["value"][0].as_f64().unwrap() - 160.0).abs() < 1.0, "{v}");
assert_eq!(ok_json(&["get", "-", "Green", "transform/opacity", p])["value"], 25.0);
let v = ok_json(&["props", "Main", "#1", p]);
assert_eq!(v["name"], "Green");
let tree = v["properties"]["children"].as_array().unwrap();
assert!(!tree.is_empty());
// The human (flat) listing shows paths.
let out = bin().args(["props", "Main", "#1", p]).output().unwrap();
assert!(String::from_utf8_lossy(&out.stdout).contains("transform/position"));
// Several commands in one process.
let v = ok_json(&["run", p, "time.set", r#"{"time":1}"#, "project.summary"]);
assert_eq!(v[1]["result"]["time"], v[0]["result"]["time"], "{v}");
}
#[test]
fn render_frame_writes_png() {
let out = tmp("f.png");
let o = out.to_str().unwrap();
let v = ok_json(&["render-frame", "--time", "1", "--max-side", "240", "--out", o]);
assert_eq!(v["width"], 240);
assert_eq!(v["height"], 135);
let png = std::fs::read(&out).unwrap();
assert_eq!(&png[..8], b"\x89PNG\r\n\x1a\n");
let v = ok_json(&["render-frame", "--frame", "15", "--scale", "0.125", "--out", o]);
assert_eq!(v["width"], 240);
assert!((v["time"].as_f64().unwrap() - 0.5).abs() < 0.02, "{v}");
}
#[test]
fn errors_are_json() {
let (code, v) = run_json(&["exec", "no.such.command", "--empty"]);
assert_eq!(code, 1);
assert!(v["error"].as_str().unwrap().contains("unknown command"));
let (code, v) = run_json(&["exec", "layer.select", r#"{"index":2}"#]);
assert_eq!(code, 1);
assert!(v["error"].as_str().unwrap().contains("accepted: layers, add, toggle"), "{v}");
let (code, v) = run_json(&["get", "-", "#99", "transform/opacity"]);
assert_eq!(code, 1);
assert!(v["error"].is_string());
let out = bin().args(["get", "only-one-arg"]).output().unwrap();
assert_eq!(out.status.code(), Some(2));
}
#[test]
fn mcp_over_stdio() {
let mut child = bin().args(["mcp", "--demo"]).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::null()).spawn().unwrap();
let msgs = [
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": "get_property", "arguments": {"layer": "#1", "path": "transform/opacity"}}}),
json!({"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "render_frame", "arguments": {"max_side": 64}}}),
];
{
let mut stdin = child.stdin.take().unwrap();
for m in &msgs {
writeln!(stdin, "{m}").unwrap();
}
} // EOF ends the server.
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.len(), 4, "one reply per request, none for the notification");
assert_eq!(replies[0]["result"]["serverInfo"]["name"], "effectcraft");
assert!(replies[1]["result"]["tools"].as_array().unwrap().len() >= 13);
assert_eq!(replies[2]["result"]["isError"], false, "{}", replies[2]);
assert_eq!(replies[3]["result"]["content"][0]["type"], "image");
}