diff --git a/crates/egui_kittest/src/builder.rs b/crates/egui_kittest/src/builder.rs index dc4757ee5..e11fab706 100644 --- a/crates/egui_kittest/src/builder.rs +++ b/crates/egui_kittest/src/builder.rs @@ -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 { pub(crate) state: PhantomData, pub(crate) renderer: Box, pub(crate) wait_for_pending_images: bool, + pub(crate) plugins: Vec>>, #[cfg(feature = "snapshot")] pub(crate) default_snapshot_options: crate::SnapshotOptions, @@ -37,6 +38,7 @@ impl Default for HarnessBuilder { 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 Default for HarnessBuilder { } } -impl HarnessBuilder { +impl HarnessBuilder { /// Set the size of the window. #[inline] pub fn with_size(mut self, size: impl Into) -> Self { @@ -161,6 +163,16 @@ impl HarnessBuilder { 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) -> 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. diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index 2b7c4f8f3..0dbbe2296 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -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>>, + pending_plugins: Vec>>, + in_dispatch: bool, + entry_location: Option<&'static std::panic::Location<'static>>, + consumed_event_locations: Vec<&'static std::panic::Location<'static>>, + last_accesskit_update: Option, + #[cfg(feature = "snapshot")] default_snapshot_options: SnapshotOptions, #[cfg(feature = "snapshot")] - snapshot_results: SnapshotResults, + snapshot_results: Option, } impl Debug for Harness<'_, State> { @@ -95,7 +105,7 @@ impl 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, @@ -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) { + let boxed: Box> = 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>(&self) -> Option<&P> { + self.plugins + .iter() + .find_map(|p| p.as_any().downcast_ref::

()) + } + + /// Mutably borrow a registered plugin by type. + pub fn plugin_mut>(&mut self) -> Option<&mut P> { + self.plugins + .iter_mut() + .find_map(|p| p.as_any_mut().downcast_mut::

()) + } + + /// Remove and return the first plugin of the given type. + pub fn take_plugin>(&mut self) -> Option> { + let idx = self.plugins.iter().position(|p| p.as_any().is::

())?; + let boxed = self.plugins.remove(idx); + let raw: *mut dyn Plugin = Box::into_raw(boxed); + // SAFETY: `is::

()` 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::

()) }) + } + + /// 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, &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; } @@ -301,7 +421,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) @@ -330,7 +452,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}"); @@ -339,8 +462,14 @@ impl<'a, State> Harness<'a, State> { } fn _try_run(&mut self, sleep: bool) -> Result { + 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(); @@ -348,18 +477,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 @@ -378,7 +508,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 { + self.entry_location = Some(std::panic::Location::caller()); self._try_run(false) } @@ -395,8 +527,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 { - 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. @@ -418,13 +552,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 { + 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(); } @@ -460,32 +598,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, @@ -496,6 +637,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 { @@ -509,6 +651,7 @@ impl<'a, State> Harness<'a, State> { ); } + #[track_caller] pub fn key_up(&self, key: egui::Key) { self.event(egui::Event::Key { key, @@ -519,6 +662,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 { @@ -539,6 +683,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); @@ -557,6 +702,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); @@ -578,6 +724,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]); } @@ -589,16 +736,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, @@ -609,6 +759,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, @@ -625,6 +776,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); } @@ -668,7 +820,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 @@ -748,39 +902,36 @@ impl<'a, State> Harness<'a, State> { ); } - struct UiApp { - f: Box, + // 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 { + 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 eframe::App for HarnessAsApp { + 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 { - f: Box, - state: State, - } - - impl eframe::App for UiStateApp { - 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 = 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 = Box::new(HarnessAsApp { harness: self }); eframe::run_native_ext( "egui_kittest", @@ -814,7 +965,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, { @@ -822,3 +973,40 @@ where self.root() } } + +impl 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, + }, + } +} diff --git a/crates/egui_kittest/src/node.rs b/crates/egui_kittest/src/node.rs index 7e3161c09..fca21f338 100644 --- a/crates/egui_kittest/src/node.rs +++ b/crates/egui_kittest/src/node.rs @@ -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>; @@ -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 { diff --git a/crates/egui_kittest/src/plugin.rs b/crates/egui_kittest/src/plugin.rs new file mode 100644 index 000000000..fed1964e7 --- /dev/null +++ b/crates/egui_kittest/src/plugin.rs @@ -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 Plugin 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: 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, + ) { + } + + /// 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> = const { RefCell::new(None) }; +} + +struct PanicRecord { + message: Option, + location: Option, +} + +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::().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(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 }) + }) +} diff --git a/crates/egui_kittest/src/snapshot.rs b/crates/egui_kittest/src/snapshot.rs index adbe32399..41e4912a4 100644 --- a/crates/egui_kittest/src/snapshot.rs +++ b/crates/egui_kittest/src/snapshot.rs @@ -590,7 +590,7 @@ pub fn image_snapshot(current: &image::RgbaImage, name: impl Into) { } #[cfg(any(feature = "wgpu", feature = "snapshot"))] -impl Harness<'_, State> { +impl Harness<'_, State> { /// The default options used for snapshot tests. /// set by [`crate::HarnessBuilder::with_options`]. pub fn options(&self) -> &SnapshotOptions { @@ -622,10 +622,14 @@ impl Harness<'_, State> { name: impl Into, 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. @@ -640,10 +644,8 @@ impl 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) -> 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 @@ -673,7 +675,10 @@ impl Harness<'_, State> { #[track_caller] pub fn snapshot_options(&mut self, name: impl Into, 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. @@ -690,7 +695,10 @@ impl Harness<'_, State> { #[track_caller] pub fn snapshot(&mut self, name: impl Into) { 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. @@ -743,7 +751,10 @@ impl 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") } } diff --git a/crates/egui_kittest/tests/plugin.rs b/crates/egui_kittest/tests/plugin.rs new file mode 100644 index 000000000..0154b3223 --- /dev/null +++ b/crates/egui_kittest/tests/plugin.rs @@ -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>>; + +#[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 Plugin 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) { + 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 Plugin 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 Plugin 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::().is_some()); +} + +/// Downcasting via `plugin::

()` / `plugin_mut::

()` / `take_plugin::

()`. +#[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::().is_some()); + assert!(harness.plugin_mut::().is_some()); + let taken = harness.take_plugin::(); + assert!(taken.is_some()); + assert!(harness.plugin::().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 Plugin 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}" + ); +}