#![cfg_attr(doc, doc = include_str!("../README.md"))] //! //! ## Feature flags #![cfg_attr(feature = "document-features", doc = document_features::document_features!())] #![expect(clippy::unwrap_used)] // TODO(emilk): avoid unwraps mod builder; #[cfg(feature = "snapshot")] mod snapshot; #[cfg(feature = "snapshot")] pub use crate::snapshot::*; mod app_kind; mod config; mod node; mod renderer; #[cfg(feature = "wgpu")] mod texture_to_image; #[cfg(feature = "wgpu")] pub mod wgpu; // re-exports: pub use { self::{builder::*, node::*, renderer::*}, kittest, }; use core::{ fmt::{Debug, Display, Formatter}, time::Duration, }; use egui::{ Color32, Key, Modifiers, PointerButton, Pos2, Rect, RepaintCause, Shape, Vec2, ViewportId, epaint::{ClippedShape, RectShape}, style::ScrollAnimation, }; use kittest::Queryable; use crate::app_kind::AppKind; #[derive(Debug, Clone)] pub struct ExceededMaxStepsError { pub max_steps: u64, pub repaint_causes: Vec, } impl Display for ExceededMaxStepsError { fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { write!( f, "Harness::run exceeded max_steps ({}). If your expect your ui to keep repainting \ (e.g. when showing a spinner) call Harness::step or Harness::run_steps instead.\ \nRepaint causes: {:#?}", self.max_steps, self.repaint_causes, ) } } /// The test Harness. This contains everything needed to run the test. /// /// Create a new Harness using [`Harness::new_ui`] or [`Harness::builder`]. /// /// The [Harness] has a optional generic state that can be used to pass data to the app / ui closure. /// In _most cases_ it should be fine to just store the state in the closure itself. /// The state functions are useful if you need to access the state after the harness has been created. /// /// 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 ctx: egui::Context, input: egui::RawInput, kittest: kittest::State, output: egui::FullOutput, app: AppKind<'a, State>, response: Option, state: State, renderer: Box, max_steps: u64, step_dt: f32, wait_for_pending_images: bool, queued_events: EventQueue, #[cfg(feature = "snapshot")] default_snapshot_options: SnapshotOptions, #[cfg(feature = "snapshot")] snapshot_results: SnapshotResults, } impl Debug for Harness<'_, State> { fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { self.kittest.fmt(f) } } impl<'a, State> Harness<'a, State> { #[track_caller] pub(crate) fn from_builder( builder: HarnessBuilder, mut app: AppKind<'a, State>, mut state: State, ctx: Option, ) -> Self { let HarnessBuilder { screen_rect, pixels_per_point, theme, os, max_steps, step_dt, state: _, mut renderer, wait_for_pending_images, #[cfg(feature = "snapshot")] default_snapshot_options, // rustfmt adds this weird indentation below. // See: https://github.com/rust-lang/rustfmt/issues/5920 #[cfg(feature = "wgpu")] render_options: _, } = builder; let ctx = ctx.unwrap_or_default(); ctx.set_theme(theme); ctx.set_os(os); ctx.enable_accesskit(); ctx.all_styles_mut(|style| { // Disable cursor blinking so it doesn't interfere with snapshots style.visuals.text_cursor.blink = false; style.scroll_animation = ScrollAnimation::none(); style.animation_time = 0.0; }); let mut input = egui::RawInput { screen_rect: Some(screen_rect), ..Default::default() }; let viewport = input.viewports.get_mut(&ViewportId::ROOT).unwrap(); viewport.native_pixels_per_point = Some(pixels_per_point); let mut response = None; // We need to run egui for a single frame so that the AccessKit state can be initialized // and users can immediately start querying for widgets. let mut output = ctx.run_ui(input.clone(), |ui| { response = app.run(ui, &mut state, false); }); renderer.handle_delta(&mut output.textures_delta); let mut harness = Self { app, ctx, input, kittest: kittest::State::new( output .platform_output .accesskit_update .take() .expect("AccessKit was disabled"), ), output, response, state, renderer, max_steps, step_dt, wait_for_pending_images, queued_events: Default::default(), #[cfg(feature = "snapshot")] default_snapshot_options, #[cfg(feature = "snapshot")] snapshot_results: SnapshotResults::default(), }; // Handle any viewport commands (e.g. a screenshot or resize) requested during the initial // frame above (which didn't go through `_step`). harness.handle_viewport_commands(); // Run the harness until it is stable, ensuring that all Areas are shown and animations are done harness.run_ok(); harness } /// Create a [`Harness`] via a [`HarnessBuilder`]. pub fn builder() -> HarnessBuilder { HarnessBuilder::default() } /// 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. /// /// If you e.g. want to customize the size of the ui, you can use [`Harness::builder`]. /// /// # Example /// ```rust /// # use egui_kittest::{Harness, kittest::Queryable}; /// let mut checked = false; /// let mut harness = Harness::new_ui_state(|ui, checked| { /// ui.checkbox(checked, "Check me!"); /// }, checked); /// /// harness.get_by_label("Check me!").click(); /// harness.run(); /// /// assert_eq!(*harness.state(), true); /// ``` #[track_caller] pub fn new_ui_state(app: impl FnMut(&mut egui::Ui, &mut State) + 'a, state: State) -> Self { Self::builder().build_ui_state(app, state) } /// Create a new [Harness] from the given eframe creation closure. #[cfg(feature = "eframe")] #[track_caller] pub fn new_eframe(builder: impl FnOnce(&mut eframe::CreationContext<'a>) -> State) -> Self where State: eframe::App + 'static, { Self::builder().build_eframe(builder) } /// Set the size of the window. /// Note: If you only want to set the size once at the beginning, /// prefer using [`HarnessBuilder::with_size`]. #[inline] pub fn set_size(&mut self, size: Vec2) -> &mut Self { self.input.screen_rect = Some(Rect::from_min_size(Pos2::ZERO, size)); self } /// Set the `pixels_per_point` of the window. /// Note: If you only want to set the `pixels_per_point` once at the beginning, /// prefer using [`HarnessBuilder::with_pixels_per_point`]. #[inline] pub fn set_pixels_per_point(&mut self, pixels_per_point: f32) -> &mut Self { self.ctx.set_pixels_per_point(pixels_per_point); self } /// 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. pub fn step(&mut self) { let events = core::mem::take(&mut *self.queued_events.lock()); if events.is_empty() { self.step_impl(false); } for event in events { self.input.events.push(event); self.step_impl(false); } } /// Run a single step. This will not process any events. fn step_impl(&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"), ); self.renderer.handle_delta(&mut output.textures_delta); self.output = output; self.handle_viewport_commands(); } /// Calculate the rect that includes all popups and tooltips. fn compute_total_rect_with_popups(&self) -> Option { // Start with the standard response rect let mut used = self.response.as_ref()?.rect; // Add all visible areas from other orders (popups, tooltips, etc.) self.ctx.memory(|mem| { mem.areas() .visible_layer_ids() .into_iter() .filter(|layer_id| layer_id.order != egui::Order::Background) .filter_map(|layer_id| mem.area_rect(layer_id.id)) .for_each(|area_rect| used |= area_rect); }); Some(used) } /// 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`]. pub fn fit_contents(&mut self) { self.step_impl(true); // Calculate size including all content (main UI + popups + tooltips) if let Some(rect) = self.compute_total_rect_with_popups() { self.set_size(rect.size()); } self.run_ok(); } /// Run until /// - all animations are done /// - no more repaints are requested /// /// Returns the number of frames that were run. /// /// # Panics /// Panics if the number of steps exceeds the maximum number of steps set /// in [`HarnessBuilder::with_max_steps`]. /// /// See also: /// - [`Harness::try_run`]. /// - [`Harness::try_run_realtime`]. /// - [`Harness::run_ok`]. /// - [`Harness::step`]. /// - [`Harness::run_steps`]. #[track_caller] pub fn run(&mut self) -> u64 { match self.try_run() { Ok(steps) => steps, Err(err) => { panic!("{err}"); } } } fn try_run_impl(&mut self, sleep: bool) -> Result { let mut steps = 0; loop { steps += 1; self.step(); let wait_for_images = self.wait_for_pending_images && self.ctx.has_pending_images(); // We only care about immediate repaints if self.root_viewport_output().repaint_delay != Duration::ZERO && !wait_for_images { break; } else if sleep || wait_for_images { std::thread::sleep(Duration::from_secs_f32(self.step_dt)); } if steps > self.max_steps { return Err(ExceededMaxStepsError { max_steps: self.max_steps, repaint_causes: self.ctx.repaint_causes(), }); } } Ok(steps) } /// Run until /// - all animations are done /// - no more repaints are requested /// - the maximum number of steps is reached (See [`HarnessBuilder::with_max_steps`]) /// /// Returns the number of steps that were run. /// /// # Errors /// Returns an error if the maximum number of steps is exceeded. /// /// See also: /// - [`Harness::run`]. /// - [`Harness::run_ok`]. /// - [`Harness::step`]. /// - [`Harness::run_steps`]. /// - [`Harness::try_run_realtime`]. pub fn try_run(&mut self) -> Result { self.try_run_impl(false) } /// Run until /// - all animations are done /// - no more repaints are requested /// - the maximum number of steps is reached (See [`HarnessBuilder::with_max_steps`]) /// /// Returns the number of steps that were run, or None if the maximum number of steps was exceeded. /// /// See also: /// - [`Harness::run`]. /// - [`Harness::try_run`]. /// - [`Harness::step`]. /// - [`Harness::run_steps`]. /// - [`Harness::try_run_realtime`]. pub fn run_ok(&mut self) -> Option { self.try_run().ok() } /// Run multiple frames, sleeping for [`HarnessBuilder::with_step_dt`] between frames. /// /// This is useful to e.g. wait for an async operation to complete (e.g. loading of images). /// Runs until /// - all animations are done /// - no more repaints are requested /// - the maximum number of steps is reached (See [`HarnessBuilder::with_max_steps`]) /// /// Returns the number of steps that were run. /// /// # Errors /// Returns an error if the maximum number of steps is exceeded. /// /// See also: /// - [`Harness::run`]. /// - [`Harness::run_ok`]. /// - [`Harness::step`]. /// - [`Harness::run_steps`]. /// - [`Harness::try_run`]. pub fn try_run_realtime(&mut self) -> Result { self.try_run_impl(true) } /// Run a number of steps. /// Equivalent to calling [`Harness::step`] x times. pub fn run_steps(&mut self, steps: usize) { for _ in 0..steps { self.step(); } } /// Access the [`egui::RawInput`] for the next frame. pub fn input(&self) -> &egui::RawInput { &self.input } /// Access the [`egui::RawInput`] for the next frame mutably. pub fn input_mut(&mut self) -> &mut egui::RawInput { &mut self.input } /// Access the [`egui::FullOutput`] for the last frame. pub fn output(&self) -> &egui::FullOutput { &self.output } /// Access the [`kittest::State`]. pub fn kittest_state(&self) -> &kittest::State { &self.kittest } /// Access the state. pub fn state(&self) -> &State { &self.state } /// Access the state mutably. pub fn state_mut(&mut self) -> &mut 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. pub fn event(&self, event: egui::Event) { self.queued_events.lock().push(event); } /// Queue an event with modifiers. /// /// Queues the modifiers to be pressed, then the event, then the modifiers to be released. pub fn event_modifiers(&self, event: egui::Event, modifiers: Modifiers) { let mut queue = self.queued_events.lock(); queue.push(egui::Event::ModifiersChanged(modifiers)); queue.push(event); queue.push(egui::Event::ModifiersChanged(Modifiers::default())); } fn modifiers(&self, modifiers: Modifiers) { self.queued_events .lock() .push(egui::Event::ModifiersChanged(modifiers)); } pub fn key_down(&self, key: egui::Key) { self.event(egui::Event::Key { key, pressed: true, modifiers: Modifiers::default(), repeat: false, physical_key: None, }); } pub fn key_down_modifiers(&self, modifiers: Modifiers, key: egui::Key) { self.event_modifiers( egui::Event::Key { key, pressed: true, modifiers, repeat: false, physical_key: None, }, modifiers, ); } pub fn key_up(&self, key: egui::Key) { self.event(egui::Event::Key { key, pressed: false, modifiers: Modifiers::default(), repeat: false, physical_key: None, }); } pub fn key_up_modifiers(&self, modifiers: Modifiers, key: egui::Key) { self.event_modifiers( egui::Event::Key { key, pressed: false, modifiers, repeat: false, physical_key: None, }, modifiers, ); } /// Press the given keys in combination. /// /// For e.g. [`Key::A`] + [`Key::B`] this would generate: /// - Press [`Key::A`] /// - Press [`Key::B`] /// - Release [`Key::B`] /// - Release [`Key::A`] pub fn key_combination(&self, keys: &[Key]) { for key in keys { self.key_down(*key); } for key in keys.iter().rev() { self.key_up(*key); } } /// Press the given keys in combination, with modifiers. /// /// For e.g. [`Modifiers::COMMAND`] + [`Key::A`] + [`Key::B`] this would generate: /// - Press [`Modifiers::COMMAND`] /// - Press [`Key::A`] /// - Press [`Key::B`] /// - Release [`Key::B`] /// - Release [`Key::A`] /// - Release [`Modifiers::COMMAND`] pub fn key_combination_modifiers(&self, modifiers: Modifiers, keys: &[Key]) { self.modifiers(modifiers); for pressed in [true, false] { for key in keys { self.event(egui::Event::Key { key: *key, pressed, modifiers, repeat: false, physical_key: None, }); } } self.modifiers(Modifiers::default()); } /// Press a key. /// /// This will create a key down event and a key up event. pub fn key_press(&self, key: egui::Key) { self.key_combination(&[key]); } /// Press a key with modifiers. /// /// This will /// - set the modifiers /// - create a key down event /// - create a key up event /// - reset the modifiers pub fn key_press_modifiers(&self, modifiers: Modifiers, key: egui::Key) { self.key_combination_modifiers(modifiers, &[key]); } /// Move mouse cursor to this position. pub fn hover_at(&self, pos: egui::Pos2) { self.event(egui::Event::PointerMoved(pos)); } /// Start dragging from a position. pub fn drag_at(&self, pos: egui::Pos2) { self.event(egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE, }); } /// Stop dragging and remove cursor. pub fn drop_at(&self, pos: egui::Pos2) { self.event(egui::Event::PointerButton { pos, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE, }); self.remove_cursor(); } /// Remove the cursor from the screen. /// /// Will fire a [`egui::Event::PointerGone`] event. /// /// 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. pub fn remove_cursor(&self) { self.event(egui::Event::PointerGone); } /// Mask something. Useful for snapshot tests. /// /// Call this _after_ [`Self::run`] and before [`Self::snapshot`]. /// This will add a [`RectShape`] to the output shapes, for the current frame. /// Will be overwritten on the next call to [`Self::run`]. pub fn mask(&mut self, rect: Rect) { self.output.shapes.push(ClippedShape { clip_rect: Rect::EVERYTHING, shape: Shape::Rect(RectShape::filled(rect, 0.0, Color32::MAGENTA)), }); } /// Render the last output to an image. /// /// # Errors /// Returns an error if the rendering fails. #[cfg(any(feature = "wgpu", feature = "snapshot"))] pub fn render(&mut self) -> Result { 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(), }); } self.renderer.render(&self.ctx, &output) } /// Apply the [`egui::ViewportCommand`]s the app emitted during the last frame. fn handle_viewport_commands(&mut self) { self.handle_inner_size(); #[cfg(any(feature = "wgpu", feature = "snapshot"))] self.handle_screenshots(); } /// Resize the harness to the last [`egui::ViewportCommand::InnerSize`] requested by the app /// during the last frame, if any. fn handle_inner_size(&mut self) { let new_inner_size = self.root_viewport_output() .commands .iter() .rev() .find_map(|command| { if let egui::ViewportCommand::InnerSize(size) = command { Some(*size) } else { None } }); if let Some(size) = new_inner_size { self.set_size(size); self.ctx.request_repaint(); } } /// Fulfill any [`egui::ViewportCommand::Screenshot`] requests made by the app during the /// last frame. /// /// If a screenshot was requested and no renderer is available, an error will be logged. #[cfg(any(feature = "wgpu", feature = "snapshot"))] fn handle_screenshots(&mut self) { // Collect all screenshot requests from this frame's viewport output. let requests: Vec<(ViewportId, egui::UserData)> = self .output .viewport_output .iter() .flat_map(|(id, viewport)| { viewport.commands.iter().filter_map(move |command| { if let egui::ViewportCommand::Screenshot(user_data) = command { Some((*id, user_data.clone())) } else { None } }) }) .collect(); if requests.is_empty() { return; } // Render the frame once and reuse it for every request. We render without the synthetic // mouse cursor since a real screenshot wouldn't include the OS cursor either. let image = match self.renderer.render(&self.ctx, &self.output) { Ok(image) => image, Err(err) => { log::error!("Failed to render screenshot requested via ViewportCommand: {err}"); return; } }; let image = std::sync::Arc::new(rgba_image_to_color_image(&image)); for (viewport_id, user_data) in requests { self.input.events.push(egui::Event::Screenshot { viewport_id, user_data, image: std::sync::Arc::clone(&image), }); } // Make sure the run loop runs at least one more frame so the app actually receives the // queued screenshot event. self.ctx.request_repaint(); } /// Get the root viewport output fn root_viewport_output(&self) -> &egui::ViewportOutput { self.output .viewport_output .get(&ViewportId::ROOT) .expect("Missing root viewport") } /// The root node of the test harness. pub fn root(&self) -> Node<'_> { Node::new( self.kittest.root(), &self.queued_events, self.ctx.pixels_per_point(), ) } /// Spawn a real native eframe window running this harness's app, reusing its [`egui::Context`]. /// /// Blocks until the window is closed. /// /// Useful for interactively debugging a failing test: add a call to this before the failing /// assertion to poke at the UI yourself. /// /// # macOS: must be called on the main thread /// `AppKit` requires UI work to happen on the main thread, but by default cargo's test harness /// runs each test on a spawned worker thread, so this function will panic on macOS unless /// you opt out of the default harness. /// /// To fix this, disable the default libtest harness for your test target and run tests on /// the main thread yourself. In `Cargo.toml`: /// /// ```toml /// [[test]] /// name = "your_test" /// harness = false /// ``` /// /// Then write a `fn main()` in the test file that invokes your test directly. /// /// See also: #[cfg(all(feature = "eframe", not(target_arch = "wasm32")))] #[deprecated = "Only for debugging, don't commit this."] pub fn spawn_eframe_app(self) where 'a: 'static, State: 'static, { #[cfg(target_os = "macos")] { // AppKit requires UI work to happen on the main thread, but by default cargo's // test harness runs each test on a spawned worker thread. #[expect(unsafe_code)] // SAFETY: `pthread_main_np` is a thread-safe libc query with no arguments. let is_main_thread = unsafe { unsafe extern "C" { fn pthread_main_np() -> core::ffi::c_int; } pthread_main_np() != 0 }; assert!( is_main_thread, "spawn_eframe_app must be called on the main thread on macOS, \ but the default `cargo test` harness runs each test on a worker thread.\n\ \n\ To fix this, disable the default libtest harness for your test target and run \ tests on the main thread yourself. In Cargo.toml:\n\ \n\ [[test]]\n\ name = \"your_test\"\n\ harness = false\n\ \n\ Then write a `fn main()` in the test file that invokes your test directly.\n\ \n\ See: https://doc.rust-lang.org/cargo/reference/cargo-targets.html#the-harness-field" ); } struct UiApp { f: Box, } impl eframe::App for UiApp { fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { (self.f)(ui); } } 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), }; eframe::run_native_ext( "egui_kittest", eframe::NativeOptions::default(), Some(ctx), Box::new(|_cc| Ok(eframe_app)), ) .unwrap(); } } /// Utilities for stateless harnesses. impl<'a> Harness<'a> { /// Create a new Harness with the given ui closure. /// Use the [`Harness::run`], [`Harness::step`], etc... methods to run the app. /// /// The ui closure will immediately be called once to create the initial ui. /// /// If you e.g. want to customize the size of the ui, you can use [`Harness::builder`]. /// /// # Example /// ```rust /// # use egui_kittest::Harness; /// let mut harness = Harness::new_ui(|ui| { /// ui.label("Hello, world!"); /// }); /// ``` #[track_caller] pub fn new_ui(app: impl FnMut(&mut egui::Ui) + 'a) -> Self { Self::builder().build_ui(app) } } /// 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"))] fn rgba_image_to_color_image(image: &image::RgbaImage) -> egui::ColorImage { let size = [image.width() as usize, image.height() as usize]; let pixels = image .pixels() .map(|p| Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3])) .collect(); egui::ColorImage::new(size, pixels) } impl<'tree, 'node, State> Queryable<'tree, 'node, Node<'tree>> for Harness<'_, State> where 'node: 'tree, { fn queryable_node(&'node self) -> Node<'tree> { self.root() } }