#![allow(clippy::expect_used, clippy::unwrap_used, clippy::panic, missing_docs)]
#[path = "support/approval.rs"]
mod approval_support;
use std::path::Path;
use std::process::{Command, Output};
use image::{Rgb, RgbImage};
fn save(dir: &Path, name: &str, v: u8) {
std::fs::create_dir_all(dir).expect("mkdir");
RgbImage::from_pixel(64, 64, Rgb([v, v, v]))
.save(dir.join(name))
.expect("save");
}
fn saccade(cwd: &Path, args: &[&str], paths: &[&Path]) -> Output {
Command::new(env!("CARGO_BIN_EXE_saccade"))
.current_dir(cwd)
.args(args)
.args(paths)
.output()
.expect("spawn")
}
fn text(o: &Output) -> String {
format!(
"{}{}",
String::from_utf8_lossy(&o.stdout),
String::from_utf8_lossy(&o.stderr)
)
}
#[test]
fn an_unrelated_output_directory_is_never_cleared() {
let tmp = tempfile::tempdir().unwrap();
let (base, cap, out) = (
tmp.path().join("base"),
tmp.path().join("cap"),
tmp.path().join("precious"),
);
save(&base, "a.png", 100);
save(&cap, "a.png", 100);
std::fs::create_dir_all(out.join("images/logo")).unwrap();
std::fs::write(out.join("images/logo/a.txt"), "keep me").unwrap();
let run = saccade(
tmp.path(),
&["compare"],
&[&base, &cap, Path::new("--out"), &out],
);
assert_eq!(run.status.code(), Some(2), "{}", text(&run));
assert!(
text(&run).contains("not a previous saccade output"),
"{}",
text(&run)
);
let view = saccade(
tmp.path(),
&["view"],
&[&base, &cap, Path::new("--out"), &out],
);
assert_eq!(view.status.code(), Some(2), "{}", text(&view));
assert_eq!(
std::fs::read_to_string(out.join("images/logo/a.txt")).unwrap(),
"keep me"
);
let again = tmp.path().join("report");
for _ in 0..2 {
let ok = saccade(
tmp.path(),
&["compare"],
&[&base, &cap, Path::new("--out"), &again],
);
assert_eq!(ok.status.code(), Some(0), "{}", text(&ok));
}
}
#[test]
fn outputs_next_to_capture_metadata_warn_unless_allowed() {
let tmp = tempfile::tempdir().unwrap();
let (base, cap, out) = (
tmp.path().join("base"),
tmp.path().join("cap"),
tmp.path().join("report"),
);
save(&base, "a.png", 100);
save(&cap, "a.png", 100);
for (name, flags, config) in [
("saccade-meta.json", vec![], None),
(
"cost-card.json",
vec!["--meta-name", "cost-card.json"],
None,
),
("configured.json", vec![], Some("configured.json")),
("capture.json", vec!["--meta-name", "other.json"], None),
] {
let sidecar = tmp.path().join(name);
std::fs::write(&sidecar, "{}").unwrap();
if let Some(name) = config {
std::fs::write(
tmp.path().join("saccade.toml"),
format!("meta_name = {name:?}\n"),
)
.unwrap();
}
let mut args = vec!["compare", "--json"];
args.extend(&flags);
let warned = saccade(tmp.path(), &args, &[&base, &cap, Path::new("--out"), &out]);
assert_eq!(warned.status.code(), Some(0), "{}", text(&warned));
let stderr = String::from_utf8_lossy(&warned.stderr);
assert!(
stderr.contains("--out is inside a capture directory"),
"{stderr}"
);
assert!(
stderr.contains(&format!("found {name} next to it")),
"{stderr}"
);
assert!(stderr.contains("--allow-out-near-captures"), "{stderr}");
serde_json::from_slice::<serde_json::Value>(&warned.stdout).expect("JSON stdout");
args.push("--allow-out-near-captures");
let allowed = saccade(tmp.path(), &args, &[&base, &cap, Path::new("--out"), &out]);
assert_eq!(allowed.status.code(), Some(0), "{}", text(&allowed));
assert!(!String::from_utf8_lossy(&allowed.stderr).contains("capture directory"));
std::fs::remove_file(sidecar).unwrap();
if config.is_some() {
std::fs::remove_file(tmp.path().join("saccade.toml")).unwrap();
}
}
let clean = saccade(
tmp.path(),
&["compare"],
&[&base, &cap, Path::new("--out"), &out],
);
assert_eq!(clean.status.code(), Some(0), "{}", text(&clean));
assert!(!String::from_utf8_lossy(&clean.stderr).contains("capture directory"));
}
#[test]
fn an_output_directory_inside_an_input_is_refused() {
let tmp = tempfile::tempdir().unwrap();
let (base, cap) = (tmp.path().join("base"), tmp.path().join("cap"));
save(&base, "a.png", 100);
save(&cap, "a.png", 100);
for inside in [base.join("report"), cap.join("deep/report")] {
let o = saccade(
tmp.path(),
&["compare"],
&[&base, &cap, Path::new("--out"), &inside],
);
assert_eq!(o.status.code(), Some(2), "{}", text(&o));
assert!(
text(&o).contains("inside an input directory"),
"{}",
text(&o)
);
assert!(!inside.exists(), "nothing is created inside an input");
}
let o = saccade(
tmp.path(),
&["view"],
&[&base, &cap, Path::new("--out"), &base.join("view")],
);
assert_eq!(o.status.code(), Some(2), "{}", text(&o));
}
#[test]
fn comparing_nothing_is_not_a_pass() {
let tmp = tempfile::tempdir().unwrap();
let (base, cap, out) = (
tmp.path().join("base"),
tmp.path().join("cap"),
tmp.path().join("out"),
);
std::fs::create_dir_all(&base).unwrap();
save(&cap, "a.png", 100);
let o = saccade(
tmp.path(),
&["compare"],
&[&base, &cap, Path::new("--out"), &out],
);
assert_eq!(o.status.code(), Some(1), "{}", text(&o));
assert!(text(&o).contains("nothing compared"), "{}", text(&o));
let allowed = saccade(
tmp.path(),
&["compare", "--allow-empty"],
&[&base, &cap, Path::new("--out"), &out],
);
assert_eq!(allowed.status.code(), Some(1), "{}", text(&allowed));
}
#[test]
fn a_local_hotspot_fails_only_when_hotspot_fail_is_set() {
let tmp = tempfile::tempdir().unwrap();
let (base, cap, out) = (
tmp.path().join("base"),
tmp.path().join("cap"),
tmp.path().join("out"),
);
save(&base, "a.png", 20);
save(&cap, "a.png", 20);
let mut img = RgbImage::from_pixel(64, 64, Rgb([20, 20, 20]));
for y in 28..34 {
for x in 28..34 {
img.put_pixel(x, y, Rgb([255, 255, 255]));
}
}
img.save(cap.join("a.png")).unwrap();
let args = ["compare", "--threshold", "0.05"];
let plain = saccade(tmp.path(), &args, &[&base, &cap, Path::new("--out"), &out]);
assert_eq!(plain.status.code(), Some(0), "{}", text(&plain));
assert!(
text(&plain).contains("pass with local change"),
"{}",
text(&plain)
);
let report: serde_json::Value =
serde_json::from_slice(&std::fs::read(out.join("saccade-report.v1.json")).unwrap())
.unwrap();
assert_eq!(report["entries"][0]["pass_with_local_change"], true);
let lean = saccade(
tmp.path(),
&["compare", "--threshold", "0.05", "--json"],
&[&base, &cap, Path::new("--out"), &out],
);
assert_eq!(lean.status.code(), Some(0), "{}", text(&lean));
let lean: serde_json::Value = serde_json::from_slice(&lean.stdout).unwrap();
assert_eq!(lean["data"]["pass_with_local_change"], 1);
assert_eq!(
lean["next_actions"][0]["reason_code"],
"pass_with_local_change"
);
let config = tmp.path().join("saccade.toml");
std::fs::write(&config, "hotspot_fail = 0.5\n").unwrap();
let strict = saccade(
tmp.path(),
&["compare", "--threshold", "0.05", "--config"],
&[&config, &base, &cap, Path::new("--out"), &out],
);
assert_eq!(strict.status.code(), Some(1), "{}", text(&strict));
}
#[test]
fn approve_refuses_captures_that_changed_after_review() {
let tmp = tempfile::tempdir().unwrap();
let (base, cap, out) = (
tmp.path().join("base"),
tmp.path().join("cap"),
tmp.path().join("out"),
);
save(&base, "a.png", 100);
save(&cap, "a.png", 200);
let report = out.join("saccade-report.v1.json");
let run = saccade(
tmp.path(),
&["compare"],
&[&base, &cap, Path::new("--out"), &out],
);
assert_eq!(run.status.code(), Some(1), "{}", text(&run));
let decision = approval_support::draft(&report, &tmp.path().join("plan"), &[], false);
save(&cap, "a.png", 250);
let approve = |extra: &[&str], baseline: &Path| {
let mut args = vec!["approve"];
args.extend(extra);
saccade(
tmp.path(),
&args,
&[
&cap,
baseline,
Path::new("--all-failing"),
&report,
Path::new("--decisions"),
&decision,
],
)
};
let refused = approve(&[], &base);
assert_eq!(refused.status.code(), Some(2), "{}", text(&refused));
assert!(
text(&refused).contains("changed since it was reviewed"),
"{}",
text(&refused)
);
let px = |p: &Path| {
image::open(p.join("a.png"))
.unwrap()
.to_rgb8()
.get_pixel(0, 0)[0]
};
assert_eq!(px(&base), 100, "the baseline is untouched");
let other = tmp.path().join("other");
save(&other, "a.png", 1);
let wrong = approve(&[], &other);
assert_eq!(wrong.status.code(), Some(2), "{}", text(&wrong));
assert!(text(&wrong).contains("not "), "{}", text(&wrong));
let forced = approve(&["--force"], &base);
assert_eq!(forced.status.code(), Some(2), "{}", text(&forced));
assert_eq!(px(&base), 100);
}