1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-29 04:40:03 -04:00

Add kittest recording feature

This commit is contained in:
Lucas Meurer
2026-08-11 15:59:34 +02:00
parent d802a982ce
commit f58eafd116
7 changed files with 1129 additions and 20 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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")]

View File

@@ -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<recording::AutoSaveOnDrop>,
}
impl<State> 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<image::RgbaImage, String> {
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<ClippedShape>) {
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"))]

View File

@@ -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<PathBuf>, 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<PathBuf>) -> 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<image::ImageError> 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<RgbaImage>,
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<AutoSaveMode>,
}
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<PathBuf> {
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<AutoSaveMode> {
static MODE: std::sync::OnceLock<Option<AutoSaveMode>> = 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<State> 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::<RecordingPlugin, _>(|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::<RecordingPlugin, _>(|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<R>(&self, f: impl FnOnce(&mut RecordingPlugin) -> R) -> Option<R> {
self.ctx.with_plugin::<RecordingPlugin, _>(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<AutoSaveMode>) {
let restarted = ctx
.with_plugin::<RecordingPlugin, _>(|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::<RecordingPlugin, _>(|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<dyn TestRenderer + Send + Sync>),
/// 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<RgbaImage, String> {
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
}

View File

@@ -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::<RecordingPlugin, _>(|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");
}

View File

@@ -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<TempDir> = 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");
}