mirror of
https://github.com/emilk/egui.git
synced 2026-06-26 22:53:14 -04:00
Add gif recording prototype
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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<recording::RecordingState>,
|
||||
}
|
||||
|
||||
impl<State> 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<image::RgbaImage, String> {
|
||||
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<State> 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.
|
||||
|
||||
270
crates/egui_kittest/src/recording.rs
Normal file
270
crates/egui_kittest/src/recording.rs
Normal file
@@ -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<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::DiffEveryStep`]).
|
||||
pub fn png_sequence(directory: impl Into<PathBuf>) -> 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<image::ImageError> 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<RgbaImage>,
|
||||
pub(crate) last_frame: Option<RgbaImage>,
|
||||
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<AutoSaveMode>,
|
||||
}
|
||||
|
||||
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<bool> = 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(())
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
124
crates/egui_kittest/tests/recording.rs
Normal file
124
crates/egui_kittest/tests/recording.rs
Normal file
@@ -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));
|
||||
}
|
||||
65
crates/egui_kittest/tests/recording_env.rs
Normal file
65
crates/egui_kittest/tests/recording_env.rs
Normal file
@@ -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<TempDir> = 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user