169 lines
7.3 KiB
Rust
169 lines
7.3 KiB
Rust
//! `--json-out FILE` writes the JSON `run` and `eval` would print to a file the CLI makes
|
|
//! itself. A shell's `>` can re-encode stdout (Windows PowerShell writes UTF-16, or UTF-8 with a
|
|
//! byte-order mark), which strict JSON readers refuse; the file is plain UTF-8.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::{Command, Output};
|
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
|
|
use serde_json::{Value, json};
|
|
|
|
static NEXT: AtomicUsize = AtomicUsize::new(0);
|
|
|
|
struct Scratch(PathBuf);
|
|
|
|
impl Scratch {
|
|
fn new() -> Self {
|
|
let dir = std::env::temp_dir().join(format!("solidcraft-json-out-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed)));
|
|
std::fs::create_dir(&dir).unwrap();
|
|
Self(dir)
|
|
}
|
|
|
|
/// A box, and a parameter whose comment has a character outside ASCII.
|
|
fn script(&self) -> PathBuf {
|
|
let path = self.0.join("box.json");
|
|
let script = json!({"commands": [
|
|
{"command": "parameters.add", "params": {"name": "bore", "expression": "5 mm", "comment": "\u{d8} of the bore"}},
|
|
{"command": "solid.box", "params": {"length": 2, "width": 3, "height": 4}},
|
|
]});
|
|
std::fs::write(&path, script.to_string()).unwrap();
|
|
path
|
|
}
|
|
|
|
/// A filleted box whose fillet a later parameter change makes impossible: every command
|
|
/// succeeds, the timeline ends with a failing feature.
|
|
fn broken_script(&self) -> PathBuf {
|
|
let path = self.0.join("broken.json");
|
|
let script = json!({"commands": [
|
|
{"command": "parameters.add", "params": {"name": "r", "expression": "2 mm"}},
|
|
{"command": "solid.box", "params": {"length": 20, "width": 20, "height": 10}},
|
|
{"command": "solid.fillet", "params": {"edges": [[10, 0, 10]], "radius": "r"}},
|
|
{"command": "parameters.change", "params": {"name": "r", "expression": "50 mm"}},
|
|
]});
|
|
std::fs::write(&path, script.to_string()).unwrap();
|
|
path
|
|
}
|
|
}
|
|
|
|
impl Drop for Scratch {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.0);
|
|
}
|
|
}
|
|
|
|
fn cli() -> Command {
|
|
Command::new(env!("CARGO_BIN_EXE_solidcraft-cli"))
|
|
}
|
|
|
|
fn stdout_of(output: Output) -> Vec<u8> {
|
|
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
|
output.stdout
|
|
}
|
|
|
|
/// Timings differ from one run to the next.
|
|
fn without_timings(v: &mut Value) {
|
|
match v {
|
|
Value::Object(map) => {
|
|
map.remove("ms");
|
|
map.values_mut().for_each(without_timings);
|
|
}
|
|
Value::Array(items) => items.iter_mut().for_each(without_timings),
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// The file holds what stdout carried in the same command without `--json-out`: the same JSON,
|
|
/// laid out the same way, in UTF-8 with no byte-order mark (UTF-8's or UTF-16's), so a strict
|
|
/// reader accepts it.
|
|
fn assert_plain_utf8_json(file: &Path, stdout: &[u8]) -> Value {
|
|
let bytes = std::fs::read(file).unwrap();
|
|
assert!(matches!(bytes.first(), Some(b'{' | b'[')), "the file starts with the JSON itself, not a mark");
|
|
assert_eq!(bytes.last(), Some(&b'\n'), "like stdout, the file ends with a newline");
|
|
let lines = |b: &[u8]| b.iter().filter(|c| **c == b'\n').count();
|
|
assert_eq!(lines(&bytes), lines(stdout), "laid out like stdout");
|
|
let mut value: Value = serde_json::from_str(std::str::from_utf8(&bytes).unwrap()).unwrap();
|
|
let mut printed: Value = serde_json::from_slice(stdout).unwrap();
|
|
without_timings(&mut value);
|
|
without_timings(&mut printed);
|
|
assert_eq!(value, printed, "the file is what stdout would have carried");
|
|
value
|
|
}
|
|
|
|
#[test]
|
|
fn run_results_json_out_holds_what_stdout_would() {
|
|
let dir = Scratch::new();
|
|
let script = dir.script();
|
|
let file = dir.0.join("results.json");
|
|
let on_stdout = stdout_of(cli().arg("run").arg(&script).arg("--results").output().unwrap());
|
|
let output = cli().arg("run").arg(&script).arg("--results").arg("--json-out").arg(&file).output().unwrap();
|
|
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
|
assert!(output.stdout.is_empty(), "the JSON goes to the file, not to stdout");
|
|
let results = assert_plain_utf8_json(&file, &on_stdout);
|
|
assert_eq!(results.as_array().map(Vec::len), Some(2));
|
|
}
|
|
|
|
#[test]
|
|
fn run_json_out_without_results_holds_the_measurement_even_when_quiet() {
|
|
let dir = Scratch::new();
|
|
let script = dir.script();
|
|
let on_stdout = stdout_of(cli().arg("run").arg(&script).output().unwrap());
|
|
for (name, extra) in [("plain", &[][..]), ("quiet", &["--quiet"][..])] {
|
|
let file = dir.0.join(format!("measure-{name}.json"));
|
|
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
|
|
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
|
|
assert!(output.stdout.is_empty(), "{name}");
|
|
let measure = assert_plain_utf8_json(&file, &on_stdout);
|
|
assert_eq!(measure["body_count"], 1, "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn eval_json_out_holds_what_stdout_would() {
|
|
let dir = Scratch::new();
|
|
let script = dir.script();
|
|
let on_stdout = stdout_of(cli().arg("eval").arg(&script).output().unwrap());
|
|
// The option may come before the file to evaluate, and `inspect` is the same command.
|
|
for name in ["eval", "inspect"] {
|
|
let file = dir.0.join(format!("{name}.json"));
|
|
let output = cli().arg(name).arg("--json-out").arg(&file).arg(&script).output().unwrap();
|
|
assert!(output.status.success(), "{name}: {}", String::from_utf8_lossy(&output.stderr));
|
|
assert!(output.stdout.is_empty(), "{name}");
|
|
let measured = assert_plain_utf8_json(&file, &on_stdout);
|
|
assert_eq!(measured["body_count"], 1, "{name}");
|
|
// A character outside ASCII is written as it is, in UTF-8.
|
|
assert!(std::fs::read_to_string(&file).unwrap().contains("\u{d8} of the bore"), "{name}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn json_out_needs_a_file_and_fails_when_it_cannot_write() {
|
|
let dir = Scratch::new();
|
|
let script = dir.script();
|
|
for cmd in ["run", "eval"] {
|
|
let output = cli().arg(cmd).arg(&script).arg("--json-out").output().unwrap();
|
|
assert_eq!(output.status.code(), Some(1), "{cmd}");
|
|
assert!(String::from_utf8_lossy(&output.stderr).contains("--json-out needs a FILE"), "{cmd}");
|
|
assert!(output.stdout.is_empty(), "{cmd}");
|
|
|
|
let missing = dir.0.join("missing").join("out.json");
|
|
let output = cli().arg(cmd).arg(&script).arg("--json-out").arg(&missing).output().unwrap();
|
|
assert_eq!(output.status.code(), Some(1), "{cmd}");
|
|
assert!(!output.stderr.is_empty(), "{cmd}");
|
|
assert!(output.stdout.is_empty(), "{cmd}");
|
|
assert!(!missing.exists(), "{cmd}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn run_json_out_is_not_written_when_a_feature_fails() {
|
|
let dir = Scratch::new();
|
|
let script = dir.broken_script();
|
|
for (name, extra) in [("plain", &[][..]), ("results", &["--results"][..])] {
|
|
let file = dir.0.join(format!("failed-{name}.json"));
|
|
let output = cli().arg("run").arg(&script).args(extra).arg("--json-out").arg(&file).output().unwrap();
|
|
assert_eq!(output.status.code(), Some(1), "{name}");
|
|
assert!(String::from_utf8_lossy(&output.stderr).contains("nothing was written"), "{name}");
|
|
assert!(!file.exists(), "{name}");
|
|
}
|
|
}
|