diff --git a/.isu/issues.json b/.isu/issues.json index c8edd48..d666d1c 100644 --- a/.isu/issues.json +++ b/.isu/issues.json @@ -3113,7 +3113,7 @@ ], "assigned": [], "author": "piefev", - "state": "open", + "state": "closed", "created_at": "2026-05-22T02:22:58Z" }, { diff --git a/CLAUDE.md b/CLAUDE.md index 39990ad..995991b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -205,6 +205,7 @@ Line-based, one command per line. `#` starts a comment. | `dump_console ` | Write captured console.log/warn/error output. | | `assert_dom_contains ""` | Fail if the DOM dump lacks ``. | | `assert_console_contains ""` | Fail if the console capture lacks ``. | +| `assert_screenshot_matches [--tolerance N] [--max-diff-pct P]` | Fail if `` differs from the golden `` by more than `--max-diff-pct` percent of pixels (default `0.1`), where a pixel counts as differing when any RGBA channel exceeds `--tolerance` (default `4`). On failure writes a magenta-highlighted diff image at `.diff.png`. `` is resolved relative to `--out-dir`; `` is resolved relative to the scenario file. | Output paths are resolved relative to `--out-dir` (default `.`). diff --git a/crates/e2e/src/lib.rs b/crates/e2e/src/lib.rs index 3688651..a4fa0a1 100644 --- a/crates/e2e/src/lib.rs +++ b/crates/e2e/src/lib.rs @@ -17,3 +17,4 @@ pub mod perf; pub mod png_out; pub mod render; pub mod scenario; +pub mod screenshot_diff; diff --git a/crates/e2e/src/scenario.rs b/crates/e2e/src/scenario.rs index 45646a1..33fb9ec 100644 --- a/crates/e2e/src/scenario.rs +++ b/crates/e2e/src/scenario.rs @@ -18,6 +18,12 @@ //! dump_console # write captured console //! assert_dom_contains # fail scenario if DOM dump does not contain //! assert_console_contains # fail scenario if console does not contain +//! assert_screenshot_matches [--tolerance N] [--max-diff-pct P] +//! # fail scenario if rendered PNG differs from +//! # expected (golden) PNG by more than the +//! # configured fraction; writes a magenta- +//! # highlight diff image next to +//! # on failure. //! perf_budget wall_ms # set scenario wall-clock budget (ms) //! perf_budget rss_mb # set scenario peak-RSS budget (MB) //! expect_no_panics # marker; the harness already catches every panic. @@ -62,6 +68,12 @@ pub enum Cmd { DumpConsole(PathBuf), AssertDomContains(String), AssertConsoleContains(String), + AssertScreenshotMatches { + rendered: PathBuf, + expected: PathBuf, + tolerance: u8, + max_diff_pct: f64, + }, ExpectNoPanics, PanicNow(String), PerfBudgetWallMs(u64), @@ -208,6 +220,7 @@ fn parse_line(line: &str) -> Result { } "assert_dom_contains" => Ok(Cmd::AssertDomContains(unquote(rest))), "assert_console_contains" => Ok(Cmd::AssertConsoleContains(unquote(rest))), + "assert_screenshot_matches" => parse_assert_screenshot_matches(rest), "expect_no_panics" => Ok(Cmd::ExpectNoPanics), "perf_budget" => { let mut it = rest.split_whitespace(); @@ -241,6 +254,63 @@ fn parse_line(line: &str) -> Result { } } +fn parse_assert_screenshot_matches(rest: &str) -> Result { + let mut positional: Vec = Vec::new(); + let mut tolerance: u8 = crate::screenshot_diff::DEFAULT_TOLERANCE; + let mut max_diff_pct: f64 = crate::screenshot_diff::DEFAULT_MAX_DIFF_PCT; + let mut it = rest.split_whitespace(); + while let Some(tok) = it.next() { + match tok { + "--tolerance" => { + let v = it + .next() + .ok_or("assert_screenshot_matches: --tolerance: missing value")?; + tolerance = v.parse().map_err(|_| { + "assert_screenshot_matches: --tolerance: value not a u8".to_string() + })?; + } + "--max-diff-pct" => { + let v = it + .next() + .ok_or("assert_screenshot_matches: --max-diff-pct: missing value")?; + let parsed: f64 = v.parse().map_err(|_| { + "assert_screenshot_matches: --max-diff-pct: value not a number".to_string() + })?; + if !parsed.is_finite() || parsed < 0.0 { + return Err( + "assert_screenshot_matches: --max-diff-pct: must be >= 0".to_string() + ); + } + max_diff_pct = parsed; + } + other if other.starts_with("--") => { + return Err(format!("assert_screenshot_matches: unknown flag {other:?}")); + } + other => positional.push(other.to_string()), + } + } + if positional.len() != 2 { + return Err(format!( + "assert_screenshot_matches: expected , got {} positional argument(s)", + positional.len() + )); + } + Ok(Cmd::AssertScreenshotMatches { + rendered: PathBuf::from(&positional[0]), + expected: PathBuf::from(&positional[1]), + tolerance, + max_diff_pct, + }) +} + +fn resolve(path: &Path, base: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} + fn unquote(s: &str) -> String { let s = s.trim(); if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') { @@ -384,6 +454,13 @@ fn cmd_label(cmd: &Cmd) -> String { Cmd::DumpConsole(p) => format!("dump_console {}", p.display()), Cmd::AssertDomContains(s) => format!("assert_dom_contains {s:?}"), Cmd::AssertConsoleContains(s) => format!("assert_console_contains {s:?}"), + Cmd::AssertScreenshotMatches { + rendered, expected, .. + } => format!( + "assert_screenshot_matches {} {}", + rendered.display(), + expected.display() + ), Cmd::ExpectNoPanics => "expect_no_panics".into(), Cmd::PanicNow(_) => "panic_now".into(), Cmd::PerfBudgetWallMs(n) => format!("perf_budget wall_ms {n}"), @@ -624,6 +701,50 @@ fn execute_cmd( steps.push((lineno, false, "assert_console_contains: no page".into())); } }, + Cmd::AssertScreenshotMatches { + rendered, + expected, + tolerance, + max_diff_pct, + } => { + let rendered_full = resolve(rendered, out_dir); + let expected_full = resolve(expected, scenario_dir); + let mut diff_path = rendered_full.clone(); + let diff_name = match rendered_full.file_name() { + Some(name) => { + let mut s = name.to_string_lossy().into_owned(); + s.push_str(".diff.png"); + s + } + None => "diff.png".to_string(), + }; + diff_path.set_file_name(diff_name); + match crate::screenshot_diff::diff_pngs( + &rendered_full, + &expected_full, + *tolerance, + *max_diff_pct, + Some(diff_path.as_path()), + ) { + Ok(res) => { + let summary = res.summary(); + if res.passed { + steps.push((lineno, true, summary)); + } else { + let detail = match &res.diff_image_path { + Some(p) => { + format!("{summary}; diff image at {}", p.display()) + } + None => summary, + }; + steps.push((lineno, false, detail)); + } + } + Err(e) => { + steps.push((lineno, false, format!("assert_screenshot_matches: {e}"))); + } + } + } Cmd::ExpectNoPanics => { steps.push((lineno, true, "expect_no_panics".into())); } diff --git a/crates/e2e/src/screenshot_diff.rs b/crates/e2e/src/screenshot_diff.rs new file mode 100644 index 0000000..45c4b2b --- /dev/null +++ b/crates/e2e/src/screenshot_diff.rs @@ -0,0 +1,319 @@ +//! Per-pixel screenshot diff helper used by the `assert_screenshot_matches` +//! scenario command. +//! +//! Loads two PNGs through the workspace `we-image` decoder, requires identical +//! dimensions, and walks pixel-by-pixel comparing each RGBA channel. A pixel +//! is counted as differing when any one of its R/G/B/A channels differs by +//! more than `tolerance` (an absolute per-channel difference). The pair +//! matches when the fraction of differing pixels (in percent of total pixels) +//! is at or below `max_diff_pct`. +//! +//! On mismatch, a diff image highlights every differing pixel in opaque +//! magenta (255, 0, 255, 255) so a human can spot the regression quickly. + +use std::path::{Path, PathBuf}; + +use we_image::pixel::Image; +use we_image::png::{decode_png, encode_png_rgba}; + +/// Default per-channel absolute tolerance applied when the scenario command +/// omits `--tolerance`. +pub const DEFAULT_TOLERANCE: u8 = 4; + +/// Default maximum allowed differing fraction, in percent of total pixels. +pub const DEFAULT_MAX_DIFF_PCT: f64 = 0.1; + +/// Outcome of a single screenshot comparison. +#[derive(Debug, Clone)] +pub struct DiffResult { + /// Total number of pixels in the (equally-dimensioned) images. + pub total_pixels: u64, + /// Number of pixels that differ by more than the tolerance. + pub differing_pixels: u64, + /// Width of both images. + pub width: u32, + /// Height of both images. + pub height: u32, + /// Per-channel absolute tolerance used for this comparison. + pub tolerance: u8, + /// Maximum allowed differing fraction, in percent of total pixels. + pub max_diff_pct: f64, + /// `differing_pixels / total_pixels` as a percentage (0..=100). + pub diff_pct: f64, + /// True if `diff_pct <= max_diff_pct`. + pub passed: bool, + /// Path to the diff image, if one was written on failure. + pub diff_image_path: Option, +} + +impl DiffResult { + /// Structured one-line summary used in scenario step logs. + pub fn summary(&self) -> String { + let match_pct = 100.0 - self.diff_pct; + format!( + "screenshot_matches: {:.2}% match ({}/{} px differ, tol={}, max_diff={:.4}%)", + match_pct, self.differing_pixels, self.total_pixels, self.tolerance, self.max_diff_pct, + ) + } +} + +/// Errors produced by the diff helper. +#[derive(Debug)] +pub enum DiffError { + /// One of the two image files could not be read from disk. + Read { path: PathBuf, source: String }, + /// One of the two PNG files failed to decode. + Decode { path: PathBuf, source: String }, + /// The two images do not have identical dimensions. + DimensionMismatch { + rendered: (u32, u32), + expected: (u32, u32), + }, +} + +impl std::fmt::Display for DiffError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Read { path, source } => write!(f, "read {}: {source}", path.display()), + Self::Decode { path, source } => write!(f, "decode {}: {source}", path.display()), + Self::DimensionMismatch { rendered, expected } => write!( + f, + "dimensions differ: rendered {}x{} vs expected {}x{}", + rendered.0, rendered.1, expected.0, expected.1 + ), + } + } +} + +impl std::error::Error for DiffError {} + +/// Compare two PNG files on disk with the given thresholds. +/// +/// `rendered` is the image produced by the harness; `expected` is the golden. +/// When `diff_path` is `Some`, a diff image is written there if the comparison +/// fails (pixels matching within tolerance keep the rendered colour; pixels +/// that differ are painted opaque magenta). +pub fn diff_pngs( + rendered: &Path, + expected: &Path, + tolerance: u8, + max_diff_pct: f64, + diff_path: Option<&Path>, +) -> Result { + let rendered_img = load_png(rendered)?; + let expected_img = load_png(expected)?; + if rendered_img.width != expected_img.width || rendered_img.height != expected_img.height { + return Err(DiffError::DimensionMismatch { + rendered: (rendered_img.width, rendered_img.height), + expected: (expected_img.width, expected_img.height), + }); + } + + let width = rendered_img.width; + let height = rendered_img.height; + let total_pixels = (width as u64) * (height as u64); + let pixel_count = total_pixels as usize; + + let mut diff_mask: Vec = Vec::with_capacity(pixel_count); + let mut differing_pixels: u64 = 0; + for i in 0..pixel_count { + let off = i * 4; + let differs = channel_diff(rendered_img.data[off], expected_img.data[off]) > tolerance + || channel_diff(rendered_img.data[off + 1], expected_img.data[off + 1]) > tolerance + || channel_diff(rendered_img.data[off + 2], expected_img.data[off + 2]) > tolerance + || channel_diff(rendered_img.data[off + 3], expected_img.data[off + 3]) > tolerance; + if differs { + differing_pixels += 1; + } + diff_mask.push(differs); + } + + let diff_pct = if total_pixels == 0 { + 0.0 + } else { + (differing_pixels as f64) * 100.0 / (total_pixels as f64) + }; + let passed = diff_pct <= max_diff_pct; + + let mut diff_image_path: Option = None; + if !passed { + if let Some(out) = diff_path { + write_diff_image(out, &rendered_img, &diff_mask)?; + diff_image_path = Some(out.to_path_buf()); + } + } + + Ok(DiffResult { + total_pixels, + differing_pixels, + width, + height, + tolerance, + max_diff_pct, + diff_pct, + passed, + diff_image_path, + }) +} + +fn load_png(path: &Path) -> Result { + let bytes = std::fs::read(path).map_err(|e| DiffError::Read { + path: path.to_path_buf(), + source: e.to_string(), + })?; + decode_png(&bytes).map_err(|e| DiffError::Decode { + path: path.to_path_buf(), + source: e.to_string(), + }) +} + +fn channel_diff(a: u8, b: u8) -> u8 { + a.abs_diff(b) +} + +fn write_diff_image(out: &Path, rendered: &Image, diff_mask: &[bool]) -> Result<(), DiffError> { + let mut out_data = rendered.data.clone(); + for (i, differs) in diff_mask.iter().enumerate() { + if *differs { + let off = i * 4; + out_data[off] = 255; + out_data[off + 1] = 0; + out_data[off + 2] = 255; + out_data[off + 3] = 255; + } + } + if let Some(parent) = out.parent() { + let _ = std::fs::create_dir_all(parent); + } + let png = encode_png_rgba(&out_data, rendered.width, rendered.height); + std::fs::write(out, png).map_err(|e| DiffError::Read { + path: out.to_path_buf(), + source: e.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_solid_png(path: &Path, w: u32, h: u32, color: [u8; 4]) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let mut data = Vec::with_capacity((w as usize) * (h as usize) * 4); + for _ in 0..(w as usize * h as usize) { + data.extend_from_slice(&color); + } + let png = encode_png_rgba(&data, w, h); + std::fs::write(path, png).expect("write png"); + } + + fn write_two_color_png(path: &Path, w: u32, h: u32, a: [u8; 4], b: [u8; 4], n_b: usize) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let total = (w as usize) * (h as usize); + let mut data = Vec::with_capacity(total * 4); + for i in 0..total { + if i < n_b { + data.extend_from_slice(&b); + } else { + data.extend_from_slice(&a); + } + } + let png = encode_png_rgba(&data, w, h); + std::fs::write(path, png).expect("write png"); + } + + fn tmp_dir(tag: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "we-e2e-diff-{tag}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).expect("create tmp dir"); + p + } + + #[test] + fn identical_pngs_pass_with_zero_diff() { + let dir = tmp_dir("identical"); + let a = dir.join("a.png"); + let b = dir.join("b.png"); + write_solid_png(&a, 4, 4, [10, 20, 30, 255]); + write_solid_png(&b, 4, 4, [10, 20, 30, 255]); + let res = diff_pngs(&a, &b, 0, 0.0, None).expect("diff"); + assert!(res.passed); + assert_eq!(res.differing_pixels, 0); + assert_eq!(res.total_pixels, 16); + assert_eq!(res.diff_pct, 0.0); + } + + #[test] + fn within_tolerance_counts_as_match() { + let dir = tmp_dir("within-tol"); + let a = dir.join("a.png"); + let b = dir.join("b.png"); + write_solid_png(&a, 2, 2, [100, 100, 100, 255]); + write_solid_png(&b, 2, 2, [103, 100, 100, 255]); + let res = diff_pngs(&a, &b, 4, 0.0, None).expect("diff"); + assert!(res.passed); + assert_eq!(res.differing_pixels, 0); + } + + #[test] + fn over_threshold_fails_and_writes_diff_image() { + let dir = tmp_dir("over-thr"); + let a = dir.join("a.png"); + let b = dir.join("b.png"); + let diff = dir.join("a.diff.png"); + // 4 of 16 pixels differ -> 25% diff, well over default 0.1%. + write_two_color_png(&a, 4, 4, [0, 0, 0, 255], [255, 255, 255, 255], 4); + write_solid_png(&b, 4, 4, [0, 0, 0, 255]); + let res = diff_pngs(&a, &b, 4, 0.1, Some(&diff)).expect("diff"); + assert!(!res.passed); + assert_eq!(res.differing_pixels, 4); + assert!((res.diff_pct - 25.0).abs() < 1e-9); + assert_eq!(res.diff_image_path.as_deref(), Some(diff.as_path())); + assert!(diff.exists(), "diff image should be written"); + // The diff image must contain at least one magenta pixel (255, 0, 255, 255). + let bytes = std::fs::read(&diff).expect("read diff"); + let decoded = decode_png(&bytes).expect("decode diff"); + let has_magenta = decoded + .data + .chunks_exact(4) + .any(|px| px == [255, 0, 255, 255]); + assert!(has_magenta, "diff image lacks magenta highlight"); + } + + #[test] + fn dimension_mismatch_is_an_error() { + let dir = tmp_dir("dim-mismatch"); + let a = dir.join("a.png"); + let b = dir.join("b.png"); + write_solid_png(&a, 4, 4, [0, 0, 0, 255]); + write_solid_png(&b, 3, 4, [0, 0, 0, 255]); + let err = diff_pngs(&a, &b, 0, 0.0, None).expect_err("dim mismatch"); + assert!(matches!(err, DiffError::DimensionMismatch { .. })); + } + + #[test] + fn summary_is_one_line_and_structured() { + let dir = tmp_dir("summary"); + let a = dir.join("a.png"); + let b = dir.join("b.png"); + write_solid_png(&a, 4, 4, [0, 0, 0, 255]); + write_solid_png(&b, 4, 4, [0, 0, 0, 255]); + let res = diff_pngs(&a, &b, 0, 0.0, None).expect("diff"); + let s = res.summary(); + assert!(!s.contains('\n')); + assert!(s.starts_with("screenshot_matches:")); + assert!(s.contains("100.00% match")); + assert!(s.contains("0/16 px differ")); + } +} diff --git a/crates/e2e/tests/screenshot_diff.rs b/crates/e2e/tests/screenshot_diff.rs new file mode 100644 index 0000000..7d16d18 --- /dev/null +++ b/crates/e2e/tests/screenshot_diff.rs @@ -0,0 +1,178 @@ +//! Integration tests for the `assert_screenshot_matches` scenario command. +//! +//! Covers parsing of the new command and end-to-end execution against tiny +//! PNG fixtures created on the fly: one matching pair (passes) and one +//! mismatching pair (fails and writes a diff image). + +use std::path::{Path, PathBuf}; + +use we_e2e::scenario::{self, Cmd}; +use we_image::png::encode_png_rgba; + +fn tmp_dir(name: &str) -> PathBuf { + let mut p = std::env::temp_dir(); + p.push(format!( + "we-e2e-scrshot-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).expect("create tmp dir"); + p +} + +fn write_solid_png(path: &Path, w: u32, h: u32, color: [u8; 4]) { + if let Some(parent) = path.parent() { + let _ = std::fs::create_dir_all(parent); + } + let mut data = Vec::with_capacity((w as usize) * (h as usize) * 4); + for _ in 0..(w as usize * h as usize) { + data.extend_from_slice(&color); + } + let png = encode_png_rgba(&data, w, h); + std::fs::write(path, png).expect("write png"); +} + +#[test] +fn assert_screenshot_matches_parses() { + let dir = tmp_dir("parse"); + let scenario_path = dir.join("p.we"); + std::fs::write( + &scenario_path, + "assert_screenshot_matches rendered.png expected.png --tolerance 8 --max-diff-pct 0.5\n", + ) + .expect("write scenario"); + let parsed = scenario::parse(&scenario_path).expect("parse"); + let cmd = parsed + .commands + .iter() + .find_map(|(_, c)| match c { + Cmd::AssertScreenshotMatches { + rendered, + expected, + tolerance, + max_diff_pct, + } => Some(( + rendered.clone(), + expected.clone(), + *tolerance, + *max_diff_pct, + )), + _ => None, + }) + .expect("AssertScreenshotMatches command"); + assert_eq!(cmd.0, Path::new("rendered.png")); + assert_eq!(cmd.1, Path::new("expected.png")); + assert_eq!(cmd.2, 8); + assert!((cmd.3 - 0.5).abs() < 1e-12); +} + +#[test] +fn assert_screenshot_matches_defaults_applied() { + let dir = tmp_dir("defaults"); + let scenario_path = dir.join("p.we"); + std::fs::write( + &scenario_path, + "assert_screenshot_matches rendered.png expected.png\n", + ) + .expect("write scenario"); + let parsed = scenario::parse(&scenario_path).expect("parse"); + let (_, _, tol, max_pct) = parsed + .commands + .iter() + .find_map(|(_, c)| match c { + Cmd::AssertScreenshotMatches { + rendered, + expected, + tolerance, + max_diff_pct, + } => Some(( + rendered.clone(), + expected.clone(), + *tolerance, + *max_diff_pct, + )), + _ => None, + }) + .expect("AssertScreenshotMatches command"); + assert_eq!(tol, we_e2e::screenshot_diff::DEFAULT_TOLERANCE); + assert!((max_pct - we_e2e::screenshot_diff::DEFAULT_MAX_DIFF_PCT).abs() < 1e-12); +} + +#[test] +fn assert_screenshot_matches_rejects_unknown_flag() { + let dir = tmp_dir("bad-flag"); + let scenario_path = dir.join("p.we"); + std::fs::write( + &scenario_path, + "assert_screenshot_matches a.png b.png --nope 1\n", + ) + .expect("write"); + let err = match scenario::parse(&scenario_path) { + Ok(_) => panic!("expected parse error"), + Err(e) => e, + }; + assert!(err.contains("unknown flag"), "err was: {err}"); +} + +#[test] +fn matching_pngs_pass_scenario() { + let dir = tmp_dir("match"); + let rendered = dir.join("rendered.png"); + let expected = dir.join("expected.png"); + write_solid_png(&rendered, 4, 4, [10, 20, 30, 255]); + write_solid_png(&expected, 4, 4, [10, 20, 30, 255]); + + let scenario_path = dir.join("scenario.we"); + std::fs::write( + &scenario_path, + "assert_screenshot_matches rendered.png expected.png\n", + ) + .expect("write scenario"); + let parsed = scenario::parse(&scenario_path).expect("parse"); + let report = scenario::run(&parsed, &dir); + assert!( + report.passed, + "report should pass: steps={:?}", + report.steps + ); + assert!(report + .steps + .iter() + .any(|(_, ok, msg)| *ok && msg.contains("screenshot_matches:"))); + // No diff image should be left behind when the comparison passes. + assert!(!dir.join("rendered.png.diff.png").exists()); +} + +#[test] +fn diverging_pngs_fail_and_write_diff_image() { + let dir = tmp_dir("diverge"); + let rendered = dir.join("rendered.png"); + let expected = dir.join("expected.png"); + // Every pixel differs by 255 in R/G/B: well over any reasonable tolerance. + write_solid_png(&rendered, 4, 4, [0, 0, 0, 255]); + write_solid_png(&expected, 4, 4, [255, 255, 255, 255]); + + let scenario_path = dir.join("scenario.we"); + std::fs::write( + &scenario_path, + "assert_screenshot_matches rendered.png expected.png --tolerance 4 --max-diff-pct 0.1\n", + ) + .expect("write scenario"); + let parsed = scenario::parse(&scenario_path).expect("parse"); + let report = scenario::run(&parsed, &dir); + assert!(!report.passed, "report should fail"); + let failing_step = report.steps.iter().find(|(_, ok, _)| !*ok).cloned(); + let msg = failing_step.expect("a failing step").2; + assert!(msg.contains("screenshot_matches:"), "msg was: {msg}"); + assert!(msg.contains("16/16 px differ"), "msg was: {msg}"); + let diff_image = dir.join("rendered.png.diff.png"); + assert!( + diff_image.exists(), + "expected diff image at {}", + diff_image.display() + ); +}