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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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"))]
|
||||
|
||||
774
crates/egui_kittest/src/recording.rs
Normal file
774
crates/egui_kittest/src/recording.rs
Normal 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
|
||||
}
|
||||
181
crates/egui_kittest/tests/recording.rs
Normal file
181
crates/egui_kittest/tests/recording.rs
Normal 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");
|
||||
}
|
||||
61
crates/egui_kittest/tests/recording_env.rs
Normal file
61
crates/egui_kittest/tests/recording_env.rs
Normal 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");
|
||||
}
|
||||
Reference in New Issue
Block a user