1
0
mirror of https://github.com/emilk/egui.git synced 2026-06-26 22:53:14 -04:00

Add kittest::Plugin

This commit is contained in:
lucasmerlin
2026-04-23 11:44:15 +02:00
parent b27bc2b9ea
commit 62575e6345
6 changed files with 817 additions and 89 deletions

View File

@@ -1,7 +1,7 @@
use crate::app_kind::AppKind;
#[cfg(feature = "eframe")]
use crate::app_kind::AppKindEframe;
use crate::{Harness, LazyRenderer, TestRenderer};
use crate::{Harness, LazyRenderer, Plugin, TestRenderer};
use egui::{Pos2, Rect, Vec2};
use std::marker::PhantomData;
@@ -17,6 +17,7 @@ pub struct HarnessBuilder<State = ()> {
pub(crate) state: PhantomData<State>,
pub(crate) renderer: Box<dyn TestRenderer>,
pub(crate) wait_for_pending_images: bool,
pub(crate) plugins: Vec<Box<dyn Plugin<State>>>,
#[cfg(feature = "snapshot")]
pub(crate) default_snapshot_options: crate::SnapshotOptions,
@@ -37,6 +38,7 @@ impl<State> Default for HarnessBuilder<State> {
step_dt: 1.0 / 4.0,
wait_for_pending_images: true,
os: egui::os::OperatingSystem::Nix,
plugins: Vec::new(),
#[cfg(feature = "snapshot")]
default_snapshot_options: crate::SnapshotOptions::default(),
@@ -47,7 +49,7 @@ impl<State> Default for HarnessBuilder<State> {
}
}
impl<State> HarnessBuilder<State> {
impl<State: 'static> HarnessBuilder<State> {
/// Set the size of the window.
#[inline]
pub fn with_size(mut self, size: impl Into<Vec2>) -> Self {
@@ -161,6 +163,16 @@ impl<State> HarnessBuilder<State> {
self.renderer(crate::wgpu::WgpuTestRenderer::from_setup(setup))
}
/// Register a [`Plugin`] on this harness.
///
/// Plugins observe the harness lifecycle (`before_step`, `after_step`, `on_snapshot`,
/// `on_test_result`, etc.) and are dispatched in registration order.
#[inline]
pub fn with_plugin(mut self, plugin: impl Plugin<State>) -> Self {
self.plugins.push(Box::new(plugin));
self
}
/// Create a new Harness with the given ui closure and a state.
///
/// The ui closure will immediately be called once to create the initial ui.

View File

@@ -14,12 +14,15 @@ pub use crate::snapshot::*;
mod app_kind;
mod config;
mod node;
mod plugin;
mod renderer;
#[cfg(feature = "wgpu")]
mod texture_to_image;
#[cfg(feature = "wgpu")]
pub mod wgpu;
pub use crate::plugin::{PanicLocation, Plugin, TestResult, install_panic_hook};
// re-exports:
pub use {
self::{builder::*, node::*, renderer::*},
@@ -69,7 +72,7 @@ impl Display for ExceededMaxStepsError {
/// Some egui style options are changed from the defaults:
/// - The cursor blinking is disabled
/// - The scroll animation is disabled
pub struct Harness<'a, State = ()> {
pub struct Harness<'a, State: 'static = ()> {
pub ctx: egui::Context,
input: egui::RawInput,
kittest: kittest::State,
@@ -83,10 +86,17 @@ pub struct Harness<'a, State = ()> {
wait_for_pending_images: bool,
queued_events: EventQueue,
plugins: Vec<Box<dyn Plugin<State>>>,
pending_plugins: Vec<Box<dyn Plugin<State>>>,
in_dispatch: bool,
entry_location: Option<&'static std::panic::Location<'static>>,
consumed_event_locations: Vec<&'static std::panic::Location<'static>>,
last_accesskit_update: Option<egui::accesskit::TreeUpdate>,
#[cfg(feature = "snapshot")]
default_snapshot_options: SnapshotOptions,
#[cfg(feature = "snapshot")]
snapshot_results: SnapshotResults,
snapshot_results: Option<SnapshotResults>,
}
impl<State> Debug for Harness<'_, State> {
@@ -95,7 +105,7 @@ impl<State> Debug for Harness<'_, State> {
}
}
impl<'a, State> Harness<'a, State> {
impl<'a, State: 'static> Harness<'a, State> {
#[track_caller]
pub(crate) fn from_builder(
builder: HarnessBuilder<State>,
@@ -113,6 +123,7 @@ impl<'a, State> Harness<'a, State> {
state: _,
mut renderer,
wait_for_pending_images,
plugins,
#[cfg(feature = "snapshot")]
default_snapshot_options,
@@ -149,17 +160,17 @@ impl<'a, State> Harness<'a, State> {
renderer.handle_delta(&output.textures_delta);
let initial_accesskit = output
.platform_output
.accesskit_update
.take()
.expect("AccessKit was disabled");
let mut harness = Self {
app,
ctx,
input,
kittest: kittest::State::new(
output
.platform_output
.accesskit_update
.take()
.expect("AccessKit was disabled"),
),
kittest: kittest::State::new(initial_accesskit.clone()),
output,
response,
state,
@@ -169,11 +180,18 @@ impl<'a, State> Harness<'a, State> {
wait_for_pending_images,
queued_events: Default::default(),
plugins,
pending_plugins: Vec::new(),
in_dispatch: false,
entry_location: None,
consumed_event_locations: Vec::new(),
last_accesskit_update: Some(initial_accesskit),
#[cfg(feature = "snapshot")]
default_snapshot_options,
#[cfg(feature = "snapshot")]
snapshot_results: SnapshotResults::default(),
snapshot_results: Some(SnapshotResults::default()),
};
// Run the harness until it is stable, ensuring that all Areas are shown and animations are done
harness.run_ok();
@@ -185,6 +203,89 @@ impl<'a, State> Harness<'a, State> {
HarnessBuilder::default()
}
/// Register a [`Plugin`] after construction.
///
/// See [`HarnessBuilder::with_plugin`] to register before the first frame runs.
pub fn add_plugin(&mut self, plugin: impl Plugin<State>) {
let boxed: Box<dyn Plugin<State>> = Box::new(plugin);
if self.in_dispatch {
self.pending_plugins.push(boxed);
} else {
self.plugins.push(boxed);
}
}
/// Borrow a registered plugin by type. Returns the first plugin of the matching type
/// in registration order, or `None` if no plugin of that type is registered.
pub fn plugin<P: Plugin<State>>(&self) -> Option<&P> {
self.plugins
.iter()
.find_map(|p| p.as_any().downcast_ref::<P>())
}
/// Mutably borrow a registered plugin by type.
pub fn plugin_mut<P: Plugin<State>>(&mut self) -> Option<&mut P> {
self.plugins
.iter_mut()
.find_map(|p| p.as_any_mut().downcast_mut::<P>())
}
/// Remove and return the first plugin of the given type.
pub fn take_plugin<P: Plugin<State>>(&mut self) -> Option<Box<P>> {
let idx = self.plugins.iter().position(|p| p.as_any().is::<P>())?;
let boxed = self.plugins.remove(idx);
let raw: *mut dyn Plugin<State> = Box::into_raw(boxed);
// SAFETY: `is::<P>()` confirmed the concrete type is `P`. Fat-to-thin pointer
// cast preserves the data pointer, which is the address of the underlying `P`.
#[expect(unsafe_code)]
Some(unsafe { Box::from_raw(raw.cast::<P>()) })
}
/// Advance the harness by one frame without firing plugin hooks.
///
/// Use this from inside a plugin hook when the plugin needs to drive additional
/// frames — e.g. an inspector plugin that blocks on user input and re-renders
/// after each injected event. Calling [`Self::step`] or [`Self::run`] from inside
/// a hook would recurse infinitely through that plugin's own `after_step`.
pub fn advance_frame(&mut self) {
self._step_inner(false);
}
/// The most recent AccessKit tree update, if any. Useful for plugins that mirror
/// the accessibility tree to an external debugger.
pub fn accesskit_tree_update(&self) -> Option<&egui::accesskit::TreeUpdate> {
self.last_accesskit_update.as_ref()
}
/// [`std::panic::Location`] of the most recent public `#[track_caller]` entry point
/// (e.g. the caller of `step()` / `run()`), or `None` if no such call has been made yet.
pub fn entry_location(&self) -> Option<&'static std::panic::Location<'static>> {
self.entry_location
}
/// Locations of the events consumed during the most recent step, in order.
pub fn consumed_event_locations(&self) -> &[&'static std::panic::Location<'static>] {
&self.consumed_event_locations
}
fn dispatch(&mut self, mut f: impl FnMut(&mut dyn Plugin<State>, &mut Self)) {
if self.plugins.is_empty() {
return;
}
let mut plugins = std::mem::take(&mut self.plugins);
self.in_dispatch = true;
for p in &mut plugins {
f(p.as_mut(), self);
}
self.in_dispatch = false;
self.plugins = plugins;
// Promote any plugins registered mid-dispatch to the end of the active list.
if !self.pending_plugins.is_empty() {
let pending = std::mem::take(&mut self.pending_plugins);
self.plugins.extend(pending);
}
}
/// Create a new Harness with the given ui closure and a state.
///
/// The ui closure will immediately be called once to create the initial ui.
@@ -240,17 +341,28 @@ impl<'a, State> Harness<'a, State> {
/// Run a frame for each queued event (or a single frame if there are no events).
/// This will call the app closure with each queued event and
/// update the Harness.
#[track_caller]
pub fn step(&mut self) {
debug_assert!(
!self.in_dispatch,
"Harness::step called from inside a plugin hook — use Harness::advance_frame instead to avoid infinite recursion"
);
self.entry_location = Some(std::panic::Location::caller());
let events = std::mem::take(&mut *self.queued_events.lock());
if events.is_empty() {
self.consumed_event_locations.clear();
self._step(false);
}
for event in events {
self.consumed_event_locations.clear();
match event {
EventType::Event(event) => {
self.input.events.push(event);
EventType::Event(event, loc) => {
self.consumed_event_locations.push(loc);
self.input.events.push(event.clone());
self.dispatch(|p, h| p.on_event(h, &event));
}
EventType::Modifiers(modifiers) => {
EventType::Modifiers(modifiers, loc) => {
self.consumed_event_locations.push(loc);
self.input.modifiers = modifiers;
}
}
@@ -258,20 +370,28 @@ impl<'a, State> Harness<'a, State> {
}
}
/// Run a single step. This will not process any events.
/// Run a single step, firing `before_step` / `after_step` plugin hooks.
fn _step(&mut self, sizing_pass: bool) {
self.dispatch(|p, h| p.before_step(h));
self._step_inner(sizing_pass);
self.dispatch(|p, h| p.after_step(h));
}
/// Core frame advance. Does NOT fire plugin hooks — callable from within
/// hooks via [`Self::advance_frame`] without recursing.
fn _step_inner(&mut self, sizing_pass: bool) {
self.input.predicted_dt = self.step_dt;
let mut output = self.ctx.run_ui(self.input.take(), |ui| {
self.response = self.app.run(ui, &mut self.state, sizing_pass);
});
self.kittest.update(
output
.platform_output
.accesskit_update
.take()
.expect("AccessKit was disabled"),
);
let accesskit_update = output
.platform_output
.accesskit_update
.take()
.expect("AccessKit was disabled");
self.last_accesskit_update = Some(accesskit_update.clone());
self.kittest.update(accesskit_update);
self.renderer.handle_delta(&output.textures_delta);
self.output = output;
}
@@ -297,7 +417,9 @@ impl<'a, State> Harness<'a, State> {
/// Resize the test harness to fit the contents. This only works when creating the Harness via
/// [`Harness::new_ui`] / [`Harness::new_ui_state`] or
/// [`HarnessBuilder::build_ui`] / [`HarnessBuilder::build_ui_state`].
#[track_caller]
pub fn fit_contents(&mut self) {
self.entry_location = Some(std::panic::Location::caller());
self._step(true);
// Calculate size including all content (main UI + popups + tooltips)
@@ -326,7 +448,8 @@ impl<'a, State> Harness<'a, State> {
/// - [`Harness::run_steps`].
#[track_caller]
pub fn run(&mut self) -> u64 {
match self.try_run() {
self.entry_location = Some(std::panic::Location::caller());
match self._try_run(false) {
Ok(steps) => steps,
Err(err) => {
panic!("{err}");
@@ -335,8 +458,14 @@ impl<'a, State> Harness<'a, State> {
}
fn _try_run(&mut self, sleep: bool) -> Result<u64, ExceededMaxStepsError> {
debug_assert!(
!self.in_dispatch,
"Harness::run / Harness::try_run called from inside a plugin hook — use Harness::advance_frame instead to avoid infinite recursion"
);
self.dispatch(|p, h| p.before_run(h));
let mut steps = 0;
loop {
let result = loop {
steps += 1;
self.step();
@@ -344,18 +473,19 @@ impl<'a, State> Harness<'a, State> {
// We only care about immediate repaints
if self.root_viewport_output().repaint_delay != Duration::ZERO && !wait_for_images {
break;
break Ok(steps);
} else if sleep || wait_for_images {
std::thread::sleep(Duration::from_secs_f32(self.step_dt));
}
if steps > self.max_steps {
return Err(ExceededMaxStepsError {
break Err(ExceededMaxStepsError {
max_steps: self.max_steps,
repaint_causes: self.ctx.repaint_causes(),
});
}
}
Ok(steps)
};
self.dispatch(|p, h| p.after_run(h, result.as_ref().map(|s| *s)));
result
}
/// Run until
@@ -374,7 +504,9 @@ impl<'a, State> Harness<'a, State> {
/// - [`Harness::step`].
/// - [`Harness::run_steps`].
/// - [`Harness::try_run_realtime`].
#[track_caller]
pub fn try_run(&mut self) -> Result<u64, ExceededMaxStepsError> {
self.entry_location = Some(std::panic::Location::caller());
self._try_run(false)
}
@@ -391,8 +523,10 @@ impl<'a, State> Harness<'a, State> {
/// - [`Harness::step`].
/// - [`Harness::run_steps`].
/// - [`Harness::try_run_realtime`].
#[track_caller]
pub fn run_ok(&mut self) -> Option<u64> {
self.try_run().ok()
self.entry_location = Some(std::panic::Location::caller());
self._try_run(false).ok()
}
/// Run multiple frames, sleeping for [`HarnessBuilder::with_step_dt`] between frames.
@@ -414,13 +548,17 @@ impl<'a, State> Harness<'a, State> {
/// - [`Harness::step`].
/// - [`Harness::run_steps`].
/// - [`Harness::try_run`].
#[track_caller]
pub fn try_run_realtime(&mut self) -> Result<u64, ExceededMaxStepsError> {
self.entry_location = Some(std::panic::Location::caller());
self._try_run(true)
}
/// Run a number of steps.
/// Equivalent to calling [`Harness::step`] x times.
#[track_caller]
pub fn run_steps(&mut self, steps: usize) {
self.entry_location = Some(std::panic::Location::caller());
for _ in 0..steps {
self.step();
}
@@ -456,32 +594,35 @@ impl<'a, State> Harness<'a, State> {
&mut self.state
}
/// Consume the harness and return the state.
pub fn into_state(self) -> State {
self.state
}
/// Queue an event to be processed in the next frame.
#[track_caller]
pub fn event(&self, event: egui::Event) {
self.queued_events.lock().push(EventType::Event(event));
self.queued_events
.lock()
.push(EventType::Event(event, std::panic::Location::caller()));
}
/// Queue an event with modifiers.
///
/// Queues the modifiers to be pressed, then the event, then the modifiers to be released.
#[track_caller]
pub fn event_modifiers(&self, event: egui::Event, modifiers: Modifiers) {
let caller = std::panic::Location::caller();
let mut queue = self.queued_events.lock();
queue.push(EventType::Modifiers(modifiers));
queue.push(EventType::Event(event));
queue.push(EventType::Modifiers(Modifiers::default()));
queue.push(EventType::Modifiers(modifiers, caller));
queue.push(EventType::Event(event, caller));
queue.push(EventType::Modifiers(Modifiers::default(), caller));
}
#[track_caller]
fn modifiers(&self, modifiers: Modifiers) {
self.queued_events
.lock()
.push(EventType::Modifiers(modifiers));
self.queued_events.lock().push(EventType::Modifiers(
modifiers,
std::panic::Location::caller(),
));
}
#[track_caller]
pub fn key_down(&self, key: egui::Key) {
self.event(egui::Event::Key {
key,
@@ -492,6 +633,7 @@ impl<'a, State> Harness<'a, State> {
});
}
#[track_caller]
pub fn key_down_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
self.event_modifiers(
egui::Event::Key {
@@ -505,6 +647,7 @@ impl<'a, State> Harness<'a, State> {
);
}
#[track_caller]
pub fn key_up(&self, key: egui::Key) {
self.event(egui::Event::Key {
key,
@@ -515,6 +658,7 @@ impl<'a, State> Harness<'a, State> {
});
}
#[track_caller]
pub fn key_up_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
self.event_modifiers(
egui::Event::Key {
@@ -535,6 +679,7 @@ impl<'a, State> Harness<'a, State> {
/// - Press [`Key::B`]
/// - Release [`Key::B`]
/// - Release [`Key::A`]
#[track_caller]
pub fn key_combination(&self, keys: &[Key]) {
for key in keys {
self.key_down(*key);
@@ -553,6 +698,7 @@ impl<'a, State> Harness<'a, State> {
/// - Release [`Key::B`]
/// - Release [`Key::A`]
/// - Release [`Modifiers::COMMAND`]
#[track_caller]
pub fn key_combination_modifiers(&self, modifiers: Modifiers, keys: &[Key]) {
self.modifiers(modifiers);
@@ -574,6 +720,7 @@ impl<'a, State> Harness<'a, State> {
/// Press a key.
///
/// This will create a key down event and a key up event.
#[track_caller]
pub fn key_press(&self, key: egui::Key) {
self.key_combination(&[key]);
}
@@ -585,16 +732,19 @@ impl<'a, State> Harness<'a, State> {
/// - create a key down event
/// - create a key up event
/// - reset the modifiers
#[track_caller]
pub fn key_press_modifiers(&self, modifiers: Modifiers, key: egui::Key) {
self.key_combination_modifiers(modifiers, &[key]);
}
/// Move mouse cursor to this position.
#[track_caller]
pub fn hover_at(&self, pos: egui::Pos2) {
self.event(egui::Event::PointerMoved(pos));
}
/// Start dragging from a position.
#[track_caller]
pub fn drag_at(&self, pos: egui::Pos2) {
self.event(egui::Event::PointerButton {
pos,
@@ -605,6 +755,7 @@ impl<'a, State> Harness<'a, State> {
}
/// Stop dragging and remove cursor.
#[track_caller]
pub fn drop_at(&self, pos: egui::Pos2) {
self.event(egui::Event::PointerButton {
pos,
@@ -621,6 +772,7 @@ impl<'a, State> Harness<'a, State> {
///
/// If you click a button and then take a snapshot, the button will be shown as hovered.
/// If you don't want that, you can call this method after clicking.
#[track_caller]
pub fn remove_cursor(&self) {
self.event(egui::Event::PointerGone);
}
@@ -664,7 +816,9 @@ impl<'a, State> Harness<'a, State> {
});
}
self.renderer.render(&self.ctx, &output)
let image = self.renderer.render(&self.ctx, &output)?;
self.dispatch(|p, h| p.on_render(h, &image));
Ok(image)
}
/// Get the root viewport output
@@ -744,39 +898,36 @@ impl<'a, State> Harness<'a, State> {
);
}
struct UiApp {
f: Box<dyn FnMut(&mut egui::Ui)>,
// Wrap the whole `Harness` in an `eframe::App` adapter so we don't need to
// destructure `self` (which we can't, since `Harness` implements `Drop`).
// The adapter delegates `ui`/`logic` through the stored `AppKind`.
struct HarnessAsApp<State: 'static> {
harness: Harness<'static, State>,
}
impl eframe::App for UiApp {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
(self.f)(ui);
impl<State: 'static> eframe::App for HarnessAsApp<State> {
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
if let AppKind::Eframe(crate::app_kind::AppKindEframe { get_app, .. }) =
&mut self.harness.app
{
get_app(&mut self.harness.state).logic(ctx, frame);
}
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
let harness = &mut self.harness;
match &mut harness.app {
AppKind::Ui(f) => f(ui),
AppKind::UiState(f) => f(ui, &mut harness.state),
AppKind::Eframe(crate::app_kind::AppKindEframe { get_app, .. }) => {
get_app(&mut harness.state).ui(ui, frame);
}
}
}
}
struct UiStateApp<State> {
f: Box<dyn FnMut(&mut egui::Ui, &mut State)>,
state: State,
}
impl<State: 'static> eframe::App for UiStateApp<State> {
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
let Self { f, state } = self;
f(ui, state);
}
}
use crate::app_kind::AppKindEframe;
let Self {
ctx, state, app, ..
} = self;
let eframe_app: Box<dyn eframe::App> = match app {
AppKind::Ui(f) => Box::new(UiApp { f }),
AppKind::UiState(f) => Box::new(UiStateApp { f, state }),
AppKind::Eframe(AppKindEframe { take_app, .. }) => take_app(state),
};
let ctx = self.ctx.clone();
let eframe_app: Box<dyn eframe::App> = Box::new(HarnessAsApp { harness: self });
eframe::run_native_ext(
"egui_kittest",
@@ -810,7 +961,7 @@ impl<'a> Harness<'a> {
}
}
impl<'tree, 'node, State> Queryable<'tree, 'node, Node<'tree>> for Harness<'_, State>
impl<'tree, 'node, State: 'static> Queryable<'tree, 'node, Node<'tree>> for Harness<'_, State>
where
'node: 'tree,
{
@@ -818,3 +969,40 @@ where
self.root()
}
}
impl<State: 'static> Drop for Harness<'_, State> {
fn drop(&mut self) {
// Consume SnapshotResults first so its own panic-check runs under our control,
// and so `std::thread::panicking()` reflects snapshot failures when plugins observe
// the final outcome.
#[cfg(feature = "snapshot")]
if let Some(results) = self.snapshot_results.take() {
// Drop may panic; if so, the panic propagates and plugins still see Fail.
drop(results);
}
if self.plugins.is_empty() {
return;
}
if std::thread::panicking() {
plugin::with_fail_test_result(|result| {
self.dispatch(|p, h| p.on_test_result(h, fail_ref(&result)));
});
} else {
self.dispatch(|p, h| p.on_test_result(h, TestResult::Pass));
}
}
}
// Helper: reborrow a `TestResult::Fail` so it can be passed to multiple plugins from
// inside `dispatch`'s FnMut closure.
fn fail_ref<'a>(result: &'a TestResult<'a>) -> TestResult<'a> {
match result {
TestResult::Pass => TestResult::Pass,
TestResult::Fail { message, location } => TestResult::Fail {
message: *message,
location: *location,
},
}
}

View File

@@ -5,8 +5,8 @@ use kittest::{AccessKitNode, NodeT, debug_fmt_node};
use std::fmt::{Debug, Formatter};
pub(crate) enum EventType {
Event(egui::Event),
Modifiers(Modifiers),
Event(egui::Event, &'static std::panic::Location<'static>),
Modifiers(Modifiers, &'static std::panic::Location<'static>),
}
pub(crate) type EventQueue = Mutex<Vec<EventType>>;
@@ -37,27 +37,38 @@ impl<'tree> NodeT<'tree> for Node<'tree> {
}
impl Node<'_> {
#[track_caller]
fn event(&self, event: egui::Event) {
self.queue.lock().push(EventType::Event(event));
self.queue
.lock()
.push(EventType::Event(event, std::panic::Location::caller()));
}
#[track_caller]
fn modifiers(&self, modifiers: Modifiers) {
self.queue.lock().push(EventType::Modifiers(modifiers));
self.queue.lock().push(EventType::Modifiers(
modifiers,
std::panic::Location::caller(),
));
}
#[track_caller]
pub fn hover(&self) {
self.event(egui::Event::PointerMoved(self.rect().center()));
}
/// Click at the node center with the primary button.
#[track_caller]
pub fn click(&self) {
self.click_button(PointerButton::Primary);
}
#[track_caller]
pub fn click_secondary(&self) {
self.click_button(PointerButton::Secondary);
}
#[track_caller]
pub fn click_button(&self, button: PointerButton) {
self.hover();
for pressed in [true, false] {
@@ -70,10 +81,12 @@ impl Node<'_> {
}
}
#[track_caller]
pub fn click_modifiers(&self, modifiers: Modifiers) {
self.click_button_modifiers(PointerButton::Primary, modifiers);
}
#[track_caller]
pub fn click_button_modifiers(&self, button: PointerButton, modifiers: Modifiers) {
self.hover();
self.modifiers(modifiers);
@@ -92,6 +105,7 @@ impl Node<'_> {
///
/// This will trigger a [`accesskit::Action::Click`] action.
/// In contrast to `click()`, this can also click widgets that are not currently visible.
#[track_caller]
pub fn click_accesskit(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(
@@ -115,6 +129,7 @@ impl Node<'_> {
}
}
#[track_caller]
pub fn focus(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
@@ -125,6 +140,7 @@ impl Node<'_> {
}));
}
#[track_caller]
pub fn type_text(&self, text: &str) {
self.event(egui::Event::Text(text.to_owned()));
}
@@ -138,6 +154,7 @@ impl Node<'_> {
}
/// Scroll the node into view.
#[track_caller]
pub fn scroll_to_me(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
@@ -149,6 +166,7 @@ impl Node<'_> {
}
/// Scroll the [`egui::ScrollArea`] containing this node down (100px).
#[track_caller]
pub fn scroll_down(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
@@ -160,6 +178,7 @@ impl Node<'_> {
}
/// Scroll the [`egui::ScrollArea`] containing this node up (100px).
#[track_caller]
pub fn scroll_up(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
@@ -171,6 +190,7 @@ impl Node<'_> {
}
/// Scroll the [`egui::ScrollArea`] containing this node left (100px).
#[track_caller]
pub fn scroll_left(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {
@@ -182,6 +202,7 @@ impl Node<'_> {
}
/// Scroll the [`egui::ScrollArea`] containing this node right (100px).
#[track_caller]
pub fn scroll_right(&self) {
let (target_node, target_tree) = self.accesskit_node.locate();
self.event(egui::Event::AccessKitActionRequest(ActionRequest {

View File

@@ -0,0 +1,183 @@
//! Plugin system for observing and extending the [`crate::Harness`] test lifecycle.
//!
//! Implement [`Plugin`] to hook into harness events: frame steps, run loops, events,
//! renders, snapshots, and final pass/fail. Register plugins via
//! [`crate::HarnessBuilder::with_plugin`] or [`crate::Harness::add_plugin`].
use std::any::Any;
use crate::{ExceededMaxStepsError, Harness};
/// A plugin observes the test-harness lifecycle and can drive additional frames.
///
/// All methods default to no-ops; implement only the ones you need.
///
/// State-agnostic plugins should impl for all `State` so they're reusable across harnesses:
/// ```
/// use egui_kittest::{Harness, Plugin};
///
/// struct MyPlugin;
///
/// impl<S> Plugin<S> for MyPlugin {
/// fn after_step(&mut self, _harness: &mut Harness<'_, S>) {
/// // ...
/// }
/// fn as_any(&self) -> &dyn std::any::Any { self }
/// fn as_any_mut(&mut self) -> &mut dyn std::any::Any { self }
/// }
/// ```
///
/// # Re-entrancy
///
/// Plugin hooks receive `&mut Harness`. Calling [`Harness::step`] / [`Harness::run`] /
/// etc. from inside a hook is forbidden (it would recurse infinitely through your own
/// `after_step`) and is caught by a `debug_assert!`. If a plugin needs to advance the
/// harness from inside a hook — e.g. an inspector that blocks on user input — use
/// [`Harness::advance_frame`] instead.
#[expect(unused_variables, reason = "default no-op impls")]
pub trait Plugin<State = ()>: Send + 'static {
/// Called once at the start of every `run()` / `try_run()` / `try_run_realtime()` /
/// `run_ok()` invocation, before the first step.
fn before_run(&mut self, harness: &mut Harness<'_, State>) {}
/// Called once after the outer run loop exits (successful completion or
/// [`ExceededMaxStepsError`]).
fn after_run(
&mut self,
harness: &mut Harness<'_, State>,
result: Result<u64, &ExceededMaxStepsError>,
) {
}
/// Called immediately before each single-frame step (per-frame, not per public call).
fn before_step(&mut self, harness: &mut Harness<'_, State>) {}
/// Called immediately after each single-frame step.
fn after_step(&mut self, harness: &mut Harness<'_, State>) {}
/// Called after a queued event has been pushed into the harness input, before the
/// frame runs that consumes it.
fn on_event(&mut self, harness: &mut Harness<'_, State>, event: &egui::Event) {}
/// Called from inside [`Harness::render`] after the image is produced. Lets a plugin
/// observe every rendered frame without triggering a second render pass.
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
fn on_render(&mut self, harness: &mut Harness<'_, State>, image: &image::RgbaImage) {}
/// Called from [`Harness::try_snapshot`] / [`Harness::try_snapshot_options`] after
/// the comparison has run, before the result is handed back to the caller. The
/// `image` is the frame that was compared against the stored snapshot.
#[cfg(feature = "snapshot")]
fn on_snapshot(
&mut self,
harness: &mut Harness<'_, State>,
name: &str,
image: &image::RgbaImage,
result: &crate::SnapshotResult,
) {
}
/// Called exactly once, from [`Harness::drop`], after the harness has finalized its
/// snapshot results. `result` is [`TestResult::Pass`] unless a panic is in progress
/// on this thread, in which case it's [`TestResult::Fail`].
///
/// The `message` and `location` fields of `Fail` are only populated if the user has
/// called [`install_panic_hook`]. Without the hook, the variant still flips to
/// `Fail` but both fields are `None`.
fn on_test_result(&mut self, harness: &mut Harness<'_, State>, result: TestResult<'_>) {}
/// Downcast support — implement as `self`.
fn as_any(&self) -> &dyn Any;
/// Downcast support — implement as `self`.
fn as_any_mut(&mut self) -> &mut dyn Any;
}
/// Location of a panic — a `std::panic::Location` stripped of its borrow so it can be
/// stored in a thread-local and handed to plugins.
#[derive(Debug, Clone)]
pub struct PanicLocation {
pub file: String,
pub line: u32,
pub column: u32,
}
/// Outcome of a test, as seen by [`Plugin::on_test_result`].
#[derive(Debug)]
pub enum TestResult<'a> {
/// No panic in progress on this thread when `on_test_result` fired.
Pass,
/// A panic is in progress on this thread.
///
/// `message` and `location` are populated only if [`install_panic_hook`] has been
/// called (once, process-wide) before the panic occurred.
Fail {
message: Option<&'a str>,
location: Option<&'a PanicLocation>,
},
}
// ------------------------------------------------------------------------------------------------
// Opt-in panic hook for capturing the panic message + location so plugins can report them.
//
// Installing a `std::panic::set_hook` from library code is a process-wide side effect, so we
// do NOT install it automatically. Users opt in once (e.g. from a test main or `#[ctor]`).
use std::cell::RefCell;
use std::sync::OnceLock;
thread_local! {
static LAST_PANIC: RefCell<Option<PanicRecord>> = const { RefCell::new(None) };
}
struct PanicRecord {
message: Option<String>,
location: Option<PanicLocation>,
}
static INSTALLED: OnceLock<()> = OnceLock::new();
/// Install a `std::panic::set_hook` that captures each panic's message and location into
/// a thread-local, which [`Plugin::on_test_result`] then reads into its `Fail` variant.
///
/// Process-wide and idempotent (subsequent calls are no-ops). Chains to whatever hook was
/// previously installed, so existing output is preserved.
pub fn install_panic_hook() {
INSTALLED.get_or_init(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let message = info
.payload()
.downcast_ref::<&'static str>()
.map(|s| (*s).to_owned())
.or_else(|| info.payload().downcast_ref::<String>().cloned());
let location = info.location().map(|loc| PanicLocation {
file: loc.file().to_owned(),
line: loc.line(),
column: loc.column(),
});
LAST_PANIC.with(|slot| {
*slot.borrow_mut() = Some(PanicRecord { message, location });
});
prev(info);
}));
});
}
/// Called from [`Harness::drop`] when `std::thread::panicking()` is true. Builds a
/// [`TestResult::Fail`] borrowing from the thread-local panic record, invokes `f` with
/// it, then restores the record.
///
/// We have to invoke via callback (rather than returning the `Fail`) because the borrows
/// live inside the thread-local's `RefCell`.
pub(crate) fn with_fail_test_result<R>(f: impl FnOnce(TestResult<'_>) -> R) -> R {
LAST_PANIC.with(|slot| {
let borrow = slot.borrow();
let (message, location) = match borrow.as_ref() {
Some(rec) => (rec.message.as_deref(), rec.location.as_ref()),
None => (None, None),
};
f(TestResult::Fail { message, location })
})
}

View File

@@ -591,7 +591,7 @@ pub fn image_snapshot(current: &image::RgbaImage, name: impl Into<String>) {
}
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
impl<State> Harness<'_, State> {
impl<State: 'static> Harness<'_, State> {
/// The default options used for snapshot tests.
/// set by [`crate::HarnessBuilder::with_options`].
pub fn options(&self) -> &SnapshotOptions {
@@ -623,10 +623,14 @@ impl<State> Harness<'_, State> {
name: impl Into<String>,
options: &SnapshotOptions,
) -> SnapshotResult {
let image = self
.render()
.map_err(|err| SnapshotError::RenderError { err })?;
try_image_snapshot_options(&image, name.into(), options)
let name = name.into();
let image = match self.render() {
Ok(img) => img,
Err(err) => return Err(SnapshotError::RenderError { err }),
};
let result = try_image_snapshot_options(&image, name.clone(), options);
self.dispatch(|p, h| p.on_snapshot(h, &name, &image, &result));
result
}
/// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot.
@@ -641,10 +645,8 @@ impl<State> Harness<'_, State> {
/// Returns a [`SnapshotError`] if the image does not match the snapshot, if there was an
/// error reading or writing the snapshot, if the rendering fails or if no default renderer is available.
pub fn try_snapshot(&mut self, name: impl Into<String>) -> SnapshotResult {
let image = self
.render()
.map_err(|err| SnapshotError::RenderError { err })?;
try_image_snapshot_options(&image, name.into(), &self.default_snapshot_options)
let options = self.default_snapshot_options.clone();
self.try_snapshot_options(name, &options)
}
/// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot
@@ -674,7 +676,10 @@ impl<State> Harness<'_, State> {
#[track_caller]
pub fn snapshot_options(&mut self, name: impl Into<String>, options: &SnapshotOptions) {
let result = self.try_snapshot_options(name, options);
self.snapshot_results.add(result);
self.snapshot_results
.as_mut()
.expect("SnapshotResults already taken")
.add(result);
}
/// Render an image using the setup [`crate::TestRenderer`] and compare it to the snapshot.
@@ -691,7 +696,10 @@ impl<State> Harness<'_, State> {
#[track_caller]
pub fn snapshot(&mut self, name: impl Into<String>) {
let result = self.try_snapshot(name);
self.snapshot_results.add(result);
self.snapshot_results
.as_mut()
.expect("SnapshotResults already taken")
.add(result);
}
/// Render a snapshot, save it to a temp file and open it in the default image viewer.
@@ -744,7 +752,10 @@ impl<State> Harness<'_, State> {
/// This removes the snapshot results from the harness. Useful if you e.g. want to merge it
/// with the results from another harness (using [`SnapshotResults::add`]).
pub fn take_snapshot_results(&mut self) -> SnapshotResults {
std::mem::take(&mut self.snapshot_results)
// Replace with a fresh SnapshotResults so subsequent snapshot calls don't panic.
self.snapshot_results
.replace(SnapshotResults::default())
.expect("SnapshotResults already taken")
}
}

View File

@@ -0,0 +1,313 @@
#![allow(
clippy::unwrap_used,
clippy::disallowed_methods,
clippy::disallowed_types,
clippy::clone_on_ref_ptr
)]
use std::sync::{Arc, Mutex};
use egui_kittest::{ExceededMaxStepsError, Harness, Plugin, TestResult};
type Log = Arc<Mutex<Vec<String>>>;
#[derive(Default)]
struct CountingPlugin {
log: Log,
}
impl CountingPlugin {
fn new() -> (Self, Log) {
let log: Log = Arc::default();
(Self { log: log.clone() }, log)
}
fn push(&self, tag: &str) {
self.log.lock().unwrap().push(tag.to_owned());
}
}
impl<S> Plugin<S> for CountingPlugin {
fn before_run(&mut self, _h: &mut Harness<'_, S>) {
self.push("before_run");
}
fn after_run(&mut self, _h: &mut Harness<'_, S>, result: Result<u64, &ExceededMaxStepsError>) {
self.push(if result.is_ok() {
"after_run:ok"
} else {
"after_run:err"
});
}
fn before_step(&mut self, _h: &mut Harness<'_, S>) {
self.push("before_step");
}
fn after_step(&mut self, _h: &mut Harness<'_, S>) {
self.push("after_step");
}
fn on_event(&mut self, _h: &mut Harness<'_, S>, _event: &egui::Event) {
self.push("on_event");
}
#[cfg(any(feature = "wgpu", feature = "snapshot"))]
fn on_render(&mut self, _h: &mut Harness<'_, S>, _image: &image::RgbaImage) {
self.push("on_render");
}
#[cfg(feature = "snapshot")]
fn on_snapshot(
&mut self,
_h: &mut Harness<'_, S>,
name: &str,
_image: &image::RgbaImage,
result: &egui_kittest::SnapshotResult,
) {
self.push(&format!(
"on_snapshot:{}:{}",
name,
if result.is_ok() { "ok" } else { "err" }
));
}
fn on_test_result(&mut self, _h: &mut Harness<'_, S>, result: TestResult<'_>) {
self.push(match result {
TestResult::Pass => "on_test_result:pass",
TestResult::Fail { .. } => "on_test_result:fail",
});
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
/// Lifecycle ordering: a simple run+drop cycle fires the expected hooks in order.
#[test]
fn hooks_fire_in_expected_order() {
let (plugin, log) = CountingPlugin::new();
let mut harness = Harness::builder().with_plugin(plugin).build_ui(|ui| {
ui.label("hi");
});
harness.run();
drop(harness);
let log = log.lock().unwrap().clone();
// Construction calls `run_ok()`, so the first batch of hooks fires during `new_ui`:
// before_run, before_step, after_step, after_run
// Then `harness.run()` fires another set.
// Then Drop fires `on_test_result:pass`.
assert_eq!(log.first().map(String::as_str), Some("before_run"));
assert!(log.contains(&"before_step".to_owned()));
assert!(log.contains(&"after_step".to_owned()));
assert!(log.contains(&"after_run:ok".to_owned()));
assert_eq!(log.last().map(String::as_str), Some("on_test_result:pass"));
// Every before_step has a matching after_step.
let befores = log.iter().filter(|s| s == &"before_step").count();
let afters = log.iter().filter(|s| s == &"after_step").count();
assert_eq!(befores, afters);
}
/// `on_event` fires per queued event.
#[test]
fn on_event_fires_per_event() {
let (plugin, log) = CountingPlugin::new();
let mut harness = Harness::builder().with_plugin(plugin).build_ui(|ui| {
ui.label("hi");
});
log.lock().unwrap().clear(); // drop construction-time hooks
harness.event(egui::Event::PointerMoved(egui::pos2(10.0, 10.0)));
harness.event(egui::Event::PointerMoved(egui::pos2(20.0, 20.0)));
harness.step();
let log = log.lock().unwrap();
let events = log.iter().filter(|s| s == &"on_event").count();
assert_eq!(events, 2, "expected 2 on_event calls, got log: {log:?}");
}
/// `advance_frame` does NOT fire `before_step`/`after_step`.
#[test]
fn advance_frame_skips_hooks() {
struct DrivingPlugin {
log: Log,
drove: bool,
}
impl<S> Plugin<S> for DrivingPlugin {
fn after_step(&mut self, h: &mut Harness<'_, S>) {
self.log.lock().unwrap().push("after_step".into());
if !self.drove {
self.drove = true;
// Call advance_frame from inside a hook — must not recurse.
h.advance_frame();
}
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
let log: Log = Arc::default();
let mut harness = Harness::builder()
.with_plugin(DrivingPlugin {
log: log.clone(),
drove: false,
})
.build_ui(|ui| {
ui.label("hi");
});
log.lock().unwrap().clear();
harness.step();
let log = log.lock().unwrap();
// Exactly one after_step from the user's step(), plus any from construction-time run_ok
// (cleared above). advance_frame must NOT have produced another after_step.
assert_eq!(log.iter().filter(|s| s == &"after_step").count(), 1);
}
/// Registering a plugin inside a hook defers it to the next dispatch.
#[test]
fn mid_dispatch_registration_is_deferred() {
struct Registrar {
log: Log,
registered: bool,
}
impl<S: 'static> Plugin<S> for Registrar {
fn after_step(&mut self, h: &mut Harness<'_, S>) {
self.log.lock().unwrap().push("registrar:after_step".into());
if !self.registered {
self.registered = true;
let (latecomer, latecomer_log) = CountingPlugin::new();
// Share the same log so we can see its hooks interleave.
*latecomer.log.lock().unwrap() = std::mem::take(&mut self.log.lock().unwrap());
self.log = latecomer.log.clone();
let _ = latecomer_log; // dropped
h.add_plugin(latecomer);
}
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
let log: Log = Arc::default();
let mut harness = Harness::builder()
.with_plugin(Registrar {
log: log.clone(),
registered: false,
})
.build_ui(|ui| {
ui.label("hi");
});
harness.step(); // registrar hooks fire here; latecomer gets registered
// The latecomer should NOT see this step's hooks (it was registered mid-dispatch).
// On the next step, it should start seeing hooks.
// Easier assertion: before the second step, the latecomer shouldn't have produced
// any "before_step" entries. Since we merged logs, we can't easily isolate — instead,
// verify the harness does not deadlock / recurse.
harness.step();
assert!(harness.plugin::<CountingPlugin>().is_some());
}
/// Downcasting via `plugin::<P>()` / `plugin_mut::<P>()` / `take_plugin::<P>()`.
#[test]
fn downcast_plugin_by_type() {
let (plugin, _log) = CountingPlugin::new();
let mut harness = Harness::builder().with_plugin(plugin).build_ui(|ui| {
ui.label("hi");
});
assert!(harness.plugin::<CountingPlugin>().is_some());
assert!(harness.plugin_mut::<CountingPlugin>().is_some());
let taken = harness.take_plugin::<CountingPlugin>();
assert!(taken.is_some());
assert!(harness.plugin::<CountingPlugin>().is_none());
}
/// When `Harness::drop` fires while a panic is unwinding, `on_test_result` gets `Fail`.
#[test]
fn on_test_result_sees_panic() {
let (plugin, log) = CountingPlugin::new();
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _harness = Harness::builder().with_plugin(plugin).build_ui(|ui| {
ui.label("hi");
});
// Panic while the harness is alive so its Drop runs during unwind.
panic!("kaboom");
}));
assert!(result.is_err());
let log = log.lock().unwrap();
let last = log.last().map(String::as_str);
assert_eq!(last, Some("on_test_result:fail"), "log = {log:?}");
}
/// Calling `Harness::step` from inside a plugin hook should panic in debug builds
/// (the `in_dispatch` guard).
#[cfg(debug_assertions)]
#[test]
#[should_panic(expected = "inside a plugin hook")]
fn reentrant_step_panics_in_debug() {
struct Misbehaver;
impl<S: 'static> Plugin<S> for Misbehaver {
fn after_step(&mut self, h: &mut Harness<'_, S>) {
// Forbidden: step from inside a hook.
h.step();
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
let mut harness = Harness::builder().with_plugin(Misbehaver).build_ui(|ui| {
ui.label("hi");
});
harness.step();
}
/// `on_snapshot` fires with an Err result for a missing snapshot.
#[cfg(feature = "snapshot")]
#[test]
fn on_snapshot_fires_with_err_for_missing() {
let (plugin, log) = CountingPlugin::new();
let tmp = tempfile::tempdir().unwrap();
let mut harness = Harness::builder()
.wgpu()
.with_plugin(plugin)
.with_options(
egui_kittest::SnapshotOptions::default().output_path(tmp.path().to_path_buf()),
)
.build_ui(|ui| {
ui.label("snap");
});
let result = harness.try_snapshot("nonexistent_snapshot_for_plugin_test");
// Expect Err (no snapshot file exists in tmpdir).
assert!(result.is_err(), "expected snapshot err, got {result:?}");
let log = log.lock().unwrap();
let on_snapshot_entry = log
.iter()
.find(|s| s.starts_with("on_snapshot:"))
.expect("on_snapshot should have been logged");
assert!(
on_snapshot_entry.ends_with(":err"),
"entry = {on_snapshot_entry}"
);
assert!(
on_snapshot_entry.contains("nonexistent_snapshot_for_plugin_test"),
"entry should contain the snapshot name: {on_snapshot_entry}"
);
}