1use std::path::{Path, PathBuf};
20
21use base64::Engine as _;
22use serde_json::{Value, json};
23
24const DIFF: [u8; 4] = [255, 0, 128, 255];
26const MAX_BYTES: usize = 256 << 20;
28
29#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
31pub struct Tolerance {
32 pub channel: u8,
34 pub pixels: usize,
36}
37
38impl Tolerance {
39 pub fn parse(s: &str) -> Result<Tolerance, String> {
41 let mut t = Tolerance::default();
42 for part in s.split(',').map(str::trim).filter(|p| !p.is_empty()) {
43 let (k, v) = part.split_once('=').ok_or_else(|| format!("tolerance `{part}`: expected key=value"))?;
44 match k.trim() {
45 "channel" => t.channel = v.trim().parse().map_err(|_| format!("tolerance channel={v}: 0 to 255"))?,
46 "pixels" => t.pixels = v.trim().parse().map_err(|_| format!("tolerance pixels={v}: a count"))?,
47 other => return Err(format!("tolerance `{other}`: channel or pixels")),
48 }
49 }
50 Ok(t)
51 }
52
53 fn to_json(self) -> Value {
54 json!({"channel": self.channel, "pixels": self.pixels})
55 }
56
57 fn from_json(v: &Value) -> Option<Tolerance> {
58 let channel = u8::try_from(v.get("channel")?.as_u64()?).ok()?;
59 let pixels = usize::try_from(v.get("pixels")?.as_u64()?).ok()?;
60 Some(Tolerance { channel, pixels })
61 }
62}
63
64#[derive(Debug, Clone, PartialEq)]
66pub struct Snapshots {
67 pub dir: PathBuf,
69 pub tolerance: Option<Tolerance>,
71 pub update: bool,
73}
74
75impl Snapshots {
76 pub fn from_env() -> Snapshots {
79 let dir = std::env::var_os("ASHLAR_SNAPSHOT_DIR").map(PathBuf::from).unwrap_or_else(|| PathBuf::from("tests/snapshots"));
80 let update = std::env::var("ASHLAR_UPDATE_SNAPSHOTS").is_ok_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"));
81 Snapshots { dir, tolerance: None, update }
82 }
83
84 pub fn in_dir(dir: impl Into<PathBuf>) -> Snapshots {
86 Snapshots { dir: dir.into(), tolerance: None, update: false }
87 }
88
89 pub fn check(&self, name: &str, reply: &Value) -> Result<Outcome, String> {
93 check_name(name)?;
94 let base = self.dir.join(format!("{name}.png"));
95 let meta_path = self.dir.join(format!("{name}.meta.json"));
96 let new_path = self.dir.join(format!("{name}-new.png"));
97 let diff_path = self.dir.join(format!("{name}-diff.png"));
98 let png = png_bytes(reply)?;
99 let now = Meta::of(reply);
100 if reply.get("renderer").and_then(Value::as_str) == Some("none") {
101 return Ok(Outcome::Skipped(format!("snapshot `{name}`: no GPU renderer, the screenshot is grey; not compared")));
102 }
103 let img = decode(&png)?;
104 if self.update {
105 let mut meta = now.to_json(img.width, img.height);
106 if let Some(t) = self.tolerance {
107 meta["tolerance"] = t.to_json();
108 }
109 write(&base, &png)?;
110 let text = serde_json::to_string_pretty(&meta).map_err(|e| e.to_string())?;
111 write(&meta_path, format!("{text}\n").as_bytes())?;
112 remove(&new_path);
113 remove(&diff_path);
114 return Ok(Outcome::Written(base));
115 }
116 let Ok(old_png) = std::fs::read(&base) else {
117 write(&new_path, &png)?;
118 return Err(format!(
119 "snapshot `{name}`: no baseline at {}; the screenshot is in {}. Run with ASHLAR_UPDATE_SNAPSHOTS=1 to record it",
120 base.display(),
121 new_path.display()
122 ));
123 };
124 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);
125 if let Some(note) = now.other_device(&old_meta) {
126 return Ok(Outcome::Skipped(format!("snapshot `{name}`: {note}; not compared (record one here with ASHLAR_UPDATE_SNAPSHOTS=1)")));
127 }
128 let tol = self.tolerance.or_else(|| old_meta.get("tolerance").and_then(Tolerance::from_json)).unwrap_or_default();
129 let old = decode(&old_png)?;
130 let fail = |why: String| -> Result<Outcome, String> {
131 write(&new_path, &png)?;
132 let mut msg = format!("snapshot `{name}` differs from {}: {why}. New: {}", base.display(), new_path.display());
133 if old.width == img.width && old.height == img.height {
134 write(&diff_path, &encode(&diff(&old, &img, tol.channel), img.width, img.height)?)?;
135 msg.push_str(&format!(", differences (magenta): {}", diff_path.display()));
136 }
137 msg.push_str(". If the change is intended, rerun with ASHLAR_UPDATE_SNAPSHOTS=1");
138 Err(msg)
139 };
140 if (old.width, old.height) != (img.width, img.height) {
141 return fail(format!("the size is {}x{}, the baseline's {}x{}", img.width, img.height, old.width, old.height));
142 }
143 let off = differing(&old, &img, tol.channel);
144 if off > tol.pixels {
145 return fail(format!("{off} pixel(s) differ by more than {} (allowed: {})", tol.channel, tol.pixels));
146 }
147 remove(&new_path);
148 remove(&diff_path);
149 Ok(Outcome::Matched { differing: off })
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub enum Outcome {
156 Matched { differing: usize },
158 Written(PathBuf),
160 Skipped(String),
162}
163
164impl Outcome {
165 pub fn text(&self) -> String {
167 match self {
168 Outcome::Matched { differing: 0 } => "matches".into(),
169 Outcome::Matched { differing } => format!("matches ({differing} pixel(s) within tolerance)"),
170 Outcome::Written(p) => format!("wrote {}", p.display()),
171 Outcome::Skipped(note) => format!("note: {note}"),
172 }
173 }
174
175 pub fn to_json(&self) -> Value {
177 match self {
178 Outcome::Matched { differing } => json!({"snapshot": "matched", "differing": differing}),
179 Outcome::Written(p) => json!({"snapshot": "written", "path": p}),
180 Outcome::Skipped(note) => json!({"snapshot": "skipped", "note": note}),
181 }
182 }
183}
184
185struct Meta {
187 adapter: String,
188 backend: String,
189 scale: Option<f64>,
190}
191
192impl Meta {
193 fn of(reply: &Value) -> Meta {
194 let s = |k: &str| reply.get(k).and_then(Value::as_str).unwrap_or("unknown").to_string();
195 Meta { adapter: s("adapter"), backend: s("backend"), scale: reply.get("scale").and_then(Value::as_f64) }
196 }
197
198 fn to_json(&self, width: u32, height: u32) -> Value {
199 json!({"adapter": self.adapter, "backend": self.backend, "scale": self.scale, "width": width, "height": height})
200 }
201
202 fn other_device(&self, old: &Value) -> Option<String> {
204 let field = |k: &str| old.get(k).and_then(Value::as_str).filter(|v| *v != "unknown");
205 for (k, now) in [("adapter", &self.adapter), ("backend", &self.backend)] {
206 if let Some(then) = field(k)
207 && now != "unknown"
208 && then != now
209 {
210 return Some(format!("the baseline was rendered on {k} `{then}`, this run on `{now}`"));
211 }
212 }
213 None
214 }
215}
216
217fn check_name(name: &str) -> Result<(), String> {
219 let ok = !name.is_empty()
220 && name.len() <= 128
221 && !name.starts_with('.')
222 && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
223 if ok { Ok(()) } else { Err(format!("snapshot name `{name}`: use letters, digits, `-`, `_` and `.`")) }
224}
225
226fn png_bytes(reply: &Value) -> Result<Vec<u8>, String> {
227 let b64 = reply.get("png").and_then(Value::as_str).filter(|s| !s.is_empty()).ok_or("the reply carried no picture")?;
228 base64::engine::general_purpose::STANDARD.decode(b64.trim()).map_err(|e| format!("the picture is not base64: {e}"))
229}
230
231struct Rgba {
233 width: u32,
234 height: u32,
235 px: Vec<u8>,
236}
237
238fn decode(bytes: &[u8]) -> Result<Rgba, String> {
239 let mut dec = png::Decoder::new_with_limits(std::io::Cursor::new(bytes), png::Limits { bytes: MAX_BYTES });
240 dec.set_transformations(png::Transformations::normalize_to_color8());
241 let mut reader = dec.read_info().map_err(|e| format!("not a PNG: {e}"))?;
242 let size = reader.output_buffer_size().filter(|n| *n <= MAX_BYTES).ok_or("the PNG is too large")?;
243 let mut buf = vec![0; size];
244 let info = reader.next_frame(&mut buf).map_err(|e| format!("bad PNG: {e}"))?;
245 buf.truncate(info.buffer_size());
246 let px = match info.color_type {
247 png::ColorType::Rgba => buf,
248 png::ColorType::Rgb => buf.as_chunks::<3>().0.iter().flat_map(|&[r, g, b]| [r, g, b, 255]).collect(),
249 png::ColorType::GrayscaleAlpha => buf.as_chunks::<2>().0.iter().flat_map(|&[g, a]| [g, g, g, a]).collect(),
250 png::ColorType::Grayscale => buf.iter().flat_map(|g| [*g, *g, *g, 255]).collect(),
251 png::ColorType::Indexed => return Err("indexed PNGs are not supported".into()),
252 };
253 Ok(Rgba { width: info.width, height: info.height, px })
254}
255
256fn encode(px: &[u8], width: u32, height: u32) -> Result<Vec<u8>, String> {
257 let mut out = Vec::new();
258 let mut enc = png::Encoder::new(&mut out, width, height);
259 enc.set_color(png::ColorType::Rgba);
260 enc.set_depth(png::BitDepth::Eight);
261 let mut w = enc.write_header().map_err(|e| e.to_string())?;
262 w.write_image_data(px).map_err(|e| e.to_string())?;
263 w.finish().map_err(|e| e.to_string())?;
264 Ok(out)
265}
266
267fn pixel_differs(a: &[u8; 4], b: &[u8; 4], channel: u8) -> bool {
268 a.iter().zip(b).any(|(x, y)| x.abs_diff(*y) > channel)
269}
270
271fn differing(old: &Rgba, new: &Rgba, channel: u8) -> usize {
272 old.px.as_chunks::<4>().0.iter().zip(new.px.as_chunks::<4>().0).filter(|(a, b)| pixel_differs(a, b, channel)).count()
273}
274
275fn diff(old: &Rgba, new: &Rgba, channel: u8) -> Vec<u8> {
277 let mut out = new.px.clone();
278 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)) {
279 if pixel_differs(a, b, channel) {
280 o.copy_from_slice(&DIFF);
281 }
282 }
283 out
284}
285
286fn write(path: &Path, bytes: &[u8]) -> Result<(), String> {
287 if let Some(d) = path.parent().filter(|d| !d.as_os_str().is_empty()) {
288 std::fs::create_dir_all(d).map_err(|e| format!("cannot create {}: {e}", d.display()))?;
289 }
290 std::fs::write(path, bytes).map_err(|e| format!("cannot write {}: {e}", path.display()))
291}
292
293fn remove(path: &Path) {
295 let _ = std::fs::remove_file(path);
296}
297
298#[cfg(test)]
299#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
300mod tests {
301 use super::*;
302
303 fn shot(colour: [u8; 4], odd: [u8; 4], adapter: &str) -> Value {
305 let mut px: Vec<u8> = (0..8).flat_map(|_| colour).collect();
306 px[20..24].copy_from_slice(&odd);
307 let png = encode(&px, 4, 2).unwrap();
308 json!({"png": base64::engine::general_purpose::STANDARD.encode(png), "renderer": "wgpu", "adapter": adapter, "backend": "Metal", "scale": 2.0})
309 }
310
311 const RED: [u8; 4] = [200, 0, 0, 255];
312
313 fn update(dir: &Path) -> Snapshots {
314 Snapshots { update: true, ..Snapshots::in_dir(dir) }
315 }
316
317 #[test]
318 fn update_writes_the_baseline_and_its_meta() {
319 let t = tempfile::tempdir().unwrap();
320 let out = update(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap();
321 assert_eq!(out, Outcome::Written(t.path().join("home.png")));
322 let meta: Value = serde_json::from_str(&std::fs::read_to_string(t.path().join("home.meta.json")).unwrap()).unwrap();
323 assert_eq!(meta, json!({"adapter": "GPU A", "backend": "Metal", "scale": 2.0, "width": 4, "height": 2}));
324 assert_eq!(Snapshots::in_dir(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap(), Outcome::Matched { differing: 0 });
325 }
326
327 #[test]
328 fn a_changed_colour_fails_and_writes_new_and_diff() {
329 let t = tempfile::tempdir().unwrap();
330 update(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap();
331 let e = Snapshots::in_dir(t.path()).check("home", &shot(RED, [0, 0, 255, 255], "GPU A")).unwrap_err();
332 assert!(e.contains("1 pixel(s) differ") && e.contains("ASHLAR_UPDATE_SNAPSHOTS=1"), "{e}");
333 let d = decode(&std::fs::read(t.path().join("home-diff.png")).unwrap()).unwrap();
334 assert_eq!(&d.px[20..24], &DIFF);
335 assert_eq!(&d.px[0..4], &RED);
336 assert!(t.path().join("home-new.png").exists());
337 let loose = Snapshots { tolerance: Some(Tolerance { channel: 0, pixels: 1 }), ..Snapshots::in_dir(t.path()) };
339 assert_eq!(loose.check("home", &shot(RED, [0, 0, 255, 255], "GPU A")).unwrap(), Outcome::Matched { differing: 1 });
340 assert!(!t.path().join("home-diff.png").exists());
341 }
342
343 #[test]
344 fn another_adapter_or_no_gpu_is_skipped() {
345 let t = tempfile::tempdir().unwrap();
346 update(t.path()).check("home", &shot(RED, RED, "GPU A")).unwrap();
347 let out = Snapshots::in_dir(t.path()).check("home", &shot([0, 0, 0, 255], RED, "GPU B")).unwrap();
348 assert!(matches!(&out, Outcome::Skipped(n) if n.contains("GPU A") && n.contains("GPU B")), "{out:?}");
349 let mut grey = shot(RED, RED, "GPU A");
350 grey["renderer"] = json!("none");
351 assert!(matches!(Snapshots::in_dir(t.path()).check("home", &grey).unwrap(), Outcome::Skipped(_)));
352 }
353
354 #[test]
355 fn missing_baselines_sizes_and_names_fail_clearly() {
356 let t = tempfile::tempdir().unwrap();
357 let e = Snapshots::in_dir(t.path()).check("none", &shot(RED, RED, "GPU A")).unwrap_err();
358 assert!(e.contains("no baseline"), "{e}");
359 assert!(Snapshots::in_dir(t.path()).check("../up", &shot(RED, RED, "GPU A")).is_err());
360 let tiny = encode(&RED, 1, 1).unwrap();
361 std::fs::write(t.path().join("small.png"), tiny).unwrap();
362 let e = Snapshots::in_dir(t.path()).check("small", &shot(RED, RED, "GPU A")).unwrap_err();
363 assert!(e.contains("4x2") && e.contains("1x1"), "{e}");
364 }
365
366 #[test]
367 fn tolerances_parse_and_are_recorded() {
368 assert_eq!(Tolerance::parse("channel=2,pixels=4").unwrap(), Tolerance { channel: 2, pixels: 4 });
369 assert_eq!(Tolerance::parse("pixels=9").unwrap(), Tolerance { channel: 0, pixels: 9 });
370 assert!(Tolerance::parse("channel=300").is_err());
371 assert!(Tolerance::parse("colour=1").is_err());
372 let t = tempfile::tempdir().unwrap();
373 let rec = Snapshots { tolerance: Some(Tolerance { channel: 0, pixels: 1 }), ..update(t.path()) };
374 rec.check("home", &shot(RED, RED, "GPU A")).unwrap();
375 assert_eq!(Snapshots::in_dir(t.path()).check("home", &shot(RED, [1, 2, 3, 255], "GPU A")).unwrap(), Outcome::Matched { differing: 1 });
377 }
378}