From 78c29329a4340aa3322c1355d75d9ba0ed96841a Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Fri, 17 Apr 2026 18:31:47 +0200 Subject: [PATCH] Add gif recording prototype --- crates/egui_kittest/Cargo.toml | 4 + crates/egui_kittest/src/config.rs | 18 ++ crates/egui_kittest/src/lib.rs | 164 ++++++++++++- crates/egui_kittest/src/recording.rs | 270 +++++++++++++++++++++ crates/egui_kittest/src/renderer.rs | 4 +- crates/egui_kittest/tests/recording.rs | 124 ++++++++++ crates/egui_kittest/tests/recording_env.rs | 65 +++++ 7 files changed, 646 insertions(+), 3 deletions(-) create mode 100644 crates/egui_kittest/src/recording.rs create mode 100644 crates/egui_kittest/tests/recording.rs create mode 100644 crates/egui_kittest/tests/recording_env.rs diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index 2d26e6bd8eba..8b5bf36e2fc8 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -26,6 +26,9 @@ wgpu = ["dep:egui-wgpu", "dep:pollster", "dep:image", "dep:wgpu", "eframe?/wgpu" ## Adds a dify-based image snapshot utility. snapshot = ["dep:dify", "dep:image", "dep:open", "dep:tempfile", "image/png"] +## Record a test session as an animated GIF or PNG sequence. +recording = ["dep:image", "image/gif", "image/png"] + ## Allows testing eframe::App eframe = ["dep:eframe", "eframe/accesskit"] @@ -62,6 +65,7 @@ tempfile = { workspace = true, optional = true } egui = { workspace = true, features = ["default_fonts"] } image = { workspace = true, features = ["png"] } egui_extras = { workspace = true, features = ["image", "http"] } +tempfile = { workspace = true } [lints] workspace = true diff --git a/crates/egui_kittest/src/config.rs b/crates/egui_kittest/src/config.rs index a1859f232068..e8f17b84f497 100644 --- a/crates/egui_kittest/src/config.rs +++ b/crates/egui_kittest/src/config.rs @@ -25,6 +25,14 @@ pub struct Config { /// Default is 0. failed_pixel_count_threshold: usize, + /// When `true`, every harness automatically records itself and writes a GIF to + /// `{output_path}/failures/{test_name}.gif` on test failure. + /// + /// Requires the `recording` feature; ignored otherwise. + /// Default is `false`. + #[cfg_attr(not(feature = "recording"), allow(dead_code))] + save_gif_on_failure: bool, + windows: OsConfig, mac: OsConfig, linux: OsConfig, @@ -36,6 +44,7 @@ impl Default for Config { output_path: PathBuf::from("tests/snapshots"), threshold: 0.6, failed_pixel_count_threshold: 0, + save_gif_on_failure: false, windows: Default::default(), mac: Default::default(), linux: Default::default(), @@ -113,6 +122,15 @@ impl Config { pub fn output_path(&self) -> PathBuf { self.output_path.clone() } + + /// Whether harnesses should automatically record themselves and save a GIF on test failure. + /// + /// Configurable via `kittest.toml`. Requires the `recording` feature; ignored otherwise. + /// Default is `false`. + #[cfg(feature = "recording")] + pub fn save_gif_on_failure(&self) -> bool { + self.save_gif_on_failure + } } #[cfg(feature = "snapshot")] diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index 507829d3ddf8..bb4a2c56ec9f 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -14,12 +14,17 @@ pub use crate::snapshot::*; mod app_kind; mod config; mod node; +#[cfg(feature = "recording")] +mod recording; mod renderer; #[cfg(feature = "wgpu")] mod texture_to_image; #[cfg(feature = "wgpu")] pub mod wgpu; +#[cfg(feature = "recording")] +pub use crate::recording::{RecordKind, RecordingError, RecordingOptions, RecordingTrigger}; + // re-exports: pub use { self::{builder::*, node::*, renderer::*}, @@ -87,6 +92,9 @@ pub struct Harness<'a, State = ()> { default_snapshot_options: SnapshotOptions, #[cfg(feature = "snapshot")] snapshot_results: SnapshotResults, + + #[cfg(feature = "recording")] + recording: Option, } impl Debug for Harness<'_, State> { @@ -174,9 +182,29 @@ impl<'a, State> Harness<'a, State> { #[cfg(feature = "snapshot")] snapshot_results: SnapshotResults::default(), + + #[cfg(feature = "recording")] + recording: None, }; // Run the harness until it is stable, ensuring that all Areas are shown and animations are done harness.run_ok(); + + #[cfg(all(feature = "recording", feature = "snapshot"))] + { + // Env var takes precedence (always saves), then config (only saves on failure). + let auto_mode = if recording::record_env_enabled() { + Some(recording::AutoSaveMode::Always) + } else if config::config().save_gif_on_failure() { + Some(recording::AutoSaveMode::OnFailure) + } else { + None + }; + if let Some(mode) = auto_mode { + let options = recording::RecordingOptions::gif(std::path::PathBuf::new(), 10.0); + harness.recording = Some(recording::RecordingState::new(options).with_auto_save(mode)); + } + } + harness } @@ -274,6 +302,9 @@ impl<'a, State> Harness<'a, State> { ); self.renderer.handle_delta(&output.textures_delta); self.output = output; + + #[cfg(feature = "recording")] + self.capture_frame_if_recording(false); } /// Calculate the rect that includes all popups and tooltips. @@ -359,6 +390,10 @@ impl<'a, State> Harness<'a, State> { }); } } + + #[cfg(feature = "recording")] + self.capture_frame_if_recording(true); + Ok(steps) } @@ -640,7 +675,7 @@ impl<'a, State> Harness<'a, State> { /// /// # Errors /// Returns an error if the rendering fails. - #[cfg(any(feature = "wgpu", feature = "snapshot"))] + #[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording"))] pub fn render(&mut self) -> Result { let mut output = self.output.clone(); @@ -666,6 +701,62 @@ impl<'a, State> Harness<'a, State> { self.renderer.render(&self.ctx, &output) } + /// Start recording the test session. + /// + /// Captures one frame per [`Self::step`] (or per [`Self::run`], depending on the + /// configured [`RecordingTrigger`]). Replaces any previously active recording. + /// Call [`Self::finish_recording`] to write the output. + /// + /// Requires a renderer (e.g. enable the `wgpu` feature, or set one via + /// [`HarnessBuilder::renderer`]). + #[cfg(feature = "recording")] + pub fn start_recording(&mut self, options: RecordingOptions) { + self.recording = Some(recording::RecordingState::new(options)); + } + + /// Stop the active recording and write its output (GIF or PNG sequence). + /// + /// # Errors + /// Returns [`RecordingError::NotRecording`] if no recording is active, or an I/O / encode + /// error if writing fails. + #[cfg(feature = "recording")] + pub fn finish_recording(&mut self) -> Result<(), RecordingError> { + let state = self.recording.take().ok_or(RecordingError::NotRecording)?; + state.save() + } + + /// Whether a recording is currently active. + #[cfg(feature = "recording")] + pub fn is_recording(&self) -> bool { + self.recording.is_some() + } + + /// Render the current frame and append it to the active recording according to its trigger. + /// Called from [`Self::_step`] (with `after_run = false`) and at the end of [`Self::_try_run`] + /// (with `after_run = true`). + #[cfg(feature = "recording")] + fn capture_frame_if_recording(&mut self, after_run: bool) { + let Some(state) = self.recording.as_mut() else { + return; + }; + if !state.should_capture(after_run) { + return; + } + match self.render() { + Ok(image) => { + if let Some(state) = self.recording.as_mut() { + state.push_frame(image); + } + } + Err(err) => { + #[expect(clippy::print_stderr)] + { + eprintln!("egui_kittest recording: render failed, skipping frame: {err}"); + } + } + } + } + /// Get the root viewport output fn root_viewport_output(&self) -> &egui::ViewportOutput { self.output @@ -683,6 +774,77 @@ impl<'a, State> Harness<'a, State> { } } +/// Save the in-progress recording (auto-started by `save_gif_on_failure` or `KITTEST_RECORD`) +/// when the harness is dropped. +/// +/// Recordings started by an explicit `start_recording` call are *not* saved here — the user +/// is expected to call `finish_recording`. +#[cfg(all(feature = "recording", feature = "snapshot"))] +#[expect(clippy::print_stderr)] // Drop path: stderr is the only signal we have. +impl Drop for Harness<'_, State> { + fn drop(&mut self) { + let Some(mut state) = self.recording.take() else { + return; + }; + let Some(mode) = state.auto_save_mode else { + // Explicit recording — discard if not finished. + return; + }; + + let should_save = match mode { + recording::AutoSaveMode::Always => true, + recording::AutoSaveMode::OnFailure => { + std::thread::panicking() || self.snapshot_results.has_errors() + } + }; + if !should_save { + return; + } + + let subdir = match mode { + recording::AutoSaveMode::Always => "recordings", + recording::AutoSaveMode::OnFailure => "failures", + }; + let name = std::thread::current() + .name() + .map(sanitize_thread_name) + .unwrap_or_else(default_recording_name); + let resolved_path = config::config() + .output_path() + .join(subdir) + .join(format!("{name}.gif")); + + // Replace the placeholder path with the resolved one. + if let recording::RecordKind::Gif { path, .. } = &mut state.options.kind { + *path = resolved_path.clone(); + } + + match state.save() { + Ok(()) => eprintln!("egui_kittest: saved GIF to {}", resolved_path.display()), + Err(err) => eprintln!( + "egui_kittest: failed to save GIF to {}: {err}", + resolved_path.display() + ), + } + } +} + +#[cfg(all(feature = "recording", feature = "snapshot"))] +fn sanitize_thread_name(name: &str) -> String { + // Test thread names look like `module::tests::name` — make that filesystem-safe. + name.replace(|c: char| !c.is_alphanumeric() && c != '_' && c != '-', "_") +} + +#[cfg(all(feature = "recording", feature = "snapshot"))] +fn default_recording_name() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("recording-{ts}") +} + /// Utilities for stateless harnesses. impl<'a> Harness<'a> { /// Create a new Harness with the given ui closure. diff --git a/crates/egui_kittest/src/recording.rs b/crates/egui_kittest/src/recording.rs new file mode 100644 index 000000000000..a89815d158e8 --- /dev/null +++ b/crates/egui_kittest/src/recording.rs @@ -0,0 +1,270 @@ +//! Capture a [`crate::Harness`] session as an animated GIF or a sequence of PNG files. +//! +//! See [`crate::Harness::start_recording`] / [`crate::Harness::finish_recording`]. + +use std::fs::File; +use std::io::BufWriter; +use std::path::{Path, PathBuf}; + +use image::RgbaImage; +use image::codecs::gif::{GifEncoder, Repeat}; + +/// What kind of output to produce when the recording is finished. +#[derive(Debug, Clone)] +pub enum RecordKind { + /// Save an animated GIF to `path` (looping forever). + Gif { + /// Where to write the GIF. + path: PathBuf, + + /// Frames per second. The GIF spec stores delays in 10 ms ticks, + /// so frame rates that aren't a divisor of 100 fps will be slightly approximated. + frame_rate: f32, + }, + + /// Save a sequence of PNG files (`frame_0000.png`, `frame_0001.png`, ...) into `directory`. + PngSequence { + /// Directory to write the PNG files into. It will be created if missing. + directory: PathBuf, + }, +} + +/// When to capture a frame during a recording session. +#[derive(Debug, Clone, Copy, Default)] +pub enum RecordingTrigger { + /// Render after every step. If the rendered frame is byte-identical to the + /// previously captured frame, drop it. + /// + /// This is the default and produces the smallest recordings for typical UIs, + /// since most steps don't visibly change anything. + #[default] + DiffEveryStep, + + /// Render after every step. Keep every frame, even if visually identical. + EveryStep, + + /// Capture exactly one frame at the end of each [`crate::Harness::run`] call. + /// No frames are captured during plain [`crate::Harness::step`] calls. + OnRun, + + /// Capture every `N`-th step. `EveryNSteps(1)` is equivalent to [`Self::EveryStep`]. + EveryNSteps(u32), +} + +/// Configuration for a recording session. Pass to [`crate::Harness::start_recording`]. +#[derive(Debug, Clone)] +pub struct RecordingOptions { + /// What output to produce. + pub kind: RecordKind, + + /// When to capture a frame. Defaults to [`RecordingTrigger::DiffEveryStep`]. + pub trigger: RecordingTrigger, +} + +impl RecordingOptions { + /// Record a GIF at `path` with the default trigger ([`RecordingTrigger::DiffEveryStep`]) + /// and the given frame rate. + pub fn gif(path: impl Into, frame_rate: f32) -> Self { + Self { + kind: RecordKind::Gif { + path: path.into(), + frame_rate, + }, + trigger: RecordingTrigger::default(), + } + } + + /// Record a PNG sequence into `directory` with the default trigger + /// ([`RecordingTrigger::DiffEveryStep`]). + pub fn png_sequence(directory: impl Into) -> Self { + Self { + kind: RecordKind::PngSequence { + directory: directory.into(), + }, + trigger: RecordingTrigger::default(), + } + } + + /// Replace the trigger. + #[must_use] + pub fn with_trigger(mut self, trigger: RecordingTrigger) -> Self { + self.trigger = trigger; + self + } +} + +/// Errors produced when finishing a recording. +#[derive(Debug)] +pub enum RecordingError { + /// No recording was active when [`crate::Harness::finish_recording`] was called. + NotRecording, + + /// Failed to create or write to the output file/directory. + Io { path: PathBuf, err: std::io::Error }, + + /// Failed to encode the GIF. + Encode(image::ImageError), +} + +impl std::fmt::Display for RecordingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotRecording => write!(f, "No recording is currently active"), + Self::Io { path, err } => write!(f, "I/O error writing {}: {err}", path.display()), + Self::Encode(err) => write!(f, "Failed to encode recording: {err}"), + } + } +} + +impl std::error::Error for RecordingError {} + +impl From for RecordingError { + fn from(err: image::ImageError) -> Self { + Self::Encode(err) + } +} + +/// How a recording auto-started by the harness should be saved on `Drop`. +#[derive(Debug, Clone, Copy)] +pub(crate) enum AutoSaveMode { + /// Save only when the test failed. Path resolved to `{output}/failures/{name}.gif`. + OnFailure, + /// Save unconditionally (e.g. driven by `KITTEST_RECORD`). + /// Path resolved to `{output}/recordings/{name}.gif`. + Always, +} + +/// In-memory state of an active recording. Stored on the [`crate::Harness`]. +pub(crate) struct RecordingState { + pub(crate) options: RecordingOptions, + pub(crate) frames: Vec, + pub(crate) last_frame: Option, + pub(crate) step_counter: u32, + /// Set when the recording was started automatically by the harness (config or env var) + /// rather than by an explicit `start_recording` call. Drives the `Drop` save path. + pub(crate) auto_save_mode: Option, +} + +impl RecordingState { + pub(crate) fn new(options: RecordingOptions) -> Self { + Self { + options, + frames: Vec::new(), + last_frame: None, + step_counter: 0, + auto_save_mode: None, + } + } + + pub(crate) fn with_auto_save(mut self, mode: AutoSaveMode) -> Self { + self.auto_save_mode = Some(mode); + self + } + + /// Decide whether to capture a frame on this tick. + /// + /// `after_run` is true when the call site is the end of [`crate::Harness::run`] + /// (used by the [`RecordingTrigger::OnRun`] trigger). Other triggers fire only on + /// per-step ticks (`after_run == false`). + pub(crate) fn should_capture(&mut self, after_run: bool) -> bool { + match self.options.trigger { + RecordingTrigger::DiffEveryStep | RecordingTrigger::EveryStep => !after_run, + RecordingTrigger::OnRun => after_run, + RecordingTrigger::EveryNSteps(n) => { + if after_run { + return false; + } + let n = n.max(1); + let counter = self.step_counter; + self.step_counter = self.step_counter.wrapping_add(1); + counter.is_multiple_of(n) + } + } + } + + /// Push a freshly rendered frame, applying the configured diffing policy. + pub(crate) fn push_frame(&mut self, image: RgbaImage) { + if matches!(self.options.trigger, RecordingTrigger::DiffEveryStep) { + if let Some(prev) = &self.last_frame + && prev.as_raw() == image.as_raw() + { + return; + } + self.last_frame = Some(image.clone()); + } + self.frames.push(image); + } + + pub(crate) fn save(self) -> Result<(), RecordingError> { + match self.options.kind { + RecordKind::Gif { path, frame_rate } => save_gif(&path, &self.frames, frame_rate), + RecordKind::PngSequence { directory } => save_png_sequence(&directory, &self.frames), + } + } +} + +fn save_gif(path: &Path, frames: &[RgbaImage], frame_rate: f32) -> Result<(), RecordingError> { + if let Some(parent) = path.parent() + && !parent.as_os_str().is_empty() + { + std::fs::create_dir_all(parent).map_err(|err| RecordingError::Io { + path: parent.to_path_buf(), + err, + })?; + } + + let file = File::create(path).map_err(|err| RecordingError::Io { + path: path.to_path_buf(), + err, + })?; + let writer = BufWriter::new(file); + let mut encoder = GifEncoder::new(writer); + encoder.set_repeat(Repeat::Infinite)?; + + let denom = frame_rate.max(0.1).round().clamp(1.0, u32::MAX as f32) as u32; + let frame_delay = image::Delay::from_numer_denom_ms(1000, denom); + // Hold the final frame for a full second so the loop point is obvious. + let final_delay = image::Delay::from_numer_denom_ms(1000, 1); + + let last_idx = frames.len().saturating_sub(1); + for (i, frame) in frames.iter().enumerate() { + let delay = if i == last_idx { final_delay } else { frame_delay }; + let frame = image::Frame::from_parts(frame.clone(), 0, 0, delay); + encoder.encode_frame(frame)?; + } + Ok(()) +} + +/// Name of the environment variable that enables auto-recording for every harness in the process. +/// +/// When set to `1` / `true` / `yes`, every harness records itself and saves a GIF to +/// `{snapshot_output}/recordings/{test_name}.gif` when dropped (regardless of pass/fail). +pub const RECORD_ENV_VAR: &str = "KITTEST_RECORD"; + +/// Read [`RECORD_ENV_VAR`] once and cache the result. +pub(crate) fn record_env_enabled() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| match std::env::var(RECORD_ENV_VAR) { + Ok(value) => matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ), + Err(_) => false, + }) +} + +fn save_png_sequence(directory: &Path, frames: &[RgbaImage]) -> Result<(), RecordingError> { + std::fs::create_dir_all(directory).map_err(|err| RecordingError::Io { + path: directory.to_path_buf(), + err, + })?; + + for (i, frame) in frames.iter().enumerate() { + let path = directory.join(format!("frame_{i:04}.png")); + frame.save(&path).map_err(|err| match err { + image::ImageError::IoError(io_err) => RecordingError::Io { path, err: io_err }, + other => RecordingError::Encode(other), + })?; + } + Ok(()) +} diff --git a/crates/egui_kittest/src/renderer.rs b/crates/egui_kittest/src/renderer.rs index 0806c4ead53b..ea06a773257d 100644 --- a/crates/egui_kittest/src/renderer.rs +++ b/crates/egui_kittest/src/renderer.rs @@ -12,7 +12,7 @@ pub trait TestRenderer { /// /// # Errors /// Returns an error if the rendering fails. - #[cfg(any(feature = "wgpu", feature = "snapshot"))] + #[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording"))] fn render( &mut self, ctx: &egui::Context, @@ -62,7 +62,7 @@ impl TestRenderer for LazyRenderer { } } - #[cfg(any(feature = "wgpu", feature = "snapshot"))] + #[cfg(any(feature = "wgpu", feature = "snapshot", feature = "recording"))] fn render( &mut self, ctx: &egui::Context, diff --git a/crates/egui_kittest/tests/recording.rs b/crates/egui_kittest/tests/recording.rs new file mode 100644 index 000000000000..d42ad351f0a4 --- /dev/null +++ b/crates/egui_kittest/tests/recording.rs @@ -0,0 +1,124 @@ +#![cfg(all(feature = "recording", feature = "wgpu"))] + +use egui_kittest::{Harness, RecordingOptions, RecordingTrigger}; +use kittest::Queryable as _; +use tempfile::tempdir; + +fn counter_harness(value: &mut u32) -> Harness<'_, &mut u32> { + Harness::builder() + .with_size(egui::Vec2::new(120.0, 60.0)) + .build_ui_state( + |ui, state| { + if ui.button(format!("count: {state}")).clicked() { + **state += 1; + } + }, + value, + ) +} + +fn count_pngs(dir: &std::path::Path) -> usize { + std::fs::read_dir(dir) + .expect("png output dir exists") + .filter_map(|e| e.ok()) + .filter(|e| e.path().extension().and_then(|x| x.to_str()) == Some("png")) + .count() +} + +#[test] +fn records_gif_with_diffing() { + let dir = tempdir().expect("tempdir"); + let gif_path = dir.path().join("counter.gif"); + + let mut value = 0u32; + let mut harness = counter_harness(&mut value); + harness.start_recording(RecordingOptions::gif(&gif_path, 12.0)); + + harness.run(); + harness.run(); + harness.get_by_label_contains("count").click(); + harness.run(); + + assert!(harness.is_recording()); + harness.finish_recording().expect("save gif"); + assert!(!harness.is_recording()); + + let metadata = std::fs::metadata(&gif_path).expect("gif exists"); + assert!(metadata.len() > 0, "GIF should be non-empty"); +} + +#[test] +fn records_png_sequence() { + let dir = tempdir().expect("tempdir"); + let out = dir.path().join("frames"); + + let mut value = 0u32; + let mut harness = counter_harness(&mut value); + harness.start_recording( + RecordingOptions::png_sequence(&out).with_trigger(RecordingTrigger::EveryStep), + ); + + harness.run(); + harness.get_by_label_contains("count").click(); + harness.run(); + + harness.finish_recording().expect("save png sequence"); + + assert!(count_pngs(&out) > 0, "expected at least one frame"); +} + +#[test] +fn diff_every_step_dedupes_unchanged_frames() { + let dir = tempdir().expect("tempdir"); + let out = dir.path().join("frames"); + + let mut value = 0u32; + let mut harness = counter_harness(&mut value); + harness.start_recording( + RecordingOptions::png_sequence(&out).with_trigger(RecordingTrigger::DiffEveryStep), + ); + + for _ in 0..6 { + harness.run(); + } + harness.finish_recording().expect("save png sequence"); + + assert_eq!( + count_pngs(&out), + 1, + "DiffEveryStep should dedupe unchanged frames" + ); +} + +#[test] +fn on_run_trigger_captures_per_run_only() { + let dir = tempdir().expect("tempdir"); + let out = dir.path().join("frames"); + + let mut value = 0u32; + let mut harness = counter_harness(&mut value); + harness.start_recording( + RecordingOptions::png_sequence(&out).with_trigger(RecordingTrigger::OnRun), + ); + + harness.run(); + harness.get_by_label_contains("count").click(); + harness.run(); + harness.run(); + + harness.finish_recording().expect("save png sequence"); + + assert_eq!( + count_pngs(&out), + 3, + "OnRun should produce one frame per run() call" + ); +} + +#[test] +fn finish_recording_without_start_errors() { + let mut value = 0u32; + let mut harness = counter_harness(&mut value); + let err = harness.finish_recording().expect_err("not recording"); + assert!(matches!(err, egui_kittest::RecordingError::NotRecording)); +} diff --git a/crates/egui_kittest/tests/recording_env.rs b/crates/egui_kittest/tests/recording_env.rs new file mode 100644 index 000000000000..3a4c478f3338 --- /dev/null +++ b/crates/egui_kittest/tests/recording_env.rs @@ -0,0 +1,65 @@ +//! Verifies that the `KITTEST_RECORD` env var auto-records harnesses and writes them next to +//! snapshots under `recordings/{test_name}.gif`. +//! +//! This is its own integration test binary because the env var is read once via `OnceLock`, +//! and we redirect the snapshot output path via `kittest.toml` lookup is process-global too. + +#![cfg(all(feature = "recording", feature = "snapshot", feature = "wgpu"))] +#![allow(unsafe_code)] // tests need set_var / set_current_dir + +use std::sync::OnceLock; + +use egui_kittest::Harness; +use tempfile::TempDir; + +static SETUP: OnceLock = OnceLock::new(); + +fn setup_env() -> &'static std::path::Path { + SETUP + .get_or_init(|| { + let dir = tempfile::tempdir().expect("tempdir"); + + // Point the snapshot output at our temp dir (used as the recording root). + std::fs::write(dir.path().join("kittest.toml"), "output_path = \".\"\n") + .expect("write kittest.toml"); + + // SAFETY: this OnceLock guarantees a single initialization before any harness + // reads the env var or cwd, so no concurrent env access happens. + unsafe { + std::env::set_current_dir(dir.path()).expect("chdir to tmp"); + std::env::set_var("KITTEST_RECORD", "1"); + } + dir + }) + .path() +} + +#[test] +fn env_var_records_to_recordings_dir() { + let dir = setup_env(); + + { + let mut harness = Harness::new_ui(|ui| { + ui.label("env-recorded"); + }); + harness.run(); + // Drop here triggers the auto-save. + } + + let recordings = dir.join("recordings"); + let entries: Vec<_> = std::fs::read_dir(&recordings) + .expect("recordings dir exists") + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.extension().and_then(|x| x.to_str()) == Some("gif")) + .collect(); + assert!( + !entries.is_empty(), + "KITTEST_RECORD should produce a GIF in {}", + recordings.display() + ); + for entry in &entries { + let len = std::fs::metadata(entry).expect("stat").len(); + assert!(len > 0, "GIF {} should be non-empty", entry.display()); + } +}