crates/nornir-robotui/src/snapshot.rs
//! WebP-in-warehouse UI snapshot harness (the render-oracle reference store).
//!
//! The render half of the test matrix renders a component to an
//! [`image::RgbaImage`] and diffs it against a stored **reference** image. This
//! module owns the *toolkit-neutral* half of that: lossless-WebP **encode**,
//! WebP **decode**, and a tolerant **pixel diff** — plus the thin
//! [`SnapshotStore`] seam and the [`assert_snapshot_warehouse`] entry point.
//!
//! The reference images live in the **warehouse** (lossless WebP, gitignored),
//! NOT in git — see `.nornir/snapshot-to-warehouse.md`. To keep this crate lean
//! (no iceberg / skade / warehouse deps leaking into the shared robot kit), the
//! warehouse coupling is held behind the [`SnapshotStore`] trait: `nornir`
//! implements it with the `ui_snapshots` Iceberg table and passes it in. This
//! crate only knows "fetch reference bytes for a key" / "store reference bytes
//! for a key".
//!
//! Encoder: the `webp` crate (libwebp) for **lossless** encode — the `image`
//! 0.25 native WebP encoder is decode-biased, so we use libwebp directly.
//!
//! Available only with the `snapshot` feature (which already pulls `image`).
use image::RgbaImage;
/// Identity of one reference image in the store. The warehouse keys a row on
/// `(repo, crate, test_name, theme)` and time-travels by `git_sha`; the harness
/// fills these from the running test's context.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotKey {
/// Owning repo (e.g. `nornir`, `facett`).
pub repo: String,
/// Owning crate / nested snapshot dir (e.g. `facett-graphview`).
pub krate: String,
/// The kittest snapshot id / test name.
pub test_name: String,
/// Render-matrix theme axis (`light` / `dark` / a device preset).
pub theme: String,
}
impl SnapshotKey {
pub fn new(
repo: impl Into<String>,
krate: impl Into<String>,
test_name: impl Into<String>,
theme: impl Into<String>,
) -> Self {
Self {
repo: repo.into(),
krate: krate.into(),
test_name: test_name.into(),
theme: theme.into(),
}
}
}
/// A stored reference image: the lossless-WebP bytes plus its dimensions and a
/// content hash (for dedup — an unchanged frame stores one blob, not N).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SnapshotRef {
pub webp: Vec<u8>,
pub width: u32,
pub height: u32,
pub content_sha: String,
}
/// The warehouse seam. `nornir` implements this over the `ui_snapshots` Iceberg
/// table; a test can implement it over an in-memory map. Kept deliberately
/// tiny so this crate needs no warehouse dependency.
pub trait SnapshotStore {
/// Fetch the latest stored reference for `key`, or `None` if none exists.
fn fetch_reference(&self, key: &SnapshotKey) -> Result<Option<SnapshotRef>, String>;
/// Store `r` as the new reference for `key`.
fn store_reference(&self, key: &SnapshotKey, r: &SnapshotRef) -> Result<(), String>;
}
/// What `assert_snapshot_warehouse` did.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SnapResult {
/// The rendered image matched the stored reference within threshold.
Match { diff_px: u64 },
/// No reference existed and we were not asked to update — recorded as the
/// FIRST reference (so the next run can diff). Carries the content hash.
Created { content_sha: String },
/// `NORNIR_UPDATE_SNAPSHOTS=1`: wrote a new reference into the store.
Updated { content_sha: String },
/// The rendered image differed from the reference beyond threshold. The
/// `actual_webp_path` (if writable) holds the rendered frame for inspection.
Mismatch {
diff_px: u64,
threshold_px: u64,
actual_webp_path: Option<std::path::PathBuf>,
},
}
impl SnapResult {
/// True when the assertion passed (match / created-first-ref / updated).
pub fn is_ok(&self) -> bool {
!matches!(self, SnapResult::Mismatch { .. })
}
}
/// Encode an [`RgbaImage`] to **lossless** WebP bytes via libwebp (the `webp`
/// crate). Lossless so the reference round-trips pixel-exact.
pub fn encode_lossless(img: &RgbaImage) -> Result<Vec<u8>, String> {
let (w, h) = img.dimensions();
let encoder = webp::Encoder::from_rgba(img.as_raw(), w, h);
let mem = encoder.encode_lossless().to_vec();
if mem.is_empty() {
return Err("webp lossless encode produced 0 bytes".into());
}
Ok(mem)
}
/// Decode WebP `bytes` back to an [`RgbaImage`].
pub fn decode(bytes: &[u8]) -> Result<RgbaImage, String> {
let decoded = webp::Decoder::new(bytes)
.decode()
.ok_or_else(|| "webp decode failed (not a WebP / corrupt)".to_string())?;
let img = decoded.to_image(); // -> image::DynamicImage
Ok(img.to_rgba8())
}
/// Count pixels whose per-channel difference exceeds `tol` (anti-aliasing
/// tolerance). Dimension mismatch is reported as "every pixel differs"
/// (`u64::MAX`) so it always trips the threshold.
pub fn diff_pixels(a: &RgbaImage, b: &RgbaImage, tol: u8) -> u64 {
if a.dimensions() != b.dimensions() {
return u64::MAX;
}
let mut n = 0u64;
for (pa, pb) in a.pixels().zip(b.pixels()) {
let differs = pa
.0
.iter()
.zip(pb.0.iter())
.any(|(x, y)| x.abs_diff(*y) > tol);
if differs {
n += 1;
}
}
n
}
/// How [`assert_snapshot_warehouse`] treats the reference.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SnapMode {
/// Diff against the stored reference (record it if none exists yet).
Check,
/// Overwrite the stored reference with the rendered frame.
Update,
}
impl SnapMode {
/// `Update` when `NORNIR_UPDATE_SNAPSHOTS=1`, else `Check`. The standard way
/// a test picks its mode (so a whole suite re-bakes refs with one env var).
pub fn from_env() -> Self {
if std::env::var("NORNIR_UPDATE_SNAPSHOTS").map(|v| v == "1").unwrap_or(false) {
SnapMode::Update
} else {
SnapMode::Check
}
}
}
/// Where a mismatch's `<name>.actual.webp` is written for inspection: the
/// warehouse root's `_snap_actual` dir if `$NORNIR_WAREHOUSE_ROOT` is set, else
/// the OS temp dir. Best-effort — a write failure just yields `None`.
fn actual_dir() -> std::path::PathBuf {
if let Ok(root) = std::env::var("NORNIR_WAREHOUSE_ROOT") {
std::path::PathBuf::from(root).join("_snap_actual")
} else {
std::env::temp_dir().join("nornir_snap_actual")
}
}
/// Per-test pixel threshold (anti-aliasing tolerant): how many pixels may
/// differ before the assertion fails. `channel_tol` is the per-channel slack.
#[derive(Debug, Clone, Copy)]
pub struct SnapThreshold {
/// Max number of differing pixels allowed.
pub max_diff_px: u64,
/// Per-channel value tolerance (a pixel counts as "differing" only if any
/// channel is off by more than this).
pub channel_tol: u8,
}
impl Default for SnapThreshold {
/// Exact match: zero pixels may differ, zero channel slack. Lossless WebP
/// round-trips pixel-exact, so the default reference contract is strict.
fn default() -> Self {
Self { max_diff_px: 0, channel_tol: 0 }
}
}
/// The harness entry point. Encodes `img` to lossless WebP, fetches the
/// reference for `key` from `store`, pixel-diffs within `threshold`, and:
/// * on match → [`SnapResult::Match`],
/// * on no reference → records the FIRST reference and returns
/// [`SnapResult::Created`] (so the next run has something to diff),
/// * on `mode = Update` (`NORNIR_UPDATE_SNAPSHOTS=1` via [`SnapMode::from_env`])
/// → overwrites the reference, returns [`SnapResult::Updated`],
/// * on mismatch → writes `<test_name>.actual.webp` for inspection and
/// returns [`SnapResult::Mismatch`].
pub fn assert_snapshot_warehouse(
key: &SnapshotKey,
img: &RgbaImage,
mode: SnapMode,
threshold: SnapThreshold,
store: &dyn SnapshotStore,
) -> Result<SnapResult, String> {
let (w, h) = img.dimensions();
let webp = encode_lossless(img)?;
let content_sha = nornir_hash::sha256_hex(&webp);
let existing = store.fetch_reference(key)?;
// Update mode: overwrite (or create) the reference unconditionally.
if mode == SnapMode::Update {
let r = SnapshotRef { webp, width: w, height: h, content_sha: content_sha.clone() };
store.store_reference(key, &r)?;
return Ok(SnapResult::Updated { content_sha });
}
let Some(reference) = existing else {
// No reference yet: record this frame as the first reference so the
// next run can diff against it. (Empty/missing handling.)
let r = SnapshotRef { webp, width: w, height: h, content_sha: content_sha.clone() };
store.store_reference(key, &r)?;
return Ok(SnapResult::Created { content_sha });
};
// Fast path: identical content hash ⇒ byte-identical ⇒ zero diff.
if reference.content_sha == content_sha {
return Ok(SnapResult::Match { diff_px: 0 });
}
// Decode the reference and pixel-diff.
let ref_img = decode(&reference.webp)?;
let diff_px = diff_pixels(img, &ref_img, threshold.channel_tol);
if diff_px <= threshold.max_diff_px {
return Ok(SnapResult::Match { diff_px });
}
// Mismatch: write the rendered frame for inspection (best-effort).
let actual_webp_path = (|| {
let dir = actual_dir();
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join(format!("{}.actual.webp", sanitize(&key.test_name)));
std::fs::write(&path, &webp).ok()?;
Some(path)
})();
Ok(SnapResult::Mismatch {
diff_px,
threshold_px: threshold.max_diff_px,
actual_webp_path,
})
}
/// Make a test name filesystem-safe for the `.actual.webp` dump.
fn sanitize(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use image::Rgba;
use std::cell::RefCell;
use std::collections::HashMap;
/// A simple deterministic test image: a `w×h` gradient.
fn gradient(w: u32, h: u32) -> RgbaImage {
RgbaImage::from_fn(w, h, |x, y| {
Rgba([(x % 256) as u8, (y % 256) as u8, ((x + y) % 256) as u8, 255])
})
}
/// LAW round-trip: encode a KNOWN image → lossless WebP → decode → assert
/// the dimensions and EVERY pixel match exactly (lossless ⇒ zero tolerance).
#[test]
fn webp_lossless_round_trip_is_pixel_exact() {
let img = gradient(40, 24);
let bytes = encode_lossless(&img).unwrap();
assert!(!bytes.is_empty(), "lossless encode produced bytes");
// RIFF/WEBP magic.
assert_eq!(&bytes[0..4], b"RIFF");
assert_eq!(&bytes[8..12], b"WEBP");
let back = decode(&bytes).unwrap();
assert_eq!(back.dimensions(), (40, 24), "dimensions survive the round trip");
assert_eq!(diff_pixels(&img, &back, 0), 0, "lossless ⇒ pixel-exact");
}
/// A one-pixel change is detected by the diff (inject-assert, not no-crash).
#[test]
fn diff_counts_changed_pixels() {
let a = gradient(10, 10);
let mut b = a.clone();
b.put_pixel(3, 3, Rgba([0, 0, 0, 255]));
assert_eq!(diff_pixels(&a, &b, 0), 1, "exactly one pixel changed");
// Dimension mismatch ⇒ MAX (always trips a threshold).
let c = gradient(11, 10);
assert_eq!(diff_pixels(&a, &c, 0), u64::MAX);
}
/// In-memory store so the harness is testable without the warehouse.
#[derive(Default)]
struct MemStore {
map: RefCell<HashMap<String, SnapshotRef>>,
}
impl MemStore {
fn k(key: &SnapshotKey) -> String {
format!("{}/{}/{}/{}", key.repo, key.krate, key.test_name, key.theme)
}
}
impl SnapshotStore for MemStore {
fn fetch_reference(&self, key: &SnapshotKey) -> Result<Option<SnapshotRef>, String> {
Ok(self.map.borrow().get(&Self::k(key)).cloned())
}
fn store_reference(&self, key: &SnapshotKey, r: &SnapshotRef) -> Result<(), String> {
self.map.borrow_mut().insert(Self::k(key), r.clone());
Ok(())
}
}
/// Full harness flow against an in-memory store: missing→Created,
/// same→Match, changed→Mismatch (+ writes the .actual.webp).
#[test]
fn assert_warehouse_created_match_mismatch() {
let store = MemStore::default();
let key = SnapshotKey::new("nornir", "nornir-robotui", "demo_tab", "light");
let img = gradient(32, 18);
let thr = SnapThreshold::default();
// 1) No reference → Created (first ref recorded).
let r = assert_snapshot_warehouse(&key, &img, SnapMode::Check, thr, &store).unwrap();
assert!(matches!(r, SnapResult::Created { .. }), "first run records the reference");
assert!(r.is_ok());
assert!(store.fetch_reference(&key).unwrap().is_some(), "reference now stored");
// 2) Same image → Match.
let r = assert_snapshot_warehouse(&key, &img, SnapMode::Check, thr, &store).unwrap();
assert!(matches!(r, SnapResult::Match { diff_px: 0 }), "identical frame matches");
// 3) Changed image → Mismatch + an .actual.webp on disk.
let mut changed = img.clone();
for x in 0..32 {
changed.put_pixel(x, 9, Rgba([255, 0, 0, 255]));
}
let r = assert_snapshot_warehouse(&key, &changed, SnapMode::Check, thr, &store).unwrap();
match r {
SnapResult::Mismatch { diff_px, threshold_px, actual_webp_path } => {
assert_eq!(diff_px, 32, "the full changed row differs");
assert_eq!(threshold_px, 0);
let p = actual_webp_path.expect("actual.webp written for inspection");
assert!(p.exists(), ".actual.webp exists on disk: {}", p.display());
// The dumped bytes decode back to the changed frame.
let dumped = decode(&std::fs::read(&p).unwrap()).unwrap();
assert_eq!(diff_pixels(&changed, &dumped, 0), 0);
let _ = std::fs::remove_file(&p);
}
other => panic!("expected Mismatch, got {other:?}"),
}
}
/// `SnapMode::Update` overwrites the reference and reports Updated — the
/// `NORNIR_UPDATE_SNAPSHOTS=1` re-bake path (via `SnapMode::from_env`).
#[test]
fn update_mode_overwrites_reference() {
let store = MemStore::default();
let key = SnapshotKey::new("nornir", "nornir-robotui", "upd", "dark");
let a = gradient(16, 16);
let b = {
let mut b = a.clone();
b.put_pixel(0, 0, Rgba([1, 2, 3, 255]));
b
};
let thr = SnapThreshold::default();
// Seed a reference with `a`.
assert_snapshot_warehouse(&key, &a, SnapMode::Check, thr, &store).unwrap();
// In Update mode, `b` overwrites it.
let r = assert_snapshot_warehouse(&key, &b, SnapMode::Update, thr, &store).unwrap();
assert!(matches!(r, SnapResult::Updated { .. }), "update mode writes a new ref");
// The stored reference is now `b` (next Check run matches `b`, not `a`).
let r = assert_snapshot_warehouse(&key, &b, SnapMode::Check, thr, &store).unwrap();
assert!(matches!(r, SnapResult::Match { .. }), "updated ref now matches b");
// …and the OLD frame `a` now MISMATCHES the (b) reference.
let r = assert_snapshot_warehouse(&key, &a, SnapMode::Check, thr, &store).unwrap();
assert!(matches!(r, SnapResult::Mismatch { .. }), "old frame no longer matches");
}
}