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

Add plugin-based kittest inspector

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.
This commit is contained in:
lucasmerlin
2026-05-21 12:03:47 +02:00
parent 6e679fdefc
commit e3414bec9d
6 changed files with 571 additions and 4 deletions

View File

@@ -1422,6 +1422,7 @@ dependencies = [
"egui",
"egui-wgpu",
"egui_extras",
"egui_inspection",
"image",
"kittest",
"open",

View File

@@ -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 }

View File

@@ -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<String>) -> Result<Self, InspectorError> {
Ok(Self {
conn: Connection::launch(label)?,
mode: Mode::Paused,
continuous_screenshots: true,
one_shot_screenshot: false,
})
}
}
impl<S> Plugin<S> 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<u64, &crate::ExceededMaxStepsError>,
) {
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<S>(&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<S>(&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<S>(&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<S>(&mut self, harness: &mut Harness<'_, S>, events: Vec<egui::Event>) {
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<S>(&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<S>(&mut self, harness: &mut Harness<'_, S>, tree: Option<accesskit::TreeUpdate>) {
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<ChildStdin>,
command_rx: mpsc::Receiver<InspectorCommand>,
_reader_thread: thread::JoinHandle<()>,
_child: Child,
step: u64,
broken: bool,
}
impl Connection {
fn launch(label: Option<String>) -> Result<Self, InspectorError> {
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::<InspectorCommand>();
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<accesskit::TreeUpdate>,
source: Option<SourceView>,
) {
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<ChildStdout>, tx: &mpsc::Sender<InspectorCommand>) {
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<SourceView> {
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 `<workspace>/crates/foo/` the compiler-reported path is
/// `crates/foo/src/…` but CWD is `<workspace>/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<String> {
static CACHE: LazyLock<Mutex<HashMap<String, Option<String>>>> =
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<String> {
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<bool> = 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,
})
}

View File

@@ -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<image::RgbaImage, String> {
let mut output = self.output.clone();

View File

@@ -67,7 +67,7 @@ pub trait Plugin<State = ()>: 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

View File

@@ -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,