use std::path::{Path, PathBuf};
use base64::Engine as _;
use serde_json::{Value, json};
const DIFF: [u8; 4] = [255, 0, 128, 255];
const MAX_BYTES: usize = 256 << 20;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Tolerance {
pub channel: u8,
pub pixels: usize,
}
impl Tolerance {
pub fn parse(s: &str) -> Result<Tolerance, String> {
let mut t = Tolerance::default();
for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
let (k, v) = part.split_once('=').ok_or_else(|| format!("tolerance `{part}`: expected key=value"))?;
match k.trim() {
"channel" => t.channel = v.trim().parse().map_err(|_| format!("tolerance channel={v}: 0 to 255"))?,
"pixels" => t.pixels = v.trim().parse().map_err(|_| format!("tolerance pixels={v}: a count"))?,
other => return Err(format!("tolerance `{other}`: channel or pixels")),
}
}
Ok(t)
}
fn to_json(self) -> Value {
json!({"channel": self.channel, "pixels": self.pixels})
}
fn from_json(v: &Value) -> Option<Tolerance> {
let channel = u8::try_from(v.get("channel")?.as_u64()?).ok()?;
let pixels = usize::try_from(v.get("pixels")?.as_u64()?).ok()?;
Some(Tolerance { channel, pixels })
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Snapshots {
pub dir: PathBuf,
pub tolerance: Option<Tolerance>,
pub update: bool,
}
impl Snapshots {
pub fn from_env() -> Snapshots {
let dir = std::env::var_os("ASHLAR_SNAPSHOT_DIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("tests/snapshots"));
let update = std::env::var("ASHLAR_UPDATE_SNAPSHOTS").is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"));
Snapshots { dir, tolerance: None, update }
}
pub fn in_dir(dir: impl Into<PathBuf>) -> Snapshots {
Snapshots { dir: dir.into(), tolerance: None, update: false }
}
pub fn check(&self, name: &str, reply: &Value) -> Result<Outcome, String> {
check_name(name)?;
let base = self.dir.join(format!("{name}.png"));
let meta_path = self.dir.join(format!("{name}.meta.json"));
let new_path = self.dir.join(format!("{name}-new.png"));
let diff_path = self.dir.join(format!("{name}-diff.png"));
let png = png_bytes(reply)?;
let now = Meta::of(reply);
if reply.get("renderer").and_then(Value::as_str) == Some("none") {
return Ok(Outcome::Skipped(format!("snapshot `{name}`: no GPU renderer, the screenshot is grey; not compared")));
}
let img = decode(&png)?;
if self.update {
let mut meta = now.to_json(img.width, img.height);
if let Some(t) = self.tolerance {
meta["tolerance"] = t.to_json();
}
write(&base, &png)?;
let text = serde_json::to_string_pretty(&meta).map_err(|e| e.to_string())?;
write(&meta_path, format!("{text}\n").as_bytes())?;
remove(&new_path);
remove(&diff_path);
return Ok(Outcome::Written(base));
}
let Ok(old_png) = std::fs::read(&base) else {
write(&new_path, &png)?;
return Err(format!(
"snapshot `{name}`: no baseline at {}; the screenshot is in {}. Run with ASHLAR_UPDATE_SNAPSHOTS=1 to record it",
base.display(),
new_path.display()
));
};
let old_meta: Value = std::fs::read_to_string(&meta_path).ok().and_then(|s| serde_json::from_str(&s).ok()).unwrap_or(Value::Null);
if let Some(note) = now.other_device(&old_meta) {
return Ok(Outcome::Skipped(format!("snapshot `{name}`: {note}; not compared (record one here with ASHLAR_UPDATE_SNAPSHOTS=1)")));
}
let tol = self.tolerance.or_else(|| old_meta.get("tolerance").and_then(Tolerance::from_json)).unwrap_or_default();
let old = decode(&old_png)?;
let fail = |why: String| -> Result<Outcome, String> {
write(&new_path, &png)?;
let mut msg = format!("snapshot `{name}` differs from {}: {why}. New: {}", base.display(), new_path.display());
if old.width == img.width && old.height == img.height {
write(&diff_path, &encode(&diff(&old, &img, tol.channel), img.width, img.height)?)?;
msg.push_str(&format!(", differences (magenta): {}", diff_path.display()));
}
msg.push_str(". If the change is intended, rerun with ASHLAR_UPDATE_SNAPSHOTS=1");
Err(msg)
};
if (old.width, old.height) != (img.width, img.height) {
return fail(format!("the size is {}x{}, the baseline's {}x{}", img.width, img.height, old.width, old.height));
}
let off = differing(&old, &img, tol.channel);
if off > tol.pixels {
return fail(format!("{off} pixel(s) differ by more than {} (allowed: {})", tol.channel, tol.pixels));
}
remove(&new_path);
remove(&diff_path);
Ok(Outcome::Matched { differing: off })
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Matched { differing: usize },
Written(PathBuf),
Skipped(String),
}
impl Outcome {
pub fn text(&self) -> String {
match self {
Outcome::Matched { differing: 0 } => "matches".into(),
Outcome::Matched { differing } => format!("matches ({differing} pixel(s) within tolerance)"),
Outcome::Written(p) => format!("wrote {}", p.display()),
Outcome::Skipped(note) => format!("note: {note}"),
}
}
pub fn to_json(&self) -> Value {
match self {
Outcome::Matched { differing } => json!({"snapshot": "matched", "differing": differing}),
Outcome::Written(p) => json!({"snapshot": "written", "path": p}),
Outcome::Skipped(note) => json!({"snapshot": "skipped", "note": note}),
}
}
}
struct Meta {
adapter: String,
backend: String,
scale: Option<f64>,
}
impl Meta {
fn of(reply: &Value) -> Meta {
let s = |k: &str| reply.get(k).and_then(Value::as_str).unwrap_or("unknown").to_string();
Meta { adapter: s("adapter"), backend: s("backend"), scale: reply.get("scale").and_then(Value::as_f64) }
}
fn to_json(&self, width: u32, height: u32) -> Value {
json!({"adapter": self.adapter, "backend": self.backend, "scale": self.scale, "width": width, "height": height})
}
fn other_device(&self, old: &Value) -> Option<String> {
let field = |k: &str| old.get(k).and_then(Value::as_str).filter(|v| *v != "unknown");
for (k, now) in [("adapter", &self.adapter), ("backend", &self.backend)] {
if let Some(then) = field(k)
&& now != "unknown"
&& then != now
{
return Some(format!("the baseline was rendered on {k} `{then}`, this run on `{now}`"));
}
}
None
}
}
fn check_name(name: &str) -> Result<(), String> {
let ok = !name.is_empty()
&& name.len() <= 128
&& !name.starts_with('.')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
if ok { Ok(()) } else { Err(format!("snapshot name `{name}`: use letters, digits, `-`, `_` and `.`")) }
}
fn png_bytes(reply: &Value) -> Result<Vec<u8>, String> {
let b64 = reply.get("png").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or("the reply carried no picture")?;
base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| format!("the picture is not base64: {e}"))
}
struct Rgba {
width: u32,
height: u32,
px: Vec<u8>,
}
fn decode(bytes: &[u8]) -> Result<Rgba, String> {
let mut dec = png::Decoder::new_with_limits(std::io::Cursor::new(bytes), png::Limits { bytes: MAX_BYTES });
dec.set_transformations(png::Transformations::normalize_to_color8());
let mut reader = dec.read_info().map_err(|e| format!("not a PNG: {e}"))?;
let size = reader.output_buffer_size().filter(|n| *n <= MAX_BYTES).ok_or("the PNG is too large")?;
let mut buf = vec![0; size];
let info = reader.next_frame(&mut buf).map_err(|e| format!("bad PNG: {e}"))?;
buf.truncate(info.buffer_size());
let px = match info.color_type {
png::ColorType::Rgba => buf,
png::ColorType::Rgb => buf.as_chunks::<3>().0.iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect(),
png::ColorType::GrayscaleAlpha => buf.as_chunks::<2>().0.iter().flat_map(|&[g, a]| [g, g, g, a]).collect(),
png::ColorType::Grayscale => buf.iter().flat_map(|g| [*g, *g, *g, 255]).collect(),
png::ColorType::Indexed => return Err("indexed PNGs are not supported".into()),
};
Ok(Rgba { width: info.width, height: info.height, px })
}
fn encode(px: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
let mut enc = png::Encoder::new(&mut out, width, height);
enc.set_color(png::ColorType::Rgba);
enc.set_depth(png::BitDepth::Eight);
let mut w = enc.write_header().map_err(|e| e.to_string())?;
w.write_image_data(px).map_err(|e| e.to_string())?;
w.finish().map_err(|e| e.to_string())?;
Ok(out)
}
fn pixel_differs(a: &[u8; 4], b: &[u8; 4], channel: u8) -> bool {
a.iter().zip(b).any(|(x, y)| x.abs_diff(*y) > channel)
}
fn differing(old: &Rgba, new: &Rgba, channel: u8) -> usize {
old.px.as_chunks::<4>().0.iter().zip(new.px.as_chunks::<4>().0).filter(|(a, b)| pixel_differs(a, b, channel)).count()
}
fn diff(old: &Rgba, new: &Rgba, channel: u8) -> Vec<u8> {
let mut out = new.px.clone();
for (o, (a, b)) in out.as_chunks_mut::<4>().0.iter_mut().zip(old.px.as_chunks::<4>().0.iter().zip(new.px.as_chunks::<4>().0)) {
if pixel_differs(a, b, channel) {
o.copy_from_slice(&DIFF);
}
}
out
}
fn write(path: &Path, bytes: &[u8]) -> Result<(), String> {
if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?;
}
std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))
}
fn remove(path: &Path) {
let _ = std::fs::remove_file(path);
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod tests {
use super::*;
fn shot(colour: [u8; 4], odd: [u8; 4], adapter: &str) -> Value {
let mut px: Vec<u8> = (0..8).flat_map(|_| colour).collect();
px[20..24].copy_from_slice(&odd);
let png = encode(&px, 4, 2).unwrap();
json!({"png": base64::engine::general_purpose::STANDARD.encode(png), "renderer": "wgpu", "adapter": adapter, "backend": "Metal", "scale": 2.0})
}
const RED: [u8; 4] = [200, 0, 0, 255];
fn update(dir: &Path) -> Snapshots {
Snapshots { update: true, ..Snapshots::in_dir(dir) }
}
#[test]
fn update_writes_the_baseline_and_its_meta() {
let t = tempfile::tempdir().unwrap();
let out = update(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap();
assert_eq!(out, Outcome::Written(t.path().join("home.png")));
let meta: Value = serde_json::from_str(&std::fs::read_to_string(t.path().join("home.meta.json")).unwrap()).unwrap();
assert_eq!(meta, json!({"adapter": "GPU A", "backend": "Metal", "scale": 2.0, "width": 4, "height": 2}));
assert_eq!(Snapshots::in_dir(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap(), Outcome::Matched { differing: 0 });
}
#[test]
fn a_changed_colour_fails_and_writes_new_and_diff() {
let t = tempfile::tempdir().unwrap();
update(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap();
let e = Snapshots::in_dir(t.path()).check("home", &shot(RED, [0, 0, 255, 255], "GPU A")).unwrap_err();
assert!(e.contains("1 pixel(s) differ") && e.contains("ASHLAR_UPDATE_SNAPSHOTS=1"), "{e}");
let d = decode(&std::fs::read(t.path().join("home-diff.png")).unwrap()).unwrap();
assert_eq!(&d.px[20..24], &DIFF);
assert_eq!(&d.px[0..4], &RED);
assert!(t.path().join("home-new.png").exists());
let loose = Snapshots { tolerance: Some(Tolerance { channel: 0, pixels: 1 }), ..Snapshots::in_dir(t.path()) };
assert_eq!(loose.check("home", &shot(RED, [0, 0, 255, 255], "GPU A")).unwrap(), Outcome::Matched { differing: 1 });
assert!(!t.path().join("home-diff.png").exists());
}
#[test]
fn another_adapter_or_no_gpu_is_skipped() {
let t = tempfile::tempdir().unwrap();
update(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap();
let out = Snapshots::in_dir(t.path()).check("home", &shot([0, 0, 0, 255], RED, "GPU B")).unwrap();
assert!(matches!(&out, Outcome::Skipped(n) if n.contains("GPU A") && n.contains("GPU B")), "{out:?}");
let mut grey = shot(RED, RED, "GPU A");
grey["renderer"] = json!("none");
assert!(matches!(Snapshots::in_dir(t.path()).check("home", &grey).unwrap(), Outcome::Skipped(_)));
}
#[test]
fn missing_baselines_sizes_and_names_fail_clearly() {
let t = tempfile::tempdir().unwrap();
let e = Snapshots::in_dir(t.path()).check("none", &shot(RED, RED, "GPU A")).unwrap_err();
assert!(e.contains("no baseline"), "{e}");
assert!(Snapshots::in_dir(t.path()).check("../up", &shot(RED, RED, "GPU A")).is_err());
let tiny = encode(&RED, 1, 1).unwrap();
std::fs::write(t.path().join("small.png"), tiny).unwrap();
let e = Snapshots::in_dir(t.path()).check("small", &shot(RED, RED, "GPU A")).unwrap_err();
assert!(e.contains("4x2") && e.contains("1x1"), "{e}");
}
#[test]
fn tolerances_parse_and_are_recorded() {
assert_eq!(Tolerance::parse("channel=2,pixels=4").unwrap(), Tolerance { channel: 2, pixels: 4 });
assert_eq!(Tolerance::parse("pixels=9").unwrap(), Tolerance { channel: 0, pixels: 9 });
assert!(Tolerance::parse("channel=300").is_err());
assert!(Tolerance::parse("colour=1").is_err());
let t = tempfile::tempdir().unwrap();
let rec = Snapshots { tolerance: Some(Tolerance { channel: 0, pixels: 1 }), ..update(t.path()) };
rec.check("home", &shot(RED, RED, "GPU A")).unwrap();
assert_eq!(Snapshots::in_dir(t.path()).check("home", &shot(RED, [1, 2, 3, 255], "GPU A")).unwrap(), Outcome::Matched { differing: 1 });
}
}