From e3414bec9de0ee18c7cc465dbdcf82cc33721ef8 Mon Sep 17 00:00:00 2001 From: lucasmerlin Date: Thu, 21 May 2026 12:03:47 +0200 Subject: [PATCH] Add plugin-based kittest inspector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New `InspectorPlugin` (gated behind `inspector` feature) launches a `kittest_inspector` child process and streams the harness's frames + accesskit tree updates to it over framed MessagePack on stdin/stdout. The inspector drives the harness by sending `InspectorCommand`s back; supported commands include `Step` / `Run` / `Play` / `Pause` (deterministic stepping), `Handle { events }` (event injection), `Resize`, and `Screenshot`. Auto-attaches when the `KITTEST_INSPECTOR` env var is truthy — the inspector binary path can be overridden via `KITTEST_INSPECTOR_PATH`. Uses the new `egui_inspection::protocol` types and starts every connection with a `HarnessMessage::Hello { peer_kind: Kittest, capabilities: KITTEST }` so the inspector can render the right controls. Also re-exports `egui_inspection` as `egui_kittest::inspector_api` for crates that only depend on kittest. --- Cargo.lock | 1 + crates/egui_kittest/Cargo.toml | 12 + crates/egui_kittest/src/inspector.rs | 524 +++++++++++++++++++++++++++ crates/egui_kittest/src/lib.rs | 32 +- crates/egui_kittest/src/plugin.rs | 2 +- crates/egui_kittest/src/renderer.rs | 4 +- 6 files changed, 571 insertions(+), 4 deletions(-) create mode 100644 crates/egui_kittest/src/inspector.rs diff --git a/Cargo.lock b/Cargo.lock index 82c015d44..1740978b4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1422,6 +1422,7 @@ dependencies = [ "egui", "egui-wgpu", "egui_extras", + "egui_inspection", "image", "kittest", "open", diff --git a/crates/egui_kittest/Cargo.toml b/crates/egui_kittest/Cargo.toml index 2d26e6bd8..1a322273a 100644 --- a/crates/egui_kittest/Cargo.toml +++ b/crates/egui_kittest/Cargo.toml @@ -26,6 +26,15 @@ wgpu = ["dep:egui-wgpu", "dep:pollster", "dep:image", "dep:wgpu", "eframe?/wgpu" ## Adds a dify-based image snapshot utility. snapshot = ["dep:dify", "dep:image", "dep:open", "dep:tempfile", "image/png"] +## Expose the [`inspector_api`] wire protocol used to talk to the external +## `kittest_inspector` binary. Pull this in if you're building a tool that consumes the +## same stream — the binary itself enables this transitively. +inspector_api = ["dep:egui_inspection", "egui_inspection/protocol", "egui/serde"] + +## Stream frames + accesskit tree to a `kittest_inspector` window for live debugging. +## Auto-launches when the `KITTEST_INSPECTOR` env var is truthy. +inspector = ["inspector_api", "dep:image"] + ## Allows testing eframe::App eframe = ["dep:eframe", "eframe/accesskit"] @@ -50,6 +59,9 @@ wgpu = { workspace = true, features = ["metal", "dx12", "vulkan", "gles"], optio # snapshot dependencies dify = { workspace = true, optional = true } +# inspector dependencies +egui_inspection = { workspace = true, optional = true } + # Enable this when generating docs. document-features = { workspace = true, optional = true } diff --git a/crates/egui_kittest/src/inspector.rs b/crates/egui_kittest/src/inspector.rs new file mode 100644 index 000000000..3f27141f1 --- /dev/null +++ b/crates/egui_kittest/src/inspector.rs @@ -0,0 +1,524 @@ +//! [`InspectorPlugin`] — connect a [`crate::Harness`] to a `kittest_inspector` process for +//! live debugging. +//! +//! The plugin spawns the inspector as a child process and communicates over stdin/stdout +//! using the [`crate::inspector_api`] wire protocol. A background reader thread receives +//! [`InspectorCommand`]s from the inspector and pushes them into an mpsc channel, so the +//! plugin can check for commands non-blockingly during `Play` mode and block for them in +//! `Paused` mode. +//! +//! Auto-registered on harness creation when the [`INSPECTOR_ENV_VAR`] env var is truthy. + +use std::collections::HashMap; +use std::io::{BufReader, BufWriter}; +use std::panic::Location; +use std::path::PathBuf; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::mpsc; +use std::sync::{LazyLock, OnceLock}; +use std::thread; + +use egui::accesskit; +use egui::mutex::Mutex; + +use egui_inspection::protocol::{ + Capabilities, Frame, FrameScreenshot, HarnessMessage, InspectorCommand, PROTOCOL_VERSION, + PeerHello, PeerKind, SourceView, read_message, write_message, +}; +use crate::{Harness, Plugin, TestResult}; + +/// Environment variable: when set to a truthy value, every harness auto-launches an inspector. +pub const INSPECTOR_ENV_VAR: &str = "KITTEST_INSPECTOR"; + +/// Environment variable: explicit path to the `kittest_inspector` binary. +pub const INSPECTOR_PATH_ENV_VAR: &str = "KITTEST_INSPECTOR_PATH"; + +/// Errors that can occur attaching or talking to the inspector. +#[derive(Debug)] +pub enum InspectorError { + /// Failed to launch the `kittest_inspector` binary. + Launch(std::io::Error), + /// Failed to set up the child's stdio pipes. + Pipe(String), +} + +impl std::fmt::Display for InspectorError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Launch(err) => write!( + f, + "failed to launch kittest_inspector (set {INSPECTOR_PATH_ENV_VAR} or put it on PATH): {err}" + ), + Self::Pipe(msg) => write!(f, "inspector pipe setup failed: {msg}"), + } + } +} + +impl std::error::Error for InspectorError {} + +/// Harness execution state as driven by the inspector. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + /// Block at `after_step` / `after_run` waiting for a command. + Paused, + /// Run until the next `after_step` fires, then pause. + StepOnce, + /// Run until the next `after_run` fires, then pause. + RunOnce, + /// Run freely; drain commands non-blockingly at each hook. Transitions to `Paused` on + /// `Pause`, to `StepOnce` on `Step`, to `RunOnce` on `Run`. + Playing, +} + +/// Plugin that streams frames to an external `kittest_inspector` binary. +/// +/// Typical use is to let [`Harness::from_builder`] auto-register this plugin based on the +/// [`INSPECTOR_ENV_VAR`] environment variable. For manual wiring, construct one with +/// [`Self::launch`] and pass to [`crate::HarnessBuilder::with_plugin`]. +pub struct InspectorPlugin { + conn: Connection, + mode: Mode, + /// When `true`, every emitted frame includes a freshly-rendered [`FrameScreenshot`]. + /// When `false`, frames are accesskit-only unless a one-shot [`InspectorCommand::Screenshot`] + /// has fired since the last emission. Toggled by + /// [`InspectorCommand::SetContinuousScreenshots`]. Defaults to `true` to match the + /// pre-flag always-screenshot behavior. + continuous_screenshots: bool, + /// Set by a one-shot [`InspectorCommand::Screenshot`]; consumed by the next + /// `send_frame` so the agent gets a rendered image even when continuous mode is off. + one_shot_screenshot: bool, +} + +impl InspectorPlugin { + /// Launch a `kittest_inspector` child process and attach this plugin to it. + /// + /// # Errors + /// If the inspector binary cannot be launched or its stdio pipes fail to set up. + pub fn launch(label: Option) -> Result { + Ok(Self { + conn: Connection::launch(label)?, + mode: Mode::Paused, + continuous_screenshots: true, + one_shot_screenshot: false, + }) + } +} + +impl Plugin for InspectorPlugin { + fn after_step( + &mut self, + harness: &mut Harness<'_, S>, + accesskit_update: &accesskit::TreeUpdate, + ) { + self.handle_after_step(harness, accesskit_update); + } + + /// When in `RunOnce`, `after_run` is the blocking point the user asked for. Nothing has + /// re-rendered since the last `after_step`, so we only signal the state change via a + /// `Blocked(true)` event (no duplicate frame) and then block. + fn after_run( + &mut self, + harness: &mut Harness<'_, S>, + _result: Result, + ) { + if self.mode == Mode::RunOnce { + self.mode = Mode::Paused; + self.conn.send_blocked(true); + self.block_until_resume(harness); + } + } + + /// Test ended — send `Finished` (carrying the panic location in its `SourceView` when + /// the panic's file matches the test entry), then block until the user dismisses with a + /// Step / Run / Play command. The dismiss unblocks us; the harness finishes dropping on + /// the way out. + fn on_test_result(&mut self, harness: &mut Harness<'_, S>, result: TestResult<'_>) { + if self.conn.broken { + return; + } + + let (ok, message, panic_loc) = match result { + TestResult::Pass => (true, None, None), + TestResult::Fail { message, location } => ( + false, + message.map(str::to_owned), + location.map(|loc| (loc.file.clone(), loc.line)), + ), + }; + + let source = build_source_view( + harness.entry_location(), + harness.consumed_event_locations(), + panic_loc.as_ref(), + ); + self.conn.write(&HarnessMessage::Finished { + ok, + message, + source, + }); + // Park here until the user dismisses with Step/Run/Play. `block_until_resume` exits + // on any of those (they all transition out of `Paused`); `Pause` is a no-op; `Handle` + // still works so the user can poke at the final UI on failure. The mode mutation it + // leaves behind is harmless — the plugin is about to drop. + self.mode = Mode::Paused; + self.block_until_resume(harness); + } +} + +impl InspectorPlugin { + /// Send a frame for this step and apply the current mode's blocking / draining policy. + /// `after_run` is handled separately — it only transitions `RunOnce → Paused`. + fn handle_after_step(&mut self, harness: &mut Harness<'_, S>, tree: &accesskit::TreeUpdate) { + if self.conn.broken { + return; + } + + // Blocking points at after_step are: Paused (always) and StepOnce (one-shot). + // RunOnce keeps running past every after_step until after_run completes; Playing + // runs freely. + let will_block_here = matches!(self.mode, Mode::Paused | Mode::StepOnce); + + self.send_frame(harness, Some(tree.clone())); + self.conn.send_blocked(will_block_here); + + if self.mode == Mode::StepOnce { + self.mode = Mode::Paused; + } + + match self.mode { + Mode::Paused => self.block_until_resume(harness), + Mode::StepOnce | Mode::RunOnce => { + // Non-blocking: keep running. + } + Mode::Playing => { + self.drain_playing(harness); + if self.mode == Mode::Paused { + // A `Pause` came in while playing — block now. + self.conn.send_blocked(true); + self.block_until_resume(harness); + } + } + } + } + + /// Block on the command channel until a command transitions us out of [`Mode::Paused`]. + /// `Handle` commands execute a `step_no_side_effects` and send a fresh frame, then we + /// keep blocking. + fn block_until_resume(&mut self, harness: &mut Harness<'_, S>) { + while self.mode == Mode::Paused && !self.conn.broken { + match self.conn.command_rx.recv() { + Ok(InspectorCommand::Step) => self.mode = Mode::StepOnce, + Ok(InspectorCommand::Run) => self.mode = Mode::RunOnce, + Ok(InspectorCommand::Play) => self.mode = Mode::Playing, + Ok(InspectorCommand::Pause) => { /* already paused */ } + Ok(InspectorCommand::Handle { events }) => { + self.apply_handle(harness, events); + } + Ok(InspectorCommand::Screenshot) => { + self.one_shot_screenshot = true; + } + Ok(InspectorCommand::SetContinuousScreenshots(on)) => { + self.continuous_screenshots = on; + } + Ok(InspectorCommand::Resize { width, height }) => { + self.apply_resize(harness, width, height); + } + Err(_) => { + // Reader thread is gone — no more commands will arrive. Stop blocking + // so the test can continue to drop cleanly. + self.conn.broken = true; + return; + } + } + } + } + + /// Drain any commands that are already queued without blocking. Called at every hook + /// while in [`Mode::Playing`]. + fn drain_playing(&mut self, harness: &mut Harness<'_, S>) { + loop { + match self.conn.command_rx.try_recv() { + Ok(InspectorCommand::Pause) => self.mode = Mode::Paused, + Ok(InspectorCommand::Step) => self.mode = Mode::StepOnce, + Ok(InspectorCommand::Run) => self.mode = Mode::RunOnce, + Ok(InspectorCommand::Play) => { /* already playing */ } + Ok(InspectorCommand::Handle { events }) => { + self.apply_handle(harness, events); + } + Ok(InspectorCommand::Screenshot) => { + self.one_shot_screenshot = true; + } + Ok(InspectorCommand::SetContinuousScreenshots(on)) => { + self.continuous_screenshots = on; + } + Ok(InspectorCommand::Resize { width, height }) => { + self.apply_resize(harness, width, height); + } + Err(mpsc::TryRecvError::Empty) => return, + Err(mpsc::TryRecvError::Disconnected) => { + self.conn.broken = true; + return; + } + } + } + } + + /// Queue inspector-driven events and advance one frame without firing plugin hooks, then + /// send a fresh frame so the inspector sees the effect. `Handle` never changes the + /// harness's Paused/Play/Run mode, so we don't emit a `Blocked` event here. + fn apply_handle(&mut self, harness: &mut Harness<'_, S>, events: Vec) { + for event in events { + harness.input_mut().events.push(event); + } + // `step_no_side_effects` returns the tree directly — we can't receive it via + // `after_step` because nested plugin dispatches are suppressed. + let tree = harness.step_no_side_effects(); + self.send_frame(harness, Some(tree)); + } + + /// Apply a resize request, then advance one frame so the inspector sees the new layout. + fn apply_resize(&mut self, harness: &mut Harness<'_, S>, width: u32, height: u32) { + harness.set_size(egui::Vec2::new(width as f32, height as f32)); + let tree = harness.step_no_side_effects(); + self.send_frame(harness, Some(tree)); + } + + /// Render the current harness state and push it to the inspector. + fn send_frame(&mut self, harness: &mut Harness<'_, S>, tree: Option) { + if self.conn.broken { + return; + } + let want_screenshot = self.continuous_screenshots || self.one_shot_screenshot; + self.one_shot_screenshot = false; + + let image = if want_screenshot { + match harness.render() { + Ok(img) => Some(img), + Err(err) => { + #[expect(clippy::print_stderr)] + { + eprintln!("egui_kittest inspector: render failed: {err}"); + } + self.conn.broken = true; + return; + } + } + } else { + None + }; + let ppp = harness.ctx.pixels_per_point(); + let source = build_source_view( + harness.entry_location(), + harness.consumed_event_locations(), + None, + ); + self.conn.send_frame(image.as_ref(), ppp, tree, source); + } +} + +/// The inspector's child-process connection + step counter. Private — [`InspectorPlugin`] is +/// the public wrapper. +struct Connection { + writer: BufWriter, + command_rx: mpsc::Receiver, + _reader_thread: thread::JoinHandle<()>, + _child: Child, + step: u64, + broken: bool, +} + +impl Connection { + fn launch(label: Option) -> Result { + let bin = std::env::var(INSPECTOR_PATH_ENV_VAR) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from("kittest_inspector")); + + // Important: do NOT inherit stderr. The cargo-test / nextest stderr capture pipe can + // close between tests while the inspector is still alive; a later `eprintln!` in the + // inspector would then panic ("failed printing to stderr: Broken pipe") and take the + // window down. The inspector keeps its own log file for diagnostics. + let mut child = Command::new(&bin) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(InspectorError::Launch)?; + + let stdin = child + .stdin + .take() + .ok_or_else(|| InspectorError::Pipe("missing child stdin".into()))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| InspectorError::Pipe("missing child stdout".into()))?; + + let (command_tx, command_rx) = mpsc::channel::(); + let reader_thread = thread::Builder::new() + .name("kittest_inspector_reader".into()) + .spawn(move || run_reader(BufReader::new(stdout), &command_tx)) + .map_err(|err| InspectorError::Pipe(format!("spawn reader thread: {err}")))?; + + let mut writer = BufWriter::new(stdin); + + // Hello must be the first message on the wire — the inspector reads it before any + // Frame to decide which controls to render. + let hello = HarnessMessage::Hello(PeerHello { + protocol_version: PROTOCOL_VERSION, + peer_kind: PeerKind::Kittest, + capabilities: Capabilities::KITTEST, + // Kittest defaults to continuous so legacy inspectors that ignore the flag still + // get a screenshot on every frame. + continuous_screenshots: true, + label, + }); + write_message(&mut writer, &hello) + .map_err(|err| InspectorError::Pipe(format!("send Hello: {err}")))?; + + Ok(Self { + writer, + command_rx, + _reader_thread: reader_thread, + _child: child, + step: 0, + broken: false, + }) + } + + fn send_frame( + &mut self, + image: Option<&image::RgbaImage>, + pixels_per_point: f32, + accesskit: Option, + source: Option, + ) { + if self.broken { + return; + } + self.step = self.step.saturating_add(1); + let frame = Frame { + step: self.step, + pixels_per_point, + screenshot: image.map(|img| FrameScreenshot { + width: img.width(), + height: img.height(), + rgba: img.as_raw().clone(), + }), + accesskit, + source, + }; + self.write(&HarnessMessage::Frame(Box::new(frame))); + } + + /// Tell the inspector the harness's blocking state changed. + fn send_blocked(&mut self, blocking: bool) { + self.write(&HarnessMessage::Blocked(blocking)); + } + + fn write(&mut self, msg: &HarnessMessage) { + if self.broken { + return; + } + if let Err(err) = write_message(&mut self.writer, msg) { + #[expect(clippy::print_stderr)] + { + eprintln!("egui_kittest inspector: send failed: {err}"); + } + self.broken = true; + } + } +} + +/// Reader-thread entry point: forward every decoded [`InspectorCommand`] into the mpsc +/// channel until EOF or the receiver is dropped. +fn run_reader(mut reader: BufReader, tx: &mpsc::Sender) { + loop { + match read_message::<_, InspectorCommand>(&mut reader) { + Ok(cmd) => { + if tx.send(cmd).is_err() { + return; + } + } + Err(_) => return, + } + } +} + +/// Build the [`SourceView`] payload for a frame: pick the `.run()`/`.step()` caller's file +/// as the anchor, and record each event's line within that same file. `panic_loc` is set +/// only on the final frame after a failed test — and only included in the output if the +/// panic's file matches the anchor (otherwise there's no highlight to attach). +/// +/// `#[track_caller]` chains through the entire event-queuing API, so each `Location` points +/// directly at the user's test source — no backtrace walking needed. +fn build_source_view( + call_site: Option<&'static Location<'static>>, + event_sites: &[&'static Location<'static>], + panic_loc: Option<&(String, u32)>, +) -> Option { + let call = call_site?; + let path = call.file().to_owned(); + let event_lines = event_sites + .iter() + .filter(|loc| loc.file() == path) + .map(|loc| loc.line()) + .collect(); + let panic_line = panic_loc + .filter(|(file, _)| file == &path) + .map(|(_, line)| *line); + Some(SourceView { + path: path.clone(), + contents: read_source_file(&path), + call_site_line: Some(call.line()), + event_lines, + panic_line, + }) +} + +/// Read the full contents of a source file, cached per path (including negative results). +/// +/// `path` comes from `std::panic::Location::file()`, which the compiler reports relative to +/// the *workspace* root. Cargo runs tests with CWD set to the *crate* root, so for a +/// workspace crate at `/crates/foo/` the compiler-reported path is +/// `crates/foo/src/…` but CWD is `/crates/foo/`. We try as-is first (handles +/// absolute paths and single-crate layouts), then walk up from CWD looking for an ancestor +/// where `ancestor.join(path)` resolves. +fn read_source_file(path: &str) -> Option { + static CACHE: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + let mut cache = CACHE.lock(); + cache + .entry(path.to_owned()) + .or_insert_with(|| resolve_and_read(path)) + .clone() +} + +fn resolve_and_read(path: &str) -> Option { + if let Ok(contents) = std::fs::read_to_string(path) { + return Some(contents); + } + if std::path::Path::new(path).is_absolute() { + return None; + } + let mut cursor = std::env::current_dir().ok()?; + // `pop()` returns false once we've hit the root, which terminates the search. + while cursor.pop() { + if let Ok(contents) = std::fs::read_to_string(cursor.join(path)) { + return Some(contents); + } + } + None +} + +/// Read [`INSPECTOR_ENV_VAR`] once and cache. Exposed to [`crate::Harness::from_builder`] +/// so it can auto-register an [`InspectorPlugin`]. +pub(crate) fn env_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| match std::env::var(INSPECTOR_ENV_VAR) { + Ok(value) => matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ), + Err(_) => false, + }) +} diff --git a/crates/egui_kittest/src/lib.rs b/crates/egui_kittest/src/lib.rs index b0e0afada..a7fb10fd1 100644 --- a/crates/egui_kittest/src/lib.rs +++ b/crates/egui_kittest/src/lib.rs @@ -13,6 +13,13 @@ pub use crate::snapshot::*; mod app_kind; mod config; +#[cfg(feature = "inspector")] +mod inspector; +/// Re-export of [`egui_inspection`] — the wire protocol used to talk to the external +/// `kittest_inspector` UI. Lives in its own crate so non-test consumers (e.g. a live +/// `eframe` app) can pull the protocol in without the test harness. +#[cfg(feature = "inspector_api")] +pub use egui_inspection as inspector_api; mod node; mod plugin; mod renderer; @@ -23,6 +30,11 @@ pub mod wgpu; pub use crate::plugin::{PanicLocation, Plugin, TestResult, install_panic_hook}; +#[cfg(feature = "inspector")] +pub use crate::inspector::{ + INSPECTOR_ENV_VAR, INSPECTOR_PATH_ENV_VAR, InspectorError, InspectorPlugin, +}; + // re-exports: pub use { self::{builder::*, node::*, renderer::*}, @@ -187,6 +199,24 @@ impl<'a, State: 'static> Harness<'a, State> { #[cfg(feature = "snapshot")] snapshot_results: SnapshotResults::default(), }; + + // Auto-register the Inspector plugin when the env var is set. Done before `run_ok` + // so the inspector sees the initial stabilization frames. + #[cfg(feature = "inspector")] + if inspector::env_enabled() { + match inspector::InspectorPlugin::launch( + std::thread::current().name().map(String::from), + ) { + Ok(plugin) => harness.add_plugin(plugin), + Err(err) => { + #[expect(clippy::print_stderr)] + { + eprintln!("egui_kittest: failed to launch inspector: {err}"); + } + } + } + } + // Run the harness until it is stable, ensuring that all Areas are shown and animations are done harness.run_ok(); harness @@ -749,7 +779,7 @@ impl<'a, State: 'static> Harness<'a, State> { /// /// # Errors /// Returns an error if the rendering fails. - #[cfg(any(feature = "wgpu", feature = "snapshot"))] + #[cfg(any(feature = "wgpu", feature = "snapshot", feature = "inspector"))] pub fn render(&mut self) -> Result { let mut output = self.output.clone(); diff --git a/crates/egui_kittest/src/plugin.rs b/crates/egui_kittest/src/plugin.rs index f78655791..6ddea28ba 100644 --- a/crates/egui_kittest/src/plugin.rs +++ b/crates/egui_kittest/src/plugin.rs @@ -67,7 +67,7 @@ pub trait Plugin: Send + 'static { /// 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"))] + #[cfg(any(feature = "wgpu", feature = "snapshot", feature = "inspector"))] fn on_render(&mut self, harness: &mut Harness<'_, State>, image: &image::RgbaImage) {} /// Called from [`Harness::try_snapshot`] / [`Harness::try_snapshot_options`] after diff --git a/crates/egui_kittest/src/renderer.rs b/crates/egui_kittest/src/renderer.rs index 0806c4ead..4c13fb9dc 100644 --- a/crates/egui_kittest/src/renderer.rs +++ b/crates/egui_kittest/src/renderer.rs @@ -12,7 +12,7 @@ pub trait TestRenderer { /// /// # Errors /// Returns an error if the rendering fails. - #[cfg(any(feature = "wgpu", feature = "snapshot"))] + #[cfg(any(feature = "wgpu", feature = "snapshot", feature = "inspector"))] fn render( &mut self, ctx: &egui::Context, @@ -62,7 +62,7 @@ impl TestRenderer for LazyRenderer { } } - #[cfg(any(feature = "wgpu", feature = "snapshot"))] + #[cfg(any(feature = "wgpu", feature = "snapshot", feature = "inspector"))] fn render( &mut self, ctx: &egui::Context,