CLI: run --commands reads the steps from a JSON file or standard input (#3147)

This commit is contained in:
Taha-Mahmoodi
2026-10-22 21:34:37 +07:00
committed by bflatastic
parent 9097c4a564
commit 87fdd4a370
4 changed files with 260 additions and 15 deletions
+115 -1
View File
@@ -84,11 +84,125 @@ fn scale_sizes_raster_exports_only() {
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 stderr = String::from_utf8_lossy(&out.stderr).to_string();
assert!(stderr.contains("step 0 (nope.nothing)"), "{stderr}");
let out = Command::new(BIN).args(["run", "--params", "{}"]).output().unwrap();
assert!(!out.status.success());
}
/// The JSON lines `run` printed.
fn json_lines(stdout: &[u8]) -> Vec<Value> {
String::from_utf8_lossy(stdout).lines().map(|l| serde_json::from_str(l).unwrap()).collect()
}
/// #3147: `run --commands FILE` reads its steps from a JSON array (`command` or `id`), runs them
/// where the option stands among `--cmd` and `--export`, and they count with those for `"$N"`; a
/// placed file's `dataBase64` longer than a Windows command line holds fits.
#[test]
fn run_reads_steps_from_a_commands_file() {
let steps = tmp("steps.json");
let svg = tmp("steps.svg");
// Base64 of an SVG well past 32,767 characters, the most a Windows command line holds.
let padding = "x".repeat(40_000);
let placed = format!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="40" height="20"><!-- {padding} --><rect width="40" height="20" fill="#118833"/></svg>"##
);
let data = vectorcraft_format::base64_encode(placed.as_bytes());
assert!(data.len() > 32_767);
let file = json!([
{"command": "shape.rectangle", "params": {"x": 10, "y": 10, "width": 50, "height": 30}},
{"command": "file.place", "params": {"name": "code.svg", "dataBase64": data}},
{"id": "object.setProps", "params": {"id": "$2.ids.0", "name": "barcode"}},
]);
std::fs::write(&steps, serde_json::to_string_pretty(&file).unwrap()).unwrap();
let out = Command::new(BIN)
.args(["run", "--cmd", "file.new", "--params", r#"{"width":200,"height":100}"#, "--commands", steps.to_str().unwrap()])
.args(["--cmd", "document.node", "--params", r#"{"id":"$2.ids.0"}"#, "--export", svg.to_str().unwrap()])
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines = json_lines(&out.stdout);
let ran: Vec<&str> = lines.iter().map(|l| l["command"].as_str().unwrap_or("export")).collect();
assert_eq!(ran, ["file.new", "shape.rectangle", "file.place", "object.setProps", "document.node", "export"]);
// `$2` in the file and in the `--cmd` after it is the placed art.
assert_eq!(lines[4]["result"]["name"], "barcode", "{}", lines[4]);
let written = std::fs::read_to_string(&svg).unwrap();
assert!(written.contains("118833") && written.contains("barcode"), "{written}");
}
/// `run --commands -` reads JSON Lines from standard input: a byte order mark, blank lines and
/// CRLF line ends are fine, and the first step may be `file.new`.
#[test]
fn run_reads_json_lines_from_standard_input() {
let svg = tmp("stdin.svg");
let mut child = Command::new(BIN)
.args(["run", "--commands", "-", "--export", svg.to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let text = "\u{feff}{\"command\":\"file.new\",\"params\":{\"width\":120,\"height\":80}}\n\n{\"command\":\"shape.ellipse\",\"params\":{\"x\":10,\"y\":10,\"width\":40,\"height\":30}}\r\n{\"id\":\"paint.setFill\",\"params\":{\"color\":\"#3366ff\"}}\n";
child.stdin.take().unwrap().write_all(text.as_bytes()).unwrap();
let out = child.wait_with_output().unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let lines = json_lines(&out.stdout);
assert_eq!(lines.len(), 4);
assert_eq!(lines[0]["command"], "file.new");
assert!(std::fs::read_to_string(&svg).unwrap().contains("3366ff"));
}
/// Malformed `--commands` input stops the run before any step runs, naming the step (counted from
/// 0 with the `--cmd` before it) and the entry or line; input past the size and step limits is
/// refused; a step that fails names its number.
#[test]
fn run_refuses_malformed_commands_naming_the_step() {
let fails = |args: &[&str]| {
let out = Command::new(BIN).args(args).stdin(Stdio::null()).output().unwrap();
assert!(!out.status.success(), "{args:?}");
(String::from_utf8_lossy(&out.stderr).to_string(), json_lines(&out.stdout).len())
};
let bad = |name: &str, text: &[u8], wanted: &[&str]| {
let path = tmp(name);
std::fs::write(&path, text).unwrap();
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", path.to_str().unwrap()]);
assert!(wanted.iter().all(|w| e.contains(w)), "{name}: {e}");
assert_eq!(printed, 0, "{name}: nothing runs");
};
bad("number.json", b"[1]", &["step 1 (entry 1)", "must be an object"]);
bad("both.json", br#"[{"command":"select.all"},{"command":"select.all","id":"select.none"}]"#, &["step 2 (entry 2)", "not both"]);
bad("no-command.json", br#"[{"params":{}}]"#, &["step 1 (entry 1)", "no `command`"]);
bad("id-number.json", br#"[{"command":3}]"#, &["`command` must be a string"]);
bad("params-array.json", br#"[{"command":"select.all","params":[1]}]"#, &["`params` of select.all must be a JSON object"]);
bad("typo.json", br#"[{"command":"select.all","param":{}}]"#, &["unknown key `param`"]);
bad("broken.json", b"[{\"command\":", &["--commands", "broken.json"]);
bad("lines.jsonl", b"{\"command\":\"select.all\"}\n\n{oops}\n", &["line 3"]);
bad("line-array.jsonl", b"{\"command\":\"select.all\"}\n[1]\n", &["step 2 (line 2)"]);
bad("latin1.json", b"[{\"command\":\"select.all\",\"params\":{\"name\":\"\xe9\"}}]", &["not UTF-8"]);
let many = "{\"command\":\"select.all\"}\n".repeat(100_001);
bad("many.jsonl", many.as_bytes(), &["more than 100000 steps"]);
// Past 64 MiB (a sparse file: nothing is written).
let huge = tmp("huge.json");
std::fs::File::create(&huge).unwrap().set_len(64 * 1024 * 1024 + 1).unwrap();
let (e, _) = fails(&["run", "--commands", huge.to_str().unwrap()]);
assert!(e.contains("more than 64 MiB"), "{e}");
let (e, _) = fails(&["run", "--commands", tmp("missing.json").to_str().unwrap()]);
assert!(e.contains("missing.json"), "{e}");
// `--params` sets the `--cmd` just before it, never a step of the file.
let ok = tmp("ok.json");
std::fs::write(&ok, br#"[{"command":"select.all"}]"#).unwrap();
let (e, _) = fails(&["run", "--cmd", "select.all", "--commands", ok.to_str().unwrap(), "--params", "{}"]);
assert!(e.contains("--params must follow a --cmd"), "{e}");
let (e, _) = fails(&["run", "--commands", "-", "--commands", "-"]);
assert!(e.contains("standard input is read once"), "{e}");
// A step that fails is named by its number, after the steps before it ran.
let failing = tmp("failing.json");
std::fs::write(&failing, br#"[{"command":"select.all"},{"command":"nope.nothing"}]"#).unwrap();
let (e, printed) = fails(&["run", "--cmd", "file.new", "--commands", failing.to_str().unwrap()]);
assert!(e.contains("step 2 (nope.nothing)"), "{e}");
assert_eq!(printed, 2);
}
#[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();