diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index a770438c3..47f290b09 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -26,6 +26,13 @@ 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 a sequence of PNG files. +## +## Needs a renderer, so enable the `wgpu` feature too, unless you bring your own. +## Every harness then follows the egui textures, so that a recording can start at any time, +## but nothing is rendered until a recording starts. +recording = ["snapshot", "dep:image", "image/gif"] + ## Allows testing eframe::App eframe = ["dep:eframe", "eframe/accesskit"] @@ -63,6 +70,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/README.md b/crates/egui_kittest/README.md index 8711aeabb..3f66a1602 100644 --- a/crates/egui_kittest/README.md +++ b/crates/egui_kittest/README.md @@ -63,6 +63,10 @@ max_failed_pixels = 0 [linux] threshold = 0.6 max_failed_pixels = 0 + +# record every test and save a GIF of the failing ones +# (needs the `recording` feature) +save_gif_on_failure = false ``` Raise `max_failed_pixels` only very carefully: a high value (more than ~10) is enough to hide a @@ -70,6 +74,35 @@ real change, such as a moved separator, a shifted one-pixel border, or a small i incorrectly. Prefer the smallest value that makes the test pass, and re-check it whenever you update the snapshot. +## Recording + +With the `recording` feature you can record a test as an animated GIF, which is useful to see +what a test actually does. Recording renders every egui pass, so it needs a renderer: +enable the `wgpu` feature too. + +```rust,no_run +# use egui_kittest::Harness; +# let mut harness = Harness::new_ui(|ui| { ui.label("Hello!"); }); +#[cfg(all(feature = "recording", feature = "wgpu"))] +{ + use egui_kittest::RecordingOptions; + + harness.start_recording(RecordingOptions::gif("hello.gif", 10.0)); + harness.run(); + harness.finish_recording().unwrap(); +} +``` + +You can also record without touching the test: + +* `KITTEST_RECORD=1 cargo test` writes a GIF per test to `tests/snapshots/recordings` +* `KITTEST_RECORD=open cargo test` writes each GIF to a temporary file and opens it +* `save_gif_on_failure = true` in `kittest.toml` writes a GIF to `tests/snapshots/failures`, + but only for tests that fail + +The recorder is an `egui::Plugin` (`RecordingPlugin`), so you can also register it on any +`egui::Context` yourself. + ## Snapshot testing There is a snapshot testing feature. To create snapshot tests, enable the `snapshot` and `wgpu` features. Once enabled, you can call `Harness::snapshot` to render the ui and save the image to the `tests/snapshots` directory. diff --git a/crates/egui_kittest/src/config.rs b/crates/egui_kittest/src/config.rs index cdb74e00f..656ea518c 100644 --- a/crates/egui_kittest/src/config.rs +++ b/crates/egui_kittest/src/config.rs @@ -30,6 +30,15 @@ pub struct Config { #[serde(alias = "failed_pixel_count_threshold")] max_failed_pixels: usize, + /// Record every harness and save a GIF of the failing ones to + /// `{output_path}/failures/{test_name}.gif`. + /// + /// Needs the `recording` feature; ignored without it. + /// + /// Default is `false`. + #[cfg_attr(not(feature = "recording"), expect(dead_code))] + save_gif_on_failure: bool, + windows: OsConfig, mac: OsConfig, linux: OsConfig, @@ -41,6 +50,7 @@ impl Default for Config { output_path: PathBuf::from("tests/snapshots"), threshold: 0.6, max_failed_pixels: 0, + save_gif_on_failure: false, windows: Default::default(), mac: Default::default(), linux: Default::default(), @@ -150,6 +160,15 @@ impl Config { pub fn output_path(&self) -> PathBuf { self.output_path.clone() } + + /// Record every harness and save a GIF of the failing ones to + /// `{output_path}/failures/{test_name}.gif`. + /// + /// 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 fa8f26311..ffa55ce52 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -14,12 +14,19 @@ 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::{ + RECORD_ENV_VAR, RecordKind, RecordingError, RecordingOptions, RecordingPlugin, RecordingTrigger, +}; + // re-exports: pub use { self::{builder::*, node::*, renderer::*}, @@ -87,6 +94,13 @@ pub struct Harness<'a, State = ()> { default_snapshot_options: SnapshotOptions, #[cfg(feature = "snapshot")] snapshot_results: SnapshotResults, + + /// Saves an automatically started recording when the harness is dropped. + /// + /// Must be declared after `snapshot_results`: fields are dropped in order, so this way + /// the panic from a failed snapshot happens first and we can detect it. + #[cfg(feature = "recording")] + recording_auto_save: Option, } impl Debug for Harness<'_, State> { @@ -139,6 +153,11 @@ impl<'a, State> Harness<'a, State> { let viewport = input.viewports.get_mut(&ViewportId::ROOT).unwrap(); viewport.native_pixels_per_point = Some(pixels_per_point); + // Follow the textures from the very first pass, so that a recording can be started + // at any time. This captures nothing until the recording starts. + #[cfg(feature = "recording")] + recording::install_idle(&ctx); + let mut response = None; // We need to run egui for a single frame so that the AccessKit state can be initialized @@ -174,6 +193,9 @@ impl<'a, State> Harness<'a, State> { #[cfg(feature = "snapshot")] snapshot_results: SnapshotResults::default(), + + #[cfg(feature = "recording")] + recording_auto_save: None, }; // Handle any viewport commands (e.g. a screenshot or resize) requested during the initial // frame above (which didn't go through `_step`). @@ -181,6 +203,11 @@ impl<'a, State> Harness<'a, State> { // Run the harness until it is stable, ensuring that all Areas are shown and animations are done harness.run_ok(); + + // Start recording only now, so that the setup frames above are not part of the recording. + #[cfg(feature = "recording")] + harness.maybe_start_auto_recording(); + harness } @@ -643,26 +670,7 @@ impl<'a, State> Harness<'a, State> { #[cfg(any(feature = "wgpu", feature = "snapshot"))] pub fn render(&mut self) -> Result { let mut output = self.output.clone(); - - if let Some(mouse_pos) = self.ctx.input(|i| i.pointer.hover_pos()) { - // Paint a mouse cursor: - let triangle = vec![ - mouse_pos, - mouse_pos + egui::vec2(16.0, 8.0), - mouse_pos + egui::vec2(8.0, 16.0), - ]; - - output.shapes.push(ClippedShape { - clip_rect: self.ctx.content_rect(), - shape: egui::epaint::PathShape::convex_polygon( - triangle, - Color32::WHITE, - egui::Stroke::new(1.0, Color32::BLACK), - ) - .into(), - }); - } - + push_cursor_shape(&self.ctx, &mut output.shapes); self.renderer.render(&self.ctx, &output) } @@ -890,6 +898,31 @@ impl<'a> Harness<'a> { } } +/// Paint a mouse cursor at the current pointer position. +/// +/// The test harness has no real cursor, so we draw one to show where the pointer is +/// in screenshots and recordings. +#[cfg(any(feature = "wgpu", feature = "snapshot"))] +pub(crate) fn push_cursor_shape(ctx: &egui::Context, shapes: &mut Vec) { + if let Some(mouse_pos) = ctx.input(|i| i.pointer.hover_pos()) { + let triangle = vec![ + mouse_pos, + mouse_pos + egui::vec2(16.0, 8.0), + mouse_pos + egui::vec2(8.0, 16.0), + ]; + + shapes.push(ClippedShape { + clip_rect: ctx.content_rect(), + shape: egui::epaint::PathShape::convex_polygon( + triangle, + Color32::WHITE, + egui::Stroke::new(1.0, Color32::BLACK), + ) + .into(), + }); + } +} + /// Convert a rendered [`image::RgbaImage`] (premultiplied alpha, as produced by the renderer) /// into an [`egui::ColorImage`] suitable for [`egui::Event::Screenshot`]. #[cfg(any(feature = "wgpu", feature = "snapshot"))] diff --git a/crates/egui_kittest/src/recording.rs b/crates/egui_kittest/src/recording.rs new file mode 100644 index 000000000..1a2195c3e --- /dev/null +++ b/crates/egui_kittest/src/recording.rs @@ -0,0 +1,774 @@ +//! Record an egui session as an animated GIF or a sequence of PNG files. +//! +//! The recorder is an [`egui::Plugin`], so it can record any [`egui::Context`], +//! not just a [`crate::Harness`]. It renders every pass with its own [`TestRenderer`] +//! and keeps the frames in memory until you save them. +//! +//! See [`crate::Harness::start_recording`] / [`crate::Harness::finish_recording`]. + +use std::fs::File; +use std::io::BufWriter; +use std::path::{Path, PathBuf}; + +use egui::{Context, FullOutput, TexturesDelta}; +use image::RgbaImage; +use image::codecs::gif::{GifEncoder, Repeat}; + +use crate::TestRenderer; + +/// Name of the environment variable that records every [`crate::Harness`] in the process. +/// +/// Every harness records itself and saves a GIF when it is dropped, +/// whether the test passed or not: +/// +/// - `KITTEST_RECORD=1` writes to `{output_path}/recordings/{test_name}.gif` +/// - `KITTEST_RECORD=open` writes to a temporary file and shows it in the default image viewer +pub const RECORD_ENV_VAR: &str = "KITTEST_RECORD"; + +/// What to write when the recording is saved. +#[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 format stores delays in 10 ms ticks, + /// so a frame rate that is not a divisor of 100 is 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 is created if it is missing. + directory: PathBuf, + }, +} + +/// Which passes to capture. +/// +/// Passes that egui discards (see [`egui::Context::request_discard`]) are never captured, +/// since they are never shown to the user either. +#[derive(Debug, Clone, Copy, Default)] +pub enum RecordingTrigger { + /// Capture every pass, but drop a frame if it looks exactly like the frame before it. + /// + /// This is the default. It gives the smallest recordings, because most passes + /// change nothing on screen. + #[default] + ChangedFrames, + + /// Capture every pass, even if nothing changed. + EveryFrame, + + /// Capture every `N`-th pass. `EveryNthFrame(1)` is the same as [`Self::EveryFrame`]. + EveryNthFrame(u32), +} + +/// How to record. Pass this to [`crate::Harness::start_recording`] or [`RecordingPlugin::new`]. +#[derive(Debug, Clone)] +pub struct RecordingOptions { + /// What to write when the recording is saved. + pub kind: RecordKind, + + /// Which passes to capture. Defaults to [`RecordingTrigger::ChangedFrames`]. + pub trigger: RecordingTrigger, +} + +impl RecordingOptions { + /// Record a GIF to `path` at the given frame rate, + /// with the default trigger ([`RecordingTrigger::ChangedFrames`]). + 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::ChangedFrames`]). + pub fn png_sequence(directory: impl Into) -> Self { + Self { + kind: RecordKind::PngSequence { + directory: directory.into(), + }, + trigger: RecordingTrigger::default(), + } + } + + /// Replace the trigger. + #[inline] + #[must_use] + pub fn with_trigger(mut self, trigger: RecordingTrigger) -> Self { + self.trigger = trigger; + self + } +} + +/// What went wrong when saving a recording. +#[derive(Debug)] +pub enum RecordingError { + /// No recording was running. + NotRecording, + + /// The recording did not capture a single frame. + NoFrames, + + /// Failed to create or write the output file or directory. + Io { + /// The file or directory we failed to write. + path: PathBuf, + + /// The underlying error. + err: std::io::Error, + }, + + /// Failed to encode the image data. + 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 running"), + Self::NoFrames => write!(f, "The recording contains no frames"), + Self::Io { path, err } => write!(f, "Failed to write {}: {err}", path.display()), + Self::Encode(err) => write!(f, "Failed to encode the recording: {err}"), + } + } +} + +impl std::error::Error for RecordingError {} + +impl From for RecordingError { + fn from(err: image::ImageError) -> Self { + Self::Encode(err) + } +} + +/// Records an [`egui::Context`] by rendering each pass to an image. +/// +/// Register it with [`egui::Context::add_plugin`], or let [`crate::Harness::start_recording`] +/// do it for you. +/// +/// The plugin renders with its own [`TestRenderer`] (a `wgpu` one by default), so it does not +/// interfere with the renderer of the harness. +pub struct RecordingPlugin { + options: RecordingOptions, + renderer: LazyRenderer, + frames: Vec, + pass_nr: u32, + + /// While `false` the plugin still tracks textures, but captures no frames. + active: bool, + + /// Did we give our renderer the whole font atlas? + /// + /// A plugin that is registered after the first pass never saw the font texture being + /// allocated, only the partial updates that follow it. + uploaded_font_atlas: bool, + + /// Set when the harness started the recording by itself (see [`crate::Harness`]). + pub(crate) auto_save: Option, +} + +impl std::fmt::Debug for RecordingPlugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RecordingPlugin") + .field("options", &self.options) + .field("active", &self.active) + .field("frames", &self.frames.len()) + .finish_non_exhaustive() + } +} + +impl RecordingPlugin { + /// Create a plugin that starts recording right away. + pub fn new(options: RecordingOptions) -> Self { + Self { + options, + active: true, + ..Self::idle() + } + } + + /// Create a plugin that captures nothing until [`Self::restart`] is called. + /// + /// It still follows the textures of the [`egui::Context`], so that it can render + /// correctly once it starts. + pub fn idle() -> Self { + Self { + options: RecordingOptions::gif(PathBuf::new(), AUTO_FRAME_RATE), + renderer: LazyRenderer::default(), + frames: Vec::new(), + pass_nr: 0, + active: false, + uploaded_font_atlas: false, + auto_save: None, + } + } + + /// Render with this renderer instead of the default `wgpu` one. + /// + /// The renderer must be `Send + Sync`, because [`egui::Plugin`] requires it. + #[inline] + #[must_use] + pub fn with_renderer(mut self, renderer: impl TestRenderer + Send + Sync + 'static) -> Self { + self.renderer = LazyRenderer::Ready(Box::new(renderer)); + self + } + + /// The options this recording uses. + #[inline] + pub fn options(&self) -> &RecordingOptions { + &self.options + } + + /// The options this recording uses, mutably. + #[inline] + pub fn options_mut(&mut self) -> &mut RecordingOptions { + &mut self.options + } + + /// Is the plugin capturing frames? + #[inline] + pub fn is_active(&self) -> bool { + self.active + } + + /// The frames captured so far. + #[inline] + pub fn frames(&self) -> &[RgbaImage] { + &self.frames + } + + /// Start capturing again, with new options. Any earlier frames are dropped. + pub fn restart(&mut self, options: RecordingOptions) { + self.options = options; + self.frames.clear(); + self.pass_nr = 0; + self.active = true; + self.auto_save = None; + } + + /// Stop capturing and drop all frames. + pub fn stop(&mut self) { + self.frames.clear(); + self.active = false; + self.auto_save = None; + } + + /// Write the captured frames to disk. + /// + /// # Errors + /// Returns an error if there are no frames, or if writing fails. + pub fn save(&self) -> Result<(), RecordingError> { + if self.frames.is_empty() { + return Err(RecordingError::NoFrames); + } + + 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), + } + } + + /// Where the recording will be written. + pub(crate) fn output_path(&self) -> &Path { + match &self.options.kind { + RecordKind::Gif { path, .. } => path, + RecordKind::PngSequence { directory } => directory, + } + } + + /// Change where the recording will be written. + pub(crate) fn set_output_path(&mut self, new_path: PathBuf) { + match &mut self.options.kind { + RecordKind::Gif { path, .. } => *path = new_path, + RecordKind::PngSequence { directory } => *directory = new_path, + } + } + + /// Should we capture this pass? + fn should_capture(&mut self) -> bool { + let pass_nr = self.pass_nr; + self.pass_nr = self.pass_nr.wrapping_add(1); + + match self.options.trigger { + RecordingTrigger::ChangedFrames | RecordingTrigger::EveryFrame => true, + RecordingTrigger::EveryNthFrame(n) => pass_nr.is_multiple_of(n.max(1)), + } + } + + /// Add a frame, dropping it if the trigger says it is a duplicate. + fn push_frame(&mut self, image: RgbaImage) { + if matches!(self.options.trigger, RecordingTrigger::ChangedFrames) + && let Some(previous) = self.frames.last() + && previous.as_raw() == image.as_raw() + { + return; + } + + self.frames.push(image); + } +} + +impl egui::Plugin for RecordingPlugin { + fn debug_name(&self) -> &'static str { + "egui_kittest::RecordingPlugin" + } + + fn output_hook(&mut self, ctx: &Context, output: &mut FullOutput) { + if !self.uploaded_font_atlas { + self.uploaded_font_atlas = true; + self.renderer.handle_delta(&mut font_atlas_delta(ctx)); + } + + // Our renderer needs the same textures as the renderer of the integration, + // so apply a copy of the deltas. Do this even while inactive, so that we can + // start recording at any time. + let mut textures_delta = output.textures_delta.clone(); + self.renderer.handle_delta(&mut textures_delta); + + if !self.active { + return; + } + + if output.platform_output.requested_discard() { + // This pass is thrown away and never shown, so don't record it. + return; + } + + if !self.should_capture() { + return; + } + + // `FullOutput` cannot be cloned without cloning the texture deltas + // (which panic if they are dropped unapplied), so build the render input by hand. + // Renderers only need the shapes. + let mut shapes = output.shapes.clone(); + crate::push_cursor_shape(ctx, &mut shapes); + + let render_output = FullOutput { + shapes, + pixels_per_point: output.pixels_per_point, + viewport_output: output.viewport_output.clone(), + ..Default::default() + }; + + match self.renderer.render(ctx, &render_output) { + Ok(image) => self.push_frame(image), + Err(err) => { + log::error!("egui_kittest recording: failed to render a frame: {err}"); + if self.renderer.is_failed() { + // Nothing will ever render, so stop instead of complaining every pass. + self.active = false; + } + } + } + } +} + +/// How a recording that the harness started by itself is saved when the harness is dropped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AutoSaveMode { + /// Save only if the test failed. Written to `{output_path}/failures/{test_name}.gif`. + OnFailure, + + /// Always save. Written to `{output_path}/recordings/{test_name}.gif`. + Always, + + /// Always save to a temporary file, and show it in the default image viewer. + Open, +} + +impl AutoSaveMode { + /// Where to write the recording of the test we are running. + fn path(self) -> PathBuf { + let name = std::thread::current() + .name() + .map_or_else(|| "recording".to_owned(), sanitize_file_name); + + let subdirectory = match self { + Self::OnFailure => "failures", + Self::Always => "recordings", + Self::Open => { + if let Some(path) = temp_gif_path(&name) { + return path; + } + "recordings" // Fall back to a normal recording. + } + }; + + crate::config::config() + .output_path() + .join(subdirectory) + .join(format!("{name}.gif")) + } +} + +/// A GIF in the temporary directory, which we keep after the test, so that the image +/// viewer can still read it. +fn temp_gif_path(name: &str) -> Option { + tempfile::Builder::new() + .disable_cleanup(true) + .prefix(&format!("kittest-recording-{name}-")) + .suffix(".gif") + .tempfile() + .inspect_err(|err| log::error!("egui_kittest: failed to create a temporary file: {err}")) + .ok() + .map(|file| file.path().to_path_buf()) +} + +/// Test threads are named after the test (e.g. `menu::tests::close_on_click`). +fn sanitize_file_name(name: &str) -> String { + name.replace(|c: char| !c.is_alphanumeric() && c != '_' && c != '-', "_") +} + +/// What [`RECORD_ENV_VAR`] asks for. +/// +/// Read once, then cached, so that a test cannot change it halfway through a run. +pub(crate) fn record_env_var() -> Option { + static MODE: std::sync::OnceLock> = std::sync::OnceLock::new(); + *MODE.get_or_init(|| { + let value = std::env::var(RECORD_ENV_VAR).ok()?; + + match value.trim().to_ascii_lowercase().as_str() { + "open" => Some(AutoSaveMode::Open), + "1" | "true" | "yes" | "on" => Some(AutoSaveMode::Always), + "" | "0" | "false" | "no" | "off" => None, + other => { + log::warn!("Ignoring {RECORD_ENV_VAR}={other:?}: expected `1` or `open`"); + None + } + } + }) +} + +// ---------------------------------------------------------------------------- +// Harness integration + +/// Frame rate of recordings that the harness starts by itself. +const AUTO_FRAME_RATE: f32 = 10.0; + +/// A [`crate::Harness`] can record itself. +impl crate::Harness<'_, State> { + /// Record the rest of this test session. + /// + /// One frame is captured per egui pass, as configured by [`RecordingOptions::trigger`]. + /// Call [`Self::finish_recording`] to write the result. + /// + /// This registers a [`RecordingPlugin`] on the [`egui::Context`] of the harness, and + /// restarts the recording if there already is one. + /// + /// The recording renders with its own renderer, which by default needs the `wgpu` feature. + /// + /// ```no_run + /// # use egui_kittest::{Harness, RecordingOptions}; + /// let mut harness = Harness::new_ui(|ui| { + /// ui.label("Hello!"); + /// }); + /// harness.start_recording(RecordingOptions::gif("hello.gif", 10.0)); + /// harness.run(); + /// harness.finish_recording().unwrap(); + /// ``` + pub fn start_recording(&mut self, options: RecordingOptions) { + install(&self.ctx, options, None); + self.recording_auto_save = None; + } + + /// Stop the recording and write it to disk. + /// + /// # Errors + /// Returns [`RecordingError::NotRecording`] if nothing was being recorded, + /// [`RecordingError::NoFrames`] if no frame was captured, + /// or an I/O or encoding error if writing failed. + pub fn finish_recording(&mut self) -> Result<(), RecordingError> { + self.recording_auto_save = None; + + let result = self.ctx.with_plugin::(|plugin| { + if !plugin.is_active() { + return Err(RecordingError::NotRecording); + } + let result = plugin.save(); + plugin.stop(); + result + }); + + result.unwrap_or(Err(RecordingError::NotRecording)) + } + + /// Is the harness recording? + pub fn is_recording(&self) -> bool { + self.ctx + .with_plugin::(|plugin| plugin.is_active()) + .unwrap_or(false) + } + + /// Access the [`RecordingPlugin`], e.g. to read the captured frames. + /// + /// Returns `None` if the harness never recorded anything. + pub fn with_recording(&self, f: impl FnOnce(&mut RecordingPlugin) -> R) -> Option { + self.ctx.with_plugin::(f) + } + + /// Start recording if the environment variable or the `kittest.toml` asks for it. + pub(crate) fn maybe_start_auto_recording(&mut self) { + let mode = if let Some(mode) = record_env_var() { + mode + } else if crate::config::config().save_gif_on_failure() { + AutoSaveMode::OnFailure + } else { + return; + }; + + // The file name contains the test name, which we only look up when we save, + // so record to a placeholder path for now. + let options = RecordingOptions::gif(PathBuf::new(), AUTO_FRAME_RATE); + install(&self.ctx, options, Some(mode)); + self.recording_auto_save = Some(AutoSaveOnDrop { + ctx: self.ctx.clone(), + }); + } +} + +/// Register an idle [`RecordingPlugin`], if there is none yet. +/// +/// The harness does this before the first pass, so that the plugin sees every texture that +/// egui allocates, no matter when the recording starts. +pub(crate) fn install_idle(ctx: &Context) { + ctx.add_plugin(RecordingPlugin::idle()); +} + +/// Register a [`RecordingPlugin`] on `ctx`, or restart the one that is already registered. +fn install(ctx: &Context, options: RecordingOptions, auto_save: Option) { + let restarted = ctx + .with_plugin::(|plugin| { + plugin.restart(options.clone()); + plugin.auto_save = auto_save; + }) + .is_some(); + + if !restarted { + let mut plugin = RecordingPlugin::new(options); + plugin.auto_save = auto_save; + ctx.add_plugin(plugin); + } +} + +/// Saves a recording that the harness started by itself, when the harness is dropped. +pub(crate) struct AutoSaveOnDrop { + pub ctx: Context, +} + +#[expect(clippy::print_stderr)] // We are (probably) in a panic, so logging may not be shown. +impl Drop for AutoSaveOnDrop { + fn drop(&mut self) { + self.ctx.with_plugin::(|plugin| { + let Some(mode) = plugin.auto_save.take() else { + return; + }; + + // A failing test panics, either from an assert or from the snapshot results, + // which are dropped before this. + if mode == AutoSaveMode::OnFailure && !std::thread::panicking() { + plugin.stop(); + return; + } + + plugin.set_output_path(mode.path()); + let path = plugin.output_path().to_path_buf(); + + match plugin.save() { + Ok(()) => { + eprintln!("egui_kittest: saved a recording to {}", path.display()); + + if mode == AutoSaveMode::Open + && let Err(err) = open::that_detached(&path) + { + eprintln!( + "egui_kittest: failed to open {} in the default image viewer: {err}", + path.display() + ); + } + } + Err(RecordingError::NoFrames) => {} + Err(err) => eprintln!("egui_kittest: failed to save the recording: {err}"), + } + + plugin.stop(); + }); + } +} + +// ---------------------------------------------------------------------------- +// Renderer + +/// A [`TestRenderer`] that is created when it is first used. +/// +/// This mirrors [`crate::LazyRenderer`], but is `Send + Sync`, as [`egui::Plugin`] requires. +enum LazyRenderer { + Uninitialized { + textures_delta: TexturesDelta, + }, + Ready(Box), + + /// We failed to create a renderer, and already told the user about it. + #[cfg_attr(feature = "wgpu", expect(dead_code))] + // Only reachable without the `wgpu` feature. + Failed, +} + +impl Default for LazyRenderer { + fn default() -> Self { + Self::Uninitialized { + textures_delta: TexturesDelta::default(), + } + } +} + +/// A delta that sets the whole font atlas, as it looks right now. +fn font_atlas_delta(ctx: &Context) -> TexturesDelta { + let image = ctx.fonts(|fonts| fonts.image()); + + let mut delta = TexturesDelta::default(); + delta.push( + egui::TextureId::default(), // The font atlas is always the first texture. + egui::epaint::ImageDelta::full(image, egui::TextureOptions::default()), + ); + delta +} + +impl LazyRenderer { + fn handle_delta(&mut self, delta: &mut TexturesDelta) { + match self { + Self::Uninitialized { textures_delta } => textures_delta.append(std::mem::take(delta)), + Self::Ready(renderer) => renderer.handle_delta(delta), + Self::Failed => delta.clear(), // Don't panic when the delta is dropped. + } + } + + fn render(&mut self, ctx: &Context, output: &FullOutput) -> Result { + if let Self::Uninitialized { textures_delta } = self { + #[cfg(feature = "wgpu")] + { + let mut renderer = crate::wgpu::WgpuTestRenderer::new(); + renderer.handle_delta(textures_delta); + *self = Self::Ready(Box::new(renderer)); + } + + #[cfg(not(feature = "wgpu"))] + { + textures_delta.clear(); // Don't panic when the deltas are dropped. + *self = Self::Failed; + } + } + + match self { + Self::Ready(renderer) => renderer.render(ctx, output), + Self::Uninitialized { .. } | Self::Failed => Err("A recording needs a renderer. \ + Enable the `wgpu` feature, or pass one to `RecordingPlugin::with_renderer`." + .to_owned()), + } + } + + /// Will this renderer never render anything? + fn is_failed(&self) -> bool { + matches!(self, Self::Failed) + } +} + +impl Drop for LazyRenderer { + fn drop(&mut self) { + if let Self::Uninitialized { textures_delta } = self { + textures_delta.clear(); // Don't panic when dropping unapplied deltas. + } + } +} + +// ---------------------------------------------------------------------------- +// Saving + +fn save_gif(path: &Path, frames: &[RgbaImage], frame_rate: f32) -> Result<(), RecordingError> { + create_parent_dir(path)?; + + let file = File::create(path).map_err(|err| RecordingError::Io { + path: path.to_path_buf(), + err, + })?; + let mut encoder = GifEncoder::new(BufWriter::new(file)); + encoder.set_repeat(Repeat::Infinite)?; + + let fps = frame_rate.clamp(1.0, 100.0).round() as u32; + let frame_delay = image::Delay::from_numer_denom_ms(1000, fps); + // Hold the last frame for a second, so it is obvious where the loop restarts. + let last_delay = image::Delay::from_numer_denom_ms(1000, 1); + + // All frames of a GIF share one canvas, so grow the smaller ones to fit. + let size = max_size(frames); + + let last_index = frames.len() - 1; + for (i, frame) in frames.iter().enumerate() { + let delay = if i == last_index { + last_delay + } else { + frame_delay + }; + let image = pad_to(frame, size); + encoder.encode_frame(image::Frame::from_parts(image, 0, 0, delay))?; + } + + Ok(()) +} + +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(err) => RecordingError::Io { path, err }, + err => RecordingError::Encode(err), + })?; + } + + Ok(()) +} + +fn create_parent_dir(path: &Path) -> 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, + })?; + } + Ok(()) +} + +/// The size of the largest frame, per axis. +fn max_size(frames: &[RgbaImage]) -> (u32, u32) { + frames.iter().fold((1, 1), |(w, h), frame| { + (w.max(frame.width()), h.max(frame.height())) + }) +} + +/// Copy `image` into the top-left corner of a transparent image of the given size. +fn pad_to(image: &RgbaImage, (width, height): (u32, u32)) -> RgbaImage { + if image.dimensions() == (width, height) { + return image.clone(); + } + + let mut padded = RgbaImage::new(width, height); + for (x, y, pixel) in image.enumerate_pixels() { + padded.put_pixel(x, y, *pixel); + } + padded +} diff --git a/crates/egui_kittest/tests/recording.rs b/crates/egui_kittest/tests/recording.rs new file mode 100644 index 000000000..faecc7be0 --- /dev/null +++ b/crates/egui_kittest/tests/recording.rs @@ -0,0 +1,181 @@ +#![cfg(all(feature = "recording", feature = "wgpu"))] + +use egui_kittest::{Harness, RecordingOptions, RecordingPlugin, 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(Result::ok) + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "png")) + .count() +} + +#[test] +fn records_a_gif() { + let dir = tempdir().expect("tempdir"); + let gif_path = dir.path().join("counter.gif"); + + let mut value = 0; + let mut harness = counter_harness(&mut value); + harness.start_recording(RecordingOptions::gif(&gif_path, 12.0)); + + 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 frames = decode_gif_frames(&gif_path); + assert!( + frames >= 2, + "the click should have produced at least two different frames, got {frames}" + ); +} + +fn decode_gif_frames(path: &std::path::Path) -> usize { + use image::AnimationDecoder as _; + + let file = std::io::BufReader::new(std::fs::File::open(path).expect("gif exists")); + image::codecs::gif::GifDecoder::new(file) + .expect("decode gif") + .into_frames() + .count() +} + +#[test] +fn records_a_png_sequence() { + let dir = tempdir().expect("tempdir"); + let out = dir.path().join("frames"); + + let mut value = 0; + let mut harness = counter_harness(&mut value); + harness.start_recording( + RecordingOptions::png_sequence(&out).with_trigger(RecordingTrigger::EveryFrame), + ); + + 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 changed_frames_drops_identical_frames() { + let dir = tempdir().expect("tempdir"); + let out = dir.path().join("frames"); + + let mut value = 0; + let mut harness = counter_harness(&mut value); + harness.start_recording( + RecordingOptions::png_sequence(&out).with_trigger(RecordingTrigger::ChangedFrames), + ); + + for _ in 0..6 { + harness.run(); + } + harness.finish_recording().expect("save png sequence"); + + assert_eq!( + count_pngs(&out), + 1, + "nothing changed, so only the first frame should be kept" + ); +} + +#[test] +fn every_nth_frame_skips_frames() { + let mut value = 0; + let mut harness = counter_harness(&mut value); + + harness.start_recording( + RecordingOptions::gif(std::path::PathBuf::new(), 10.0) + .with_trigger(RecordingTrigger::EveryNthFrame(2)), + ); + harness.run_steps(4); + + let frames = harness + .with_recording(|plugin| plugin.frames().len()) + .expect("the plugin is registered"); + assert_eq!(frames, 2, "every second of the 4 passes should be captured"); +} + +#[test] +fn finishing_without_recording_is_an_error() { + let mut value = 0; + let mut harness = counter_harness(&mut value); + + let err = harness.finish_recording().expect_err("not recording"); + assert!(matches!( + err, + egui_kittest::RecordingError::NotRecording | egui_kittest::RecordingError::NoFrames + )); +} + +#[test] +fn recording_without_frames_is_an_error() { + let dir = tempdir().expect("tempdir"); + + let mut value = 0; + let mut harness = counter_harness(&mut value); + harness.start_recording(RecordingOptions::gif(dir.path().join("empty.gif"), 10.0)); + + let err = harness.finish_recording().expect_err("no frames"); + assert!(matches!(err, egui_kittest::RecordingError::NoFrames)); +} + +/// The recorder is a plain [`egui::Plugin`]: it records any [`egui::Context`], +/// with no harness and no renderer of your own. +#[test] +fn records_a_plain_context() { + let dir = tempdir().expect("tempdir"); + let gif_path = dir.path().join("plain.gif"); + + let ctx = egui::Context::default(); + ctx.add_plugin(RecordingPlugin::new(RecordingOptions::gif(&gif_path, 10.0))); + + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + egui::Pos2::ZERO, + egui::Vec2::new(120.0, 60.0), + )), + ..Default::default() + }; + + for pass in 0..3 { + let output = ctx.run_ui(input.clone(), |ui| { + ui.label(format!("pass {pass}")); + }); + // We have no renderer of our own; the plugin already rendered what it needed. + output.drop_without_applying_deltas(); + } + + let frames = ctx + .with_plugin::(|plugin| { + plugin.save().expect("save gif"); + plugin.frames().len() + }) + .expect("the plugin is registered"); + + assert_eq!(frames, 3, "each pass shows a different label"); + assert!(gif_path.exists(), "the GIF should have been written"); +} diff --git a/crates/egui_kittest/tests/recording_env.rs b/crates/egui_kittest/tests/recording_env.rs new file mode 100644 index 000000000..f9b2f10ad --- /dev/null +++ b/crates/egui_kittest/tests/recording_env.rs @@ -0,0 +1,61 @@ +//! Checks that `KITTEST_RECORD` records every harness and writes the GIFs next to the +//! snapshots, in `recordings/{test_name}.gif`. +//! +//! This is a test binary of its own because it changes the environment and the working +//! directory of the whole process. + +#![cfg(all(feature = "recording", feature = "wgpu"))] +#![expect(unsafe_code)] // To set the environment variable. + +use std::sync::OnceLock; + +use egui_kittest::Harness; +use tempfile::TempDir; + +/// Run the process in a temporary directory, with recording turned on. +/// +/// Both the environment variable and the `kittest.toml` are read once per process, +/// so this must happen before the first harness is built. +fn setup() -> &'static std::path::Path { + static SETUP: OnceLock = OnceLock::new(); + + SETUP + .get_or_init(|| { + let dir = tempfile::tempdir().expect("tempdir"); + + // Write the recordings into the temporary directory. + std::fs::write(dir.path().join("kittest.toml"), "output_path = \".\"\n") + .expect("write kittest.toml"); + + // SAFETY: the `OnceLock` runs this once, before any other thread reads the + // environment or the working directory. + unsafe { + std::env::set_current_dir(dir.path()).expect("chdir to the tempdir"); + std::env::set_var(egui_kittest::RECORD_ENV_VAR, "1"); + } + + dir + }) + .path() +} + +#[test] +fn env_var_records_every_harness() { + let dir = setup(); + + { + let mut harness = Harness::new_ui(|ui| { + ui.label("recorded by the environment variable"); + }); + harness.run(); + // Dropping the harness saves the recording. + } + + let gif = dir + .join("recordings") + .join("env_var_records_every_harness.gif"); + let size = std::fs::metadata(&gif) + .unwrap_or_else(|err| panic!("{} should exist: {err}", gif.display())) + .len(); + assert!(size > 0, "the GIF should not be empty"); +}