crates/nornir-robotui/src/lib.rs
//! # nornir-robotui
//!
//! A **reusable robot-UI testing framework** for egui apps. It does three things,
//! each with a confirmed nordisk seam:
//!
//! 1. **Drive** real egui interactions with [`egui_kittest`] (AccessKit-based):
//! click a label or button, type into a field, press a key, scroll/drag, and
//! step/run frames. Optionally render a wgpu PNG snapshot (behind the
//! `snapshot` feature so the core stays light).
//! 2. **Observe / assert** via the nordisk pattern — every component exposes a
//! readable [`state_json`](Observable::state_json) (the same blob nornir's viz
//! dumps to `$NORNIR_VIZ_STATE` and facett's `Facet`s expose). The framework
//! reads that state back to assert what the user *would see* — not pixels.
//! 3. **Emit** results as [`nornir_testmatrix::TestResultRow`]s — each
//! `(action → expected outcome)` step becomes a matrix cell (pass/fail), so a
//! robot run literally **fills the test matrix**.
//!
//! ## Self-emit exemption (functional-status roster)
//!
//! This crate is **EXEMPT** from carrying a `selftest::emit` functional-status
//! bridge (the `nornir-git` / `-jobs` / `-build-thing` pattern). It is the
//! test-harness lib itself: emitting `TestResultRow`s into the matrix IS its
//! entire product surface (point 3 above), so it emits by construction, not
//! through a consumer bridge. A separate bridge would be a self-referential
//! no-op. See `.nornir/self-emitters.md` (the per-crate roster) for the
//! allow-list entry recording this exemption.
//!
//! ## The shape
//!
//! ```no_run
//! use nornir_robotui::{RobotSession, Observable, Scenario, Step, Action, Assert};
//! use egui_kittest::Harness;
//! use serde_json::{json, Value};
//!
//! // 1. Your app implements `Observable` (one method — return what you render).
//! struct MyApp { clicks: u32 }
//! impl Observable for MyApp {
//! fn state_json(&self) -> Value { json!({ "clicks": self.clicks }) }
//! }
//!
//! // 2. Wrap a kittest Harness over your app/ui in a RobotSession.
//! let harness = Harness::new_ui_state(
//! |ui, app: &mut MyApp| { if ui.button("Bump").clicked() { app.clicks += 1; } },
//! MyApp { clicks: 0 },
//! );
//! let mut robot = RobotSession::new(harness, "my-app");
//!
//! // 3. Script actions + assertions; run the scenario → matrix rows.
//! let scenario = Scenario::new("smoke").step(
//! Step::new("bump button increments")
//! .action(Action::ClickButton("Bump".into()))
//! .assert(Assert::State { pointer: "/clicks".into(), expected: json!(1) }),
//! );
//! let rows = robot.run_scenario(&scenario); // Vec<TestResultRow>, suite = "robot-ui"
//! ```
//!
//! A single `run_scenario` call drives every button and **fills the matrix**.
//!
//! ## Two modes over the SAME scenario (headless matrix + visible watch)
//!
//! A [`Scenario`] is plain data, so the SAME steps run two ways — pick the
//! backend with [`RunMode`]:
//!
//! * [`RunMode::Headless`] — the drive above: the off-screen `egui_kittest`
//! harness emits [`TestResultRow`]s (fills the matrix, for CI). This is the
//! default and the lean build.
//! * [`RunMode::Watch`] (feature `watch`) — a **real, visible eframe window** that
//! replays the same scenario step-by-step with a pacing delay, moving a cursor
//! and injecting synthetic input so you WATCH the robot click / type / scroll /
//! drag through it in real time.
//!
//! One consumer seam gives both: write ONE `draw(ui, app, targets)` closure + ONE
//! [`Scenario`], then call [`run_scenario_mode`] with the mode (an API param,
//! `ROBOTUI_WATCH=1` via [`RunMode::from_env`], or the `robotui` CLI subcommand):
//!
//! ```no_run
//! use nornir_robotui::{run_scenario_mode, RunMode, WatchConfig, Targets};
//! # use nornir_robotui::{Observable, Scenario};
//! # use serde_json::{json, Value};
//! # struct MyApp { clicks: u32 }
//! # impl Observable for MyApp { fn state_json(&self) -> Value { json!({"clicks": self.clicks}) } }
//! # let scenario = Scenario::new("smoke");
//! // ONE closure. BOTH modes resolve labels via the AccessKit tree, so `Targets`
//! // is OPTIONAL — record a rect only for a bespoke non-AccessKit surface (it is
//! // the watch fallback); a plain labelled widget needs no `t.record` at all.
//! let draw = |ui: &mut egui::Ui, app: &mut MyApp, _t: &mut Targets| {
//! if ui.button("Bump").clicked() { app.clicks += 1; }
//! };
//! let mode = RunMode::from_env(); // ROBOTUI_WATCH=1 → Watch
//! run_scenario_mode(MyApp { clicks: 0 }, draw, &scenario, "my-repo", mode, WatchConfig::default())?;
//! # Ok::<(), anyhow::Error>(())
//! ```
//!
//! CLI (feature `watch`): `robotui run <scenario.toml>` (headless matrix) and
//! `robotui watch <scenario.toml>` (the paced, visible window). The existing
//! [`RobotSession::run_scenario`] API is unchanged — Watch is purely additive.
use egui_kittest::Harness;
use egui_kittest::kittest::{NodeT, Queryable};
use serde_json::Value;
/// The substrings that mark a rendered widget as an **error atom** — a node whose
/// text says the UI surfaced a failure (a gRPC NotFound, a load failure, an
/// "unavailable" stub). The atom-walk ([`error_atoms`]) scans the live AccessKit
/// tree for any node whose label/value contains one of these. EXTEND this list as
/// new error shapes appear; one list, one walk, every surface.
pub const ERROR_MARKERS: &[&str] = &[
"is not served",
"Failed to load",
"unavailable",
"RPC: status:",
// SPECIFIC pane error renders (bug #22). NOT a blanket "failed" — that would
// over-match a "✗ failed" verdict glyph or a `failed: <n>` count column. These
// are the exact red labels the viz panes emit on a load/scan/read failure.
"scan failed",
"open warehouse failed",
"read failed",
"release_events read failed",
"failed to read registry",
];
/// The text an AccessKit node carries: its **label** (where egui puts plain
/// `ui.label(..)` text) OR, for a `Role::Label` widget, its **value** (where
/// AccessKit stores a label-role node's text). Either may hold an error atom, so
/// the walk reads both and joins them.
fn node_text(node: &egui_kittest::kittest::AccessKitNode<'_>) -> String {
let mut t = node.label().unwrap_or_default();
if let Some(v) = node.value() {
if !v.is_empty() {
if !t.is_empty() {
t.push(' ');
}
t.push_str(&v);
}
}
t
}
/// Walk the **live AccessKit tree** of a kittest [`Harness`] and return the text of
/// every node that looks like an error atom — any node whose label/value contains
/// an [`ERROR_MARKERS`] substring. An **empty** result means the rendered surface
/// is clean (no error leaked into any atom); a non-empty result names exactly which
/// atoms went red, so a test can assert `error_atoms(&h).is_empty()` and, on
/// failure, print the offending text.
///
/// This is the **L0 atom oracle** of the marker-matrix design (`§2b`): the only
/// layer that sees *sub-panel* atoms (e.g. the nornir tab's CloneEvents/Jobs panels)
/// which no per-button/per-tab enumeration covers. The "walk" is a single
/// `query_all_by` filter over the tree — kittest already recurses for us.
pub fn error_atoms<A>(harness: &Harness<'_, A>) -> Vec<String> {
// egui maps one `ui.label(text)` to *several* AccessKit nodes carrying the
// same text (a wrapping node + the galley), so the same error string can match
// more than once. The caller wants the DISTINCT error messages, so dedup while
// preserving first-seen order.
let mut seen = std::collections::BTreeSet::new();
harness
.query_all_by(|n: &egui_kittest::kittest::AccessKitNode<'_>| {
let t = node_text(n);
ERROR_MARKERS.iter().any(|m| t.contains(m))
})
.map(|node| node_text(&node.accesskit_node()))
.filter(|t| seen.insert(t.clone()))
.collect()
}
/// One **discovered atom** the [`all_atoms`] walk saw in the live AccessKit tree:
/// an interactive or labelled node with its visible text and its AccessKit role.
/// This is the per-tab *discovered surface* the atom-coverage producer turns into
/// `surface_coverage` rows and the drift cross-check compares against the
/// `button_registry` — every station the metro map would draw must correspond to
/// an atom the viz actually renders.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Atom {
/// The node's visible text (label, or label+value for a label-role node) —
/// the same text [`error_atoms`] reads, so error atoms and discovered atoms
/// share one text source.
pub text: String,
/// The AccessKit role tag (e.g. `Button`, `Label`, `CheckBox`, `TextInput`),
/// rendered via `Debug` so it's a stable string for rows + assertions.
pub role: String,
}
/// The AccessKit roles [`all_atoms`] treats as an **atom** — an interactive or
/// labelled node a user can see or drive. This is the broader predicate the task
/// asks for: the `error_atoms` walk filters on *text markers*; this one filters on
/// *role* so it enumerates the whole discovered surface of a tab (buttons the
/// metro map stations, labels the user reads), not just the red nodes. EXTEND as
/// new interactive roles matter.
fn is_atom_role(role: egui::accesskit::Role) -> bool {
use egui::accesskit::Role::*;
matches!(
role,
Button
| Label
| Link
| CheckBox
| RadioButton
| Switch
| Tab
| MenuItem
| TextInput
| MultilineTextInput
| ComboBox
| ListBoxOption
| Slider
| SpinButton
)
}
/// Walk the **live AccessKit tree** of a kittest [`Harness`] and return every
/// **discovered atom** — each interactive/labelled node (role in [`is_atom_role`])
/// that carries some visible text — deduped by `(text, role)`, first-seen order.
///
/// Where [`error_atoms`] answers *"did any node go red?"*, this answers *"what
/// interactive/labelled surface did this tab actually render?"* — the per-tab atom
/// set the atom-coverage producer ([`nornir`]'s `atom_coverage_rows`) maps to
/// `surface_coverage` rows and the `button_registry` drift cross-check compares
/// against. It reuses the same `query_all_by` + `node_text` path as `error_atoms`,
/// only with a role predicate instead of a marker predicate (one walk shape, two
/// filters). Nodes with no text (decorative/structural) are dropped — an atom must
/// be something the user can read or address.
pub fn all_atoms<A>(harness: &Harness<'_, A>) -> Vec<Atom> {
let mut seen = std::collections::BTreeSet::new();
harness
.query_all_by(|n: &egui_kittest::kittest::AccessKitNode<'_>| is_atom_role(n.role()))
.filter_map(|node| {
let ak = node.accesskit_node();
let text = node_text(&ak);
if text.is_empty() {
return None;
}
let role = format!("{:?}", ak.role());
if seen.insert((text.clone(), role.clone())) {
Some(Atom { text, role })
} else {
None
}
})
.collect()
}
/// Just the **labels** of the discovered atoms (`all_atoms(..).text`), deduped —
/// the convenience the drift cross-check uses to test set membership of a button
/// id against what a tab rendered.
pub fn atom_labels<A>(harness: &Harness<'_, A>) -> Vec<String> {
let mut seen = std::collections::BTreeSet::new();
all_atoms(harness)
.into_iter()
.filter_map(|a| seen.insert(a.text.clone()).then_some(a.text))
.collect()
}
pub use nornir_testmatrix::{self, JsonFileSink, NullSink, TestResultRow, TestSink, status};
// Re-export the driving primitives so consumers don't need to also depend on
// egui_kittest / egui directly for the common case.
pub use egui;
pub use egui_kittest;
mod scenario;
pub use scenario::{Action, Assert, FieldValue, Scenario, Step, Target};
// The `cmd_json` WIRE channel: a transport-agnostic command/assertion protocol so
// a driver can drive a REMOTE egui process (separate process / container) the SAME
// way the in-process `RobotSession` drives a co-located one. It REUSES the
// `Action`/`Assert`/`Target`/`Step` vocabulary above (one command home) — the wire
// requests just carry those enums over a byte-stream transport. Behind the `wire`
// feature so the lean/CI matrix build pulls no serde-on-egui unless a consumer
// actually needs the remote channel.
#[cfg(feature = "wire")]
pub mod cmd_json;
// Mode selection (feature-free): choose Headless (matrix) or Watch (visible
// window) for the SAME scenario, plus the `Targets` rect side-channel and
// `WatchConfig`. Only the window-opening backend below is behind `watch`.
mod mode;
pub use mode::{RunMode, Targets, WatchConfig, WatchRenderer};
// WHICH display backend a drive ran on (Wayland / Xvfb / X11) + a screenshot that
// picks its tool accordingly (`spectacle` on Wayland, `import` on X11) and says
// which it used. Feature-free: a headless CI drive should be able to state its
// environment too, and a measurement that cannot name its backend is not evidence.
pub mod display;
pub use display::{DisplayBackend, Shot};
// The trivial demo component + scenario that proves the duality (used by the
// `robotui` bin and the headless duality test). Feature-free.
pub mod demo;
// The live WATCH backend: replay a scenario in a real eframe window, visibly
// paced. Behind `watch` so the lean/CI build pulls no windowing stack.
#[cfg(feature = "watch")]
pub mod watch;
// The WebP-in-warehouse snapshot harness (render-oracle reference store).
// Behind `snapshot` (it needs `image`); the warehouse coupling is held behind
// the `SnapshotStore` trait so this crate stays warehouse-free.
#[cfg(feature = "snapshot")]
pub mod snapshot;
// The egui REPLAY DRIVER: a `nornir-saga` `ReplayPlan` (a recorded user journey)
// → a robot `Scenario` this session drives against the real egui app. Re-export
// the saga replay types so a consumer needs only this crate to replay sessions.
pub mod replay;
pub use nornir_saga;
pub use nornir_saga::replay::{ReplayPlan, ReplayStep, StepKind};
// The `xsession` module is GONE (2026-08-07). It was the rank-5 real-window leg on a
// private Xvfb display, and robot UI is Wayland-only now, so it had no world to run in.
// Rank 5 is still reachable: the `watch` backend opens a real window and captures that
// window's own presented framebuffer, which is a real surface on a real adapter and needs
// no compositor permission — see [`display`] for why that beats an external capture tool.
/// The **observe** seam: any egui app/component the robot drives implements this
/// so the framework can read back *what it renders* and assert on it — the
/// nordisk "components expose readable data" LAW. This is the same `state_json()`
/// nornir's viz and facett's `Facet`s already expose; adopting the robot is just
/// `impl Observable for YourApp { fn state_json(&self) -> Value { … } }`.
pub trait Observable {
/// A JSON blob describing the current rendered state — tab, selections,
/// counts, the rows a table shows, etc. The robot asserts against this with
/// JSON pointers (e.g. `/test/runs/0/run_id`).
fn state_json(&self) -> Value;
}
/// A scripted egui session: a [`Harness`] over an app that implements
/// [`Observable`], plus high-level actions and assertions. `A` is your app type.
///
/// Construct with [`RobotSession::new`] (you build the `Harness`, picking
/// `Harness::new_ui_state` for a single-widget UI or `Harness::builder().…
/// build_state(draw_fn, app)` for a full app), then drive it with the action
/// methods or run a whole [`Scenario`].
pub struct RobotSession<'a, A: Observable> {
harness: Harness<'a, A>,
/// The repo/app name stamped onto every emitted matrix row's `repo`.
repo: String,
}
impl<'a, A: Observable> RobotSession<'a, A> {
/// Wrap a built [`Harness`] over an [`Observable`] app. `repo` is stamped on
/// every matrix row this session emits (e.g. `"korp"`, `"nornir"`).
pub fn new(harness: Harness<'a, A>, repo: impl Into<String>) -> Self {
Self {
harness,
repo: repo.into(),
}
}
/// Borrow the underlying app (read-only) — e.g. to call its own helpers.
pub fn app(&self) -> &A {
self.harness.state()
}
/// Borrow the underlying app mutably — e.g. to inject fixture data before a
/// scenario so the panes have real rows to drive against.
pub fn app_mut(&mut self) -> &mut A {
self.harness.state_mut()
}
/// Borrow the underlying kittest [`Harness`] (for escape-hatch driving).
pub fn harness_mut(&mut self) -> &mut Harness<'a, A> {
&mut self.harness
}
/// The app's current [`Observable::state_json`] — what the robot asserts on.
pub fn state(&self) -> Value {
self.harness.state().state_json()
}
// ─── actions ────────────────────────────────────────────────────────────
/// Run one frame so a queued action takes effect and the layout settles.
/// Uses `run_ok` (not `run`) so an app that requests a repaint every frame
/// — like nornir's viz auto-refresh — doesn't trip kittest's max-steps guard.
pub fn step(&mut self) {
let _ = self.harness.run_ok();
}
/// Run several frames (handy after an action that triggers async-ish work the
/// next frame, e.g. a reload).
pub fn run(&mut self, frames: usize) {
for _ in 0..frames {
self.step();
}
}
/// Click the widget whose AccessKit **label** equals `label` (a button,
/// selectable label, checkbox, tab header — anything egui labels). Then step
/// a frame so the click lands.
///
/// # An action that cannot fail is not an action (`korpui-31`)
/// This used to be `query_by_label(label).map(Node::click)` — resolve, poke,
/// return `Ok`. `Node::click` queues a synthetic `PointerButton` at the node's
/// rect centre and returns `()`, so **every** click reported success, including
/// the ones egui delivered to a different widget or to nothing. That is the
/// hollow-ACTION twin of `Assert::Drew`: a step that is green by construction.
///
/// So the click now RESOLVES through [`label_visibility`](Self::label_visibility)
/// first: a widget that is not on the rendered surface is an `Err` naming its rect
/// and the surface it missed, not a green step over a counter that never moved.
///
/// # What this still does NOT prove, measured
/// It does not prove the press reached *that* widget when the widget IS on the
/// surface but something is painted over it. The obvious check — read the widget's
/// own `Response` back after the frame and require `clicked()` — does not work, and
/// this is measured rather than assumed: `Context::read_response` rebuilds the
/// response from the interaction state of the pass that runs AFTER the click, which
/// never carries a press, so it answers `clicked() == false` for clicks that
/// demonstrably landed. Wired up, it turned **3 of 34** of this crate's own tests
/// red, including `click_button_mutates_observable_state`, whose counter proves the
/// click worked. A real landing check needs an egui API that reports the pass's own
/// interaction; until then the honest bound is: the ACTION proves reachability, the
/// step's `Assert` proves effect.
pub fn click_label(&mut self, label: &str) -> anyhow::Result<()> {
self.click_resolved(label, "widget labelled")
}
/// Click a button by its text. In egui a button's AccessKit label *is* its
/// text, so this is [`click_label`](Self::click_label) with a button-flavoured
/// error message.
pub fn click_button(&mut self, text: &str) -> anyhow::Result<()> {
self.click_resolved(text, "button")
}
/// The one click body [`click_label`] and [`click_button`] share (LAW 5 — the
/// visibility gate and the landing check exist once, so the two spellings cannot
/// drift into two different notions of "the click worked").
///
/// [`click_label`]: Self::click_label
/// [`click_button`]: Self::click_button
fn click_resolved(&mut self, label: &str, what: &str) -> anyhow::Result<()> {
// RESOLVE + SEE. `label_visibility` is the same oracle `Assert::LabelVisible`
// uses, so "visible" and "clickable" cannot disagree — which is the whole
// korpui-31 shape: the drive was told the user could see and press a control
// that was nowhere a pointer could reach.
self.label_visibility(label)
.map_err(|e| anyhow::anyhow!("no {what} {label:?} to click: {e}"))?;
let node = self
.resolve_by_label(label)
.expect("resolved one line above");
node.click();
self.step();
Ok(())
}
/// Focus the field labelled `label` and type `text` into it, then step.
pub fn type_into(&mut self, label: &str, text: &str) -> anyhow::Result<()> {
// Focus first and let a frame land so egui marks the field active, then
// type and step again so the edit is applied — egui needs the focus to be
// in effect on the frame the text events arrive.
match self.harness.query_by_label(label) {
Some(node) => node.focus(),
None => return Err(anyhow::anyhow!("no field {label:?} to type into")),
}
self.step();
match self.harness.query_by_label(label) {
Some(node) => node.type_text(text),
None => return Err(anyhow::anyhow!("field {label:?} vanished after focus")),
}
self.step();
Ok(())
}
/// Press a key (e.g. [`egui::Key::Enter`], `Tab`, `ArrowDown`), then step.
pub fn key(&mut self, key: egui::Key) {
self.harness.key_press(key);
self.step();
}
/// Scroll by `delta` (positive y scrolls up, negative down — egui's
/// convention), then step. Emits a raw scroll event the focused/hovered
/// scroll area consumes.
pub fn scroll(&mut self, delta: egui::Vec2) {
self.harness.event(egui::Event::MouseWheel {
unit: egui::MouseWheelUnit::Point,
delta,
// egui 0.34 added a `phase` field; "if unknown, use Move".
phase: egui::TouchPhase::Move,
modifiers: egui::Modifiers::default(),
});
self.step();
}
/// Drag from `from` to `to` in screen points (press, move, release), then
/// step. Drives draggable widgets / pan gestures.
pub fn drag(&mut self, from: egui::Pos2, to: egui::Pos2) {
self.harness.drag_at(from);
self.step();
self.harness.drop_at(to);
self.step();
}
// ─── R6: richer live-drive (widget-level) ────────────────────────────────
/// **R6** — click a widget addressed by its AccessKit **node id** (`u64`,
/// i.e. `node.accesskit_node().id().0`) rather than its label. Use this when
/// labels collide (two "Submit" buttons) or are empty (an icon-only button,
/// a `TextEdit` with only `hint_text`). Returns `Err` if no node carries that
/// id this frame. The id is the one [`Self::node_id`] /
/// [`Self::find_id`] hand back, so a driver can discover ids then click them.
pub fn click_by_id(&mut self, id: u64) -> anyhow::Result<()> {
match self.query_by_id(id) {
Some(node) => {
node.click();
self.step();
Ok(())
}
None => Err(anyhow::anyhow!("no widget with AccessKit id {id} to click")),
}
}
/// **R6** — set an arbitrary form field (addressed by label OR node id) to a
/// value, choosing the gesture from the widget's AccessKit **role**:
///
/// * a [`FieldValue::Text`] clears the field (focus → select-all → backspace)
/// and types the new text — a single/multi-line `TextEdit`;
/// * a [`FieldValue::Bool`] reads the current toggle and **clicks only if it
/// differs**, so it sets (never just flips) a checkbox/switch;
/// * a [`FieldValue::Number`] sends the AccessKit `SetValue` action egui
/// honours for numeric widgets (slider / `DragValue`).
///
/// Returns `Err` if the target is absent (or, for `Number`, the widget does
/// not accept `SetValue`). Steps a frame so the edit lands. The caller should
/// then assert the change took — via `state_json` ([`Assert::State`]) or the
/// widget value ([`Assert::FieldValue`] / [`Assert::FieldChecked`]).
pub fn set_field(&mut self, target: &Target, value: &FieldValue) -> anyhow::Result<()> {
match value {
FieldValue::Text(text) => self.set_text_field(target, text),
FieldValue::Bool(want) => self.set_bool_field(target, *want),
FieldValue::Number(n) => self.set_number_field(target, *n),
}
}
fn set_text_field(&mut self, target: &Target, text: &str) -> anyhow::Result<()> {
// Resolve to a stable id first; the node handle is re-queried after each
// step (the AccessKit tree is rebuilt every frame).
let id = self
.find_id(target)
.ok_or_else(|| anyhow::anyhow!("no field {target:?} to set"))?;
// Focus + let the frame land so egui marks the field active.
self.node_by_id(id)
.ok_or_else(|| anyhow::anyhow!("field {target:?} vanished"))?
.focus();
self.step();
// Clear: select-all then Backspace removes whatever was there, so this is
// a *set* (not an append) regardless of prior contents.
self.harness
.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A);
self.harness.key_press(egui::Key::Backspace);
self.step();
// Type the new value into the (still-focused) field.
match self.node_by_id(id) {
Some(node) => node.type_text(text),
None => return Err(anyhow::anyhow!("field {target:?} vanished after clear")),
}
self.step();
Ok(())
}
fn set_bool_field(&mut self, target: &Target, want: bool) -> anyhow::Result<()> {
let id = self
.find_id(target)
.ok_or_else(|| anyhow::anyhow!("no checkbox {target:?} to set"))?;
let current = self
.node_by_id(id)
.and_then(|n| n.accesskit_node().toggled())
.map(|t| matches!(t, egui::accesskit::Toggled::True));
// Only click if the state differs (or is unknown) — so this *sets*, never
// blindly flips.
if current != Some(want) {
self.node_by_id(id)
.ok_or_else(|| anyhow::anyhow!("checkbox {target:?} vanished"))?
.click();
self.step();
}
Ok(())
}
fn set_number_field(&mut self, target: &Target, n: f64) -> anyhow::Result<()> {
let id = self
.find_id(target)
.ok_or_else(|| anyhow::anyhow!("no numeric widget {target:?} to set"))?;
// egui honours the AccessKit SetValue action (NumericValue) for sliders /
// DragValues — the same path a screen-reader uses to set a value.
let target_node = egui::accesskit::NodeId(id);
self.harness.event(egui::Event::AccessKitActionRequest(
egui::accesskit::ActionRequest {
action: egui::accesskit::Action::SetValue,
target_node,
target_tree: egui::accesskit::TreeId::ROOT,
data: Some(egui::accesskit::ActionData::NumericValue(n)),
},
));
self.step();
Ok(())
}
/// **R6** — feed an arbitrary synthetic [`egui::Event`] sequence into the
/// harness for one frame, then step. The lowest-level escape hatch: pointer
/// moves/clicks at an absolute position, key combos with modifiers, raw
/// `Text` events — anything the higher-level verbs can't address. Events are
/// delivered in order on the *same* frame, then a frame is run.
pub fn inject_events(&mut self, events: impl IntoIterator<Item = egui::Event>) {
for ev in events {
self.harness.event(ev);
}
self.step();
}
// ─── R6: AccessKit-tree readers (discover + assert widgets) ───────────────
/// Resolve a [`Target`] to a concrete AccessKit node id this frame, if the
/// widget is present. Lets a driver discover the id of a label-addressed
/// widget once, then drive it by id across frames.
pub fn find_id(&self, target: &Target) -> Option<u64> {
match target {
Target::Label(label) => self
.harness
.query_by_label(label)
.map(|n| egui_node_id(&n.accesskit_node())),
Target::Id(id) => self
.query_by_id(*id)
.map(|n| egui_node_id(&n.accesskit_node())),
}
}
/// The AccessKit node id of the widget labelled `label`, if present — the
/// discover-then-drive-by-id helper.
pub fn node_id(&self, label: &str) -> Option<u64> {
self.harness
.query_by_label(label)
.map(|n| egui_node_id(&n.accesskit_node()))
}
/// The current AccessKit `value` string of the widget the `target` addresses
/// (a `TextEdit`'s text, a slider's formatted value), if any. Reads it off
/// the rendered widget — proof a [`set_field`](Self::set_field) text edit
/// landed in the widget, independent of `state_json`.
pub fn field_value(&self, target: &Target) -> Option<String> {
// Resolve to an id (a `Copy` u64) first so no `&str` borrow of `target`
// is tied into the AccessKit tree's lifetime.
let id = self.find_id(target)?;
self.query_by_id(id).and_then(|n| n.value())
}
/// Whether the checkbox/switch the `target` addresses is currently toggled
/// on. `None` if the widget isn't a toggle (or is absent).
pub fn field_checked(&self, target: &Target) -> Option<bool> {
let id = self.find_id(target)?;
self.query_by_id(id)
.and_then(|n| n.accesskit_node().toggled())
.map(|t| matches!(t, egui::accesskit::Toggled::True))
}
// Internal: query a node by raw AccessKit id (anywhere in the tree).
fn query_by_id(&self, id: u64) -> Option<egui_kittest::Node<'_>> {
self.harness.query_by(move |n| egui_node_id(n) == id)
}
// Internal: query a node by raw AccessKit id, mutating-frame variant kept
// separate so the borrow checker is happy across the focus→type sequence.
fn node_by_id(&self, id: u64) -> Option<egui_kittest::Node<'_>> {
self.query_by_id(id)
}
// ─── assertions ─────────────────────────────────────────────────────────
/// **Where is the widget labelled `label`, and can the user actually see it?**
/// `Ok(rect)` = it is laid out AND its bounds meet the rendered surface;
/// `Err(reason)` names which of the three ways it failed.
///
/// # `korpui-31`: presence in the tree is not visibility
/// This used to be `query_by_label(label).is_some()` and its doc said "the user
/// would see it". Those are different claims, and the gap is not theoretical:
/// `egui_kittest::Node::click` injects an `Event::PointerButton` at
/// `rect().center()`, so a node whose rect is off the rendered surface is
/// "clicked" at a coordinate egui hands to whatever is painted there — or to
/// nothing at all. MEASURED (`clipped_probe`, a 200×100 surface with the button
/// pushed 400 px down): the node IS in the tree, its bounding box is `y 411..429`
/// against a `0..100` surface, the old `label_visible` answered **true**, the old
/// `click_label` returned **Ok**, and the counter stayed at **0**. Same shape
/// MEASURED on korp's real shell: a `LabelVisible "🎬 Demo"` PASS followed by a
/// `ClickLabel "🎬 Demo"` whose action "succeeded" while `/tab` never left `tables`.
///
/// So the question is asked of the RECT, not of the tree.
pub fn label_visibility(&self, label: &str) -> Result<egui::Rect, String> {
let node = self.resolve_by_label(label)?;
let bb = node.accesskit_node().bounding_box().ok_or_else(|| {
format!("widget {label:?} is in the tree but has NO bounds — it was never laid out")
})?;
let rect = egui::Rect::from_min_max(
egui::pos2(bb.x0 as f32, bb.y0 as f32),
egui::pos2(bb.x1 as f32, bb.y1 as f32),
);
let surface = self.harness.ctx.content_rect();
let seen = rect.intersect(surface);
if seen.width() <= 0.0 || seen.height() <= 0.0 {
return Err(format!(
"widget {label:?} is laid out at {rect:?}, which does not meet the rendered \
surface {surface:?} — the user cannot see it, and a click at its centre \
lands off-surface"
));
}
Ok(rect)
}
/// **The ONE label→node resolver** (LAW 5) every label-addressed call goes
/// through, and the answer to a label that names MORE THAN ONE node.
///
/// # Why this exists — MEASURED on nornir's viz, 2026-08-22
///
/// `kittest`'s `query_by_label` is `get`-flavoured: two matches is a PANIC
/// ("Found two or more nodes matching the query"), not a `None`. That is the
/// right default for a query, and the wrong one for a DRIVE, because a real
/// pane legitimately paints a name twice — once as the control and once as
/// the heading of the thing the control selects:
///
/// * `🎛 Settings` — the header TAB (`Button`) and the pane's own heading
/// (`Label` + `TextRun`);
/// * every 🔨 Build variant row — `nornir-solo` as the selectable `Button`
/// AND as the card's title `Label`.
///
/// Three of nornir's robot suites (`robot_settings_tab`, `robot_viz`,
/// `viz_build_robot`) died in that panic the first time they were compiled
/// after the egui realign — the drive never reached its assertion, so the
/// suite could not report on the app at all. Neither UI is wrong: a heading
/// SHOULD say what the button says.
///
/// So: among nodes carrying the label, prefer the one a user could actually
/// press. Ambiguity between a control and its caption is resolved toward the
/// control; ambiguity between two CONTROLS is still ambiguous and still an
/// error, because that one is a real defect in the app's labelling and
/// silently picking one would hide it.
///
/// This can only widen: before it, every one of these cases panicked, so no
/// passing drive anywhere depended on the old behaviour.
fn resolve_by_label<'q>(&'q self, label: &'q str) -> Result<egui_kittest::Node<'q>, String> {
let all: Vec<_> = self.harness.query_all_by_label(label).collect();
match all.len() {
0 => Err(format!(
"no widget labelled {label:?} in the AccessKit tree"
)),
1 => Ok(all.into_iter().next().expect("len == 1")),
_ => {
let mut pressable = all
.into_iter()
.filter(|n| is_pressable(n.accesskit_node().role()));
match (pressable.next(), pressable.next()) {
(Some(one), None) => Ok(one),
(Some(_), Some(_)) => Err(format!(
"{label:?} names MORE THAN ONE pressable widget — that is an \
ambiguous label in the app, not something a drive may guess at; \
give the controls distinct labels (or address one by id)"
)),
(None, _) => Err(format!(
"{label:?} is in the AccessKit tree but NOTHING carrying it is \
pressable — it is a caption, not a control"
)),
}
}
}
}
/// True iff a widget labelled `label` is laid out ON the rendered surface —
/// i.e. the user would see it. Non-panicking; pair with [`Assert`] for the
/// scenario path. The reason for a `false` is in
/// [`label_visibility`](Self::label_visibility).
pub fn label_visible(&self, label: &str) -> bool {
self.label_visibility(label).is_ok()
}
/// Read a value out of the app's [`Observable::state_json`] by JSON pointer
/// (RFC 6901, e.g. `/test/runs/0/run_id`). `None` if the path is absent.
pub fn state_at(&self, pointer: &str) -> Option<Value> {
self.state().pointer(pointer).cloned()
}
// ─── snapshot (PNG proof) ────────────────────────────────────────────────
/// Render a wgpu PNG snapshot named `name` (golden-compared by egui_kittest,
/// written under the consumer's snapshot dir). Only available with the
/// `snapshot` feature so the core stays GPU-free.
#[cfg(feature = "snapshot")]
pub fn snapshot(&mut self, name: &str) {
self.harness.snapshot(name);
}
/// Render the current frame to an [`image::RgbaImage`] (raw pixels) — for
/// callers that want to save proof themselves. `snapshot` feature only.
#[cfg(feature = "snapshot")]
pub fn render(&mut self) -> Result<image::RgbaImage, String> {
self.harness.render()
}
// ─── the scenario runner: drive every step → matrix rows ────────────────
/// Run a whole [`Scenario`]: for each [`Step`], perform its [`Action`],
/// evaluate its [`Assert`] against the post-action [`state`](Self::state),
/// and record a [`TestResultRow`] (`suite = "robot-ui"`,
/// `test_name = step.label`, `aspect = "robot-ui"`). All rows share one
/// `run_id` so they roll up as a single matrix run.
///
/// This is the "drive every button → the matrix fills" call: one invocation
/// returns one row per step, ready to ship to any
/// [`TestSink`](nornir_testmatrix::TestSink) (a `JsonFileSink`, or nornir's
/// Iceberg warehouse).
pub fn run_scenario(&mut self, scenario: &Scenario) -> Vec<TestResultRow> {
let run_id = nornir_testmatrix::new_run_id();
let ts = now_micros();
let mut rows = Vec::with_capacity(scenario.steps.len());
for step in &scenario.steps {
let started = std::time::Instant::now();
let (status, message) = self.run_step(step);
let duration_ms = started.elapsed().as_secs_f64() * 1000.0;
rows.push(TestResultRow {
run_id: run_id.clone(),
repo: self.repo.clone(),
suite: scenario.suite.clone(),
test_name: step.label.clone(),
status: status.to_string(),
duration_ms,
ts_micros: ts,
message,
aspect: "robot-ui".to_string(),
metric: 0.0,
});
}
rows
}
/// Perform one step's action then check its assertion. Returns
/// `(status, message)` — a failed action *or* a failed assertion is `fail`
/// with the reason; otherwise `pass`. `pub(crate)` so the `cmd_json` wire
/// host ([`crate::cmd_json`]) drives a whole step over the channel through the
/// SAME act→assert path the in-process runner uses (one vocabulary, one home).
pub(crate) fn run_step(&mut self, step: &Step) -> (&'static str, String) {
// 1. ACT — a failed action (e.g. missing button) fails the step.
if let Err(e) = self.perform(&step.action) {
return (status::FAIL, format!("action failed: {e}"));
}
// 2. ASSERT — against the post-action observable state.
match self.check(&step.assert) {
Ok(()) => (status::PASS, String::new()),
Err(e) => (status::FAIL, e),
}
}
/// Dispatch an [`Action`] to the matching driver method. `pub(crate)` so the
/// `cmd_json` wire host applies a wire-decoded [`Action`] through the exact
/// same in-process gesture the local runner uses.
pub(crate) fn perform(&mut self, action: &Action) -> anyhow::Result<()> {
match action {
Action::ClickLabel(s) => self.click_label(s),
Action::ClickButton(s) => self.click_button(s),
Action::ClickById(id) => self.click_by_id(*id),
Action::TypeInto { label, text } => self.type_into(label, text),
Action::SetField { target, value } => self.set_field(target, value),
Action::Key(k) => {
self.key(*k);
Ok(())
}
Action::Scroll(delta) => {
self.scroll(*delta);
Ok(())
}
Action::Drag { from, to } => {
self.drag(*from, *to);
Ok(())
}
Action::InjectEvents(events) => {
self.inject_events(events.iter().cloned());
Ok(())
}
Action::Step => {
self.step();
Ok(())
}
Action::None => Ok(()),
}
}
/// Evaluate an [`Assert`] against the current observable state. `Ok` = pass.
/// `pub(crate)` so the `cmd_json` wire host evaluates a wire-decoded
/// [`Assert`] through the SAME oracle — a wire assertion FAILURE surfaces as a
/// failed result exactly as the in-process one does.
pub(crate) fn check(&self, assert: &Assert) -> Result<(), String> {
match assert {
Assert::None => Ok(()),
// A chained `.assert(a).assert(b)` — EVERY clause must hold. The
// message names which clause of how many failed, so a revived clause
// is traceable to its line in the chain instead of just "the step".
Assert::All(clauses) => {
let n = clauses.len();
for (i, c) in clauses.iter().enumerate() {
self.check(c)
.map_err(|e| format!("clause {} of {n}: {e}", i + 1))?;
}
Ok(())
}
Assert::LabelVisible(label) => {
if self.label_visible(label) {
Ok(())
} else {
Err(format!(
"expected a widget labelled {label:?} to be visible"
))
}
}
Assert::Drew => {
// "Did it draw?" — the AccessKit tree has more than just the root
// when a UI rendered something. A bare/blank frame has only root.
let root = self.harness.root();
if root.children().next().is_some() {
Ok(())
} else {
Err("expected the UI to draw at least one widget, tree was empty".into())
}
}
Assert::State { pointer, expected } => match self.state_at(pointer) {
Some(actual) if &actual == expected => Ok(()),
Some(actual) => Err(format!(
"state at {pointer:?}: expected {expected}, got {actual}"
)),
None => Err(format!("state at {pointer:?} is absent")),
},
Assert::StateContains { pointer, needle } => match self.state_at(pointer) {
Some(actual) => {
let hay = match &actual {
Value::String(s) => s.clone(),
other => other.to_string(),
};
if hay.contains(needle) {
Ok(())
} else {
Err(format!(
"state at {pointer:?} ({hay}) does not contain {needle:?}"
))
}
}
None => Err(format!("state at {pointer:?} is absent")),
},
Assert::FieldValue { target, value } => match self.field_value(target) {
Some(actual) if &actual == value => Ok(()),
Some(actual) => Err(format!(
"field {target:?} value: expected {value:?}, got {actual:?}"
)),
None => Err(format!(
"field {target:?} has no value (absent or not a value widget)"
)),
},
Assert::FieldChecked { target, checked } => match self.field_checked(target) {
Some(actual) if actual == *checked => Ok(()),
Some(actual) => Err(format!(
"field {target:?} checked: expected {checked}, got {actual}"
)),
None => Err(format!(
"field {target:?} is not a toggle (absent or not a checkbox)"
)),
},
}
}
}
/// The egui-side AccessKit **node id** as a raw `u64` for an AccessKit node.
///
/// accesskit_consumer's own `Node::id()` is an *opaque* `NodeId(TreeIndex,
/// LocalNodeId)` (its `.0` is private), so we can't read a raw u64 off it any
/// more. But `Node::locate()` hands back `(LocalNodeId, TreeId)`, and the
/// `LocalNodeId` *is* the schema-level `egui::accesskit::NodeId(pub u64)` egui
/// assigned the widget (egui maps `egui::Id -> NodeId(id.value())`). So
/// `locate().0.0` is exactly the stable per-widget u64 the old `id().0` returned
/// — that keeps the public `Target::Id` / `node_id`/`find_id` u64 API working.
/// Roles a user can PRESS. Used only to break a label tie between a control and
/// its caption (see [`RobotSession::resolve_by_label`]) — deliberately a small,
/// explicit list rather than "not a Label", so a new AccessKit role defaults to
/// "not a control" and surfaces as an error instead of being silently clicked.
fn is_pressable(role: egui::accesskit::Role) -> bool {
use egui::accesskit::Role;
matches!(
role,
Role::Button
| Role::CheckBox
| Role::RadioButton
| Role::Link
| Role::Tab
| Role::MenuItem
| Role::MenuItemCheckBox
| Role::MenuItemRadio
| Role::ListBoxOption
| Role::Switch
)
}
fn egui_node_id(node: &egui_kittest::kittest::AccessKitNode<'_>) -> u64 {
let (local_id, _tree) = node.locate();
local_id.0
}
/// Microseconds since the Unix epoch (UTC) — the `ts_micros` stamp on every row.
fn now_micros() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_micros() as i64)
.unwrap_or(0)
}
/// The outcome of a [`run_scenario_mode`] call.
pub enum RunOutcome {
/// Headless drive completed — one [`TestResultRow`] per step (fills the matrix).
Headless(Vec<TestResultRow>),
/// A watch window ran to completion and closed (no rows; it was for a human).
Watched,
}
impl RunOutcome {
/// The matrix rows, if this was a headless run (`None` for a watch run).
pub fn rows(&self) -> Option<&[TestResultRow]> {
match self {
RunOutcome::Headless(r) => Some(r),
RunOutcome::Watched => None,
}
}
}
/// **Run ONE scenario in the chosen mode** — the unified seam that lets a
/// consumer write ONE `draw(ui, app, targets)` closure + ONE [`Scenario`] and get
/// BOTH modes:
///
/// * [`RunMode::Headless`] builds an off-screen `egui_kittest` [`Harness`] over
/// `draw`/`app`, runs the scenario through [`RobotSession::run_scenario`], and
/// returns [`RunOutcome::Headless`] with the matrix rows (the CI path — the
/// `Targets` the closure records are ignored; kittest resolves via AccessKit).
/// * [`RunMode::Watch`] (feature `watch`) opens a real eframe window and replays
/// the scenario visibly, returning [`RunOutcome::Watched`]. Without the `watch`
/// feature this arm returns a clear error telling you to rebuild with it.
///
/// `mode` can come from anywhere — a literal, [`RunMode::from_env`]
/// (`ROBOTUI_WATCH=1`), or a CLI subcommand. The existing
/// [`RobotSession::run_scenario`] API is untouched; this is purely additive.
pub fn run_scenario_mode<A, F>(
app: A,
mut draw: F,
scenario: &Scenario,
repo: impl Into<String>,
mode: RunMode,
#[allow(unused_variables)] cfg: WatchConfig,
) -> anyhow::Result<RunOutcome>
where
A: Observable + 'static,
F: FnMut(&mut egui::Ui, &mut A, &mut Targets) + 'static,
{
match mode {
RunMode::Headless => {
// Wrap the unified closure so kittest sees a plain `(ui, app)` UI; the
// recorded `Targets` are throwaway in this mode.
let harness = Harness::new_ui_state(
move |ui: &mut egui::Ui, app: &mut A| {
let mut targets = Targets::default();
draw(ui, app, &mut targets);
},
app,
);
let mut robot = RobotSession::new(harness, repo);
Ok(RunOutcome::Headless(robot.run_scenario(scenario)))
}
RunMode::Watch => {
#[cfg(feature = "watch")]
{
watch::run(app, draw, scenario, repo, cfg)?;
Ok(RunOutcome::Watched)
}
#[cfg(not(feature = "watch"))]
{
let _ = (app, draw, scenario, repo.into());
Err(anyhow::anyhow!(
"watch mode needs the `watch` feature — rebuild with `--features watch` \
(or use the `robotui watch` bin)"
))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
/// A tiny self-contained egui app: a "Bump" button increments a counter, a
/// text field, a checkbox and a slider whose values we read back — enough to
/// exercise click + type + set_field (text/bool/number) + click_by_id +
/// inject_events + state assertions without any nornir/viz deps.
#[derive(Default)]
struct Counter {
clicks: u32,
name: String,
agree: bool,
amount: f32,
}
impl Observable for Counter {
fn state_json(&self) -> Value {
json!({
"clicks": self.clicks,
"name": self.name,
"agree": self.agree,
"amount": self.amount,
})
}
}
fn session() -> RobotSession<'static, Counter> {
let harness = Harness::new_ui_state(
|ui, app: &mut Counter| {
if ui.button("Bump").clicked() {
app.clicks += 1;
}
// Link a visible label to the field so it gets an AccessKit name
// the robot can target — the egui idiom that makes a TextEdit
// addressable by `type_into("Name", …)`: the *input* is
// `.labelled_by(label.id)`, which puts "Name" on the TextInput.
let lbl = ui.label("Name");
ui.text_edit_singleline(&mut app.name).labelled_by(lbl.id);
// A checkbox (its label is its text) and a slider linked to a
// label — both addressable for set_field.
ui.checkbox(&mut app.agree, "Agree");
let slbl = ui.label("Amount");
ui.add(egui::Slider::new(&mut app.amount, 0.0..=100.0))
.labelled_by(slbl.id);
},
Counter::default(),
);
RobotSession::new(harness, "robotui-selftest")
}
#[test]
fn click_button_mutates_observable_state() {
let mut robot = session();
assert_eq!(robot.state_at("/clicks"), Some(json!(0)));
robot.click_button("Bump").expect("Bump button exists");
robot.click_button("Bump").expect("Bump button exists");
assert_eq!(
robot.state_at("/clicks"),
Some(json!(2)),
"two real clicks drove the counter to 2 via the AccessKit tree"
);
}
#[test]
fn missing_button_is_an_error_not_a_panic() {
let mut robot = session();
let err = robot.click_button("Nope").unwrap_err();
assert!(
err.to_string().contains("Nope"),
"names the missing button: {err}"
);
}
#[test]
fn label_visible_reads_the_accesskit_tree() {
let robot = session();
assert!(
robot.label_visible("Bump"),
"the Bump button is in the tree"
);
assert!(
!robot.label_visible("Ghost"),
"a missing label is not visible"
);
}
#[test]
fn run_scenario_fills_the_matrix_with_one_row_per_step() {
let mut robot = session();
let scenario = Scenario::new("counter")
.step(
Step::new("bump once → clicks == 1")
.action(Action::ClickButton("Bump".into()))
.assert(Assert::State {
pointer: "/clicks".into(),
expected: json!(1),
}),
)
.step(
Step::new("bump again → clicks == 2")
.action(Action::ClickButton("Bump".into()))
.assert(Assert::State {
pointer: "/clicks".into(),
expected: json!(2),
}),
)
.step(Step::new("bump button is visible").assert(Assert::LabelVisible("Bump".into())))
.step(
// This one is WRONG on purpose — proves a failing assertion lands
// as a red matrix cell, not a panic (inject-assert LAW).
Step::new("deliberately-wrong expectation → red cell").assert(Assert::State {
pointer: "/clicks".into(),
expected: json!(999),
}),
);
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 4, "one matrix row per step");
assert!(
rows.iter().all(|r| r.suite == "counter"),
"suite carried through"
);
assert!(
rows.iter().all(|r| r.aspect == "robot-ui"),
"aspect tags the robot run"
);
assert!(
rows.iter().all(|r| r.repo == "robotui-selftest"),
"repo stamped"
);
let run_id = &rows[0].run_id;
assert!(
rows.iter().all(|r| &r.run_id == run_id),
"all rows share one run id"
);
assert_eq!(rows[0].status, status::PASS, "first bump passed");
assert_eq!(rows[1].status, status::PASS, "second bump passed");
assert_eq!(rows[2].status, status::PASS, "visibility passed");
assert_eq!(
rows[3].status,
status::FAIL,
"the wrong expectation is a red cell"
);
assert!(
rows[3].message.contains("999"),
"the red cell records why it failed: {}",
rows[3].message
);
}
#[test]
fn type_into_drives_a_text_field_and_state_reads_it_back() {
let mut robot = session();
robot
.type_into("Name", "Heimdall")
.expect("Name field exists");
assert_eq!(
robot.state_at("/name"),
Some(json!("Heimdall")),
"typed text round-trips through the real egui TextEdit into state_json"
);
}
#[test]
fn assert_drew_passes_for_a_rendered_ui() {
let mut robot = session();
robot.step();
// Drew is checked via the scenario path so it goes through `check`.
let scenario =
Scenario::new("drew").step(Step::new("ui drew widgets").assert(Assert::Drew));
let rows = robot.run_scenario(&scenario);
assert_eq!(
rows[0].status,
status::PASS,
"a rendered UI counts as drawn"
);
}
/// **The chained-assert regression guard.** `Step::assert` used to be a
/// SETTER (`self.assert = assert`), so a chained `.assert(a).assert(b)`
/// silently threw `a` away and checked only `b`. Every earlier clause in
/// every chained step in the fleet was dead code reporting green.
///
/// This step chains a FALSE clause first and a TRUE clause second. Under the
/// old setter the step reported `pass` (the false clause was gone). It must
/// now report `fail` and name the discarded clause's subject.
#[test]
fn a_chained_assert_does_not_overwrite_the_one_before_it() {
let mut robot = session();
let scenario = Scenario::new("chain").step(
Step::new("first clause is FALSE, second is TRUE")
.assert(Assert::LabelVisible("Ghost".into())) // false: no such widget
.assert(Assert::Drew), // true: the UI drew
);
let rows = robot.run_scenario(&scenario);
assert_eq!(
rows[0].status,
status::FAIL,
"the FIRST clause is false, so the step must fail; got message {:?}",
rows[0].message
);
assert!(
rows[0].message.contains("Ghost"),
"the failure must name the clause that failed: {:?}",
rows[0].message
);
assert!(
rows[0].message.contains("clause 1 of 2"),
"the failure must say WHICH clause of how many: {:?}",
rows[0].message
);
}
/// The mirror of the guard above: a chain where EVERY clause holds still
/// passes — so the fix cannot be satisfied by a step that always fails.
#[test]
fn a_chain_of_true_clauses_still_passes() {
let mut robot = session();
let scenario = Scenario::new("chain-ok").step(
Step::new("bump once, then check three separate things")
.action(Action::ClickButton("Bump".into()))
.assert(Assert::State {
pointer: "/clicks".into(),
expected: json!(1),
})
.assert(Assert::LabelVisible("Bump".into()))
.assert(Assert::Drew),
);
let rows = robot.run_scenario(&scenario);
assert_eq!(
rows[0].status,
status::PASS,
"all three clauses hold: {:?}",
rows[0].message
);
}
/// A chain whose LAST clause is the false one must still fail — proves the
/// accumulator did not simply invert into "check only the first".
#[test]
fn the_last_clause_of_a_chain_is_checked_too() {
let mut robot = session();
let scenario = Scenario::new("chain-last").step(
Step::new("true then false")
.assert(Assert::Drew)
.assert(Assert::LabelVisible("Ghost".into())),
);
let rows = robot.run_scenario(&scenario);
assert_eq!(rows[0].status, status::FAIL, "{:?}", rows[0].message);
assert!(
rows[0].message.contains("clause 2 of 2"),
"names the second clause: {:?}",
rows[0].message
);
}
// ─── R6: richer live-drive (widget-level) ────────────────────────────────
/// R6 set_field(Text): clears then sets a TextEdit by label, and the AccessKit
/// value reads it back (Assert::FieldValue path) AND state_json reflects it.
#[test]
fn set_field_text_sets_a_textedit_and_value_reads_back() {
let mut robot = session();
// Pre-seed via type_into so we prove set_field *replaces*, not appends.
robot.type_into("Name", "OLD").expect("Name field");
robot
.set_field(&Target::label("Name"), &FieldValue::Text("Heimdall".into()))
.expect("set the Name field");
assert_eq!(
robot.state_at("/name"),
Some(json!("Heimdall")),
"set_field replaced (not appended) the text → state_json reflects it"
);
assert_eq!(
robot.field_value(&Target::label("Name")).as_deref(),
Some("Heimdall"),
"the AccessKit widget value reads back the set text"
);
}
/// R6 set_field(Bool): sets a checkbox to a state (idempotent — calling with
/// the same desired state again is a no-op, never a flip).
#[test]
fn set_field_bool_sets_a_checkbox_idempotently() {
let mut robot = session();
assert_eq!(
robot.state_at("/agree"),
Some(json!(false)),
"starts unchecked"
);
robot
.set_field(&Target::label("Agree"), &FieldValue::Bool(true))
.expect("set Agree");
assert_eq!(robot.state_at("/agree"), Some(json!(true)), "now checked");
assert_eq!(robot.field_checked(&Target::label("Agree")), Some(true));
// Idempotent: setting true again leaves it true (does NOT flip back).
robot
.set_field(&Target::label("Agree"), &FieldValue::Bool(true))
.expect("re-set Agree");
assert_eq!(
robot.state_at("/agree"),
Some(json!(true)),
"set_field(Bool(true)) is idempotent — it sets, never blindly flips"
);
// And setting false clears it.
robot
.set_field(&Target::label("Agree"), &FieldValue::Bool(false))
.expect("clear Agree");
assert_eq!(robot.state_at("/agree"), Some(json!(false)), "set to false");
}
/// R6 set_field(Number): the AccessKit SetValue action drives a slider to a
/// known value, and state_json reflects the new amount.
#[test]
fn set_field_number_drives_a_slider_via_accesskit_setvalue() {
let mut robot = session();
robot.step();
assert_eq!(
robot.state_at("/amount"),
Some(json!(0.0)),
"slider starts at 0"
);
robot
.set_field(&Target::label("Amount"), &FieldValue::Number(42.0))
.expect("set the slider");
assert_eq!(
robot.state_at("/amount"),
Some(json!(42.0)),
"the SetValue action moved the slider to 42 → state_json reflects it"
);
}
/// R6 click_by_id: discover a button's AccessKit id, then click it BY ID
/// (not label) and the counter increments — the label-collision escape hatch.
#[test]
fn click_by_id_clicks_a_widget_by_accesskit_node_id() {
let mut robot = session();
robot.step();
let id = robot.node_id("Bump").expect("Bump has an AccessKit id");
assert_eq!(robot.state_at("/clicks"), Some(json!(0)));
robot.click_by_id(id).expect("click Bump by id");
assert_eq!(
robot.state_at("/clicks"),
Some(json!(1)),
"clicking by AccessKit node id drove the same widget as the label would"
);
// A bogus id is an error, not a panic.
let err = robot.click_by_id(0).unwrap_err();
assert!(err.to_string().contains('0'), "names the missing id: {err}");
}
/// R6 inject_events: feed a raw synthetic egui event sequence (focus the
/// field by id via an AccessKit Focus request, then a Text event) and the
/// field receives it — the lowest-level escape hatch.
#[test]
fn inject_events_feeds_raw_synthetic_events() {
let mut robot = session();
robot.step();
let id = robot.node_id("Name").expect("Name field id");
// Focus the field via a raw AccessKit action request, then type via a raw
// Text event — both injected as arbitrary egui::Events.
robot.inject_events([egui::Event::AccessKitActionRequest(
egui::accesskit::ActionRequest {
action: egui::accesskit::Action::Focus,
target_tree: egui::accesskit::TreeId::ROOT,
target_node: egui::accesskit::NodeId(id),
data: None,
},
)]);
robot.inject_events([egui::Event::Text("Odin".into())]);
assert_eq!(
robot.state_at("/name"),
Some(json!("Odin")),
"a raw injected Focus + Text event sequence landed in the real TextEdit"
);
}
/// R6 end-to-end through the scenario runner: every new verb fills a matrix
/// row, including a deliberately-wrong FieldValue assertion → a red cell.
#[test]
fn r6_scenario_drives_every_new_verb_and_fills_the_matrix() {
let mut robot = session();
robot.step();
let bump_id = robot.node_id("Bump").expect("Bump id");
let scenario = Scenario::new("r6-selftest")
.step(
Step::new("set Name by label → value reads back")
.action(Action::SetField {
target: Target::label("Name"),
value: FieldValue::Text("Frigg".into()),
})
.assert(Assert::FieldValue {
target: Target::label("Name"),
value: "Frigg".into(),
}),
)
.step(
Step::new("check Agree → checked")
.action(Action::SetField {
target: Target::label("Agree"),
value: FieldValue::Bool(true),
})
.assert(Assert::FieldChecked {
target: Target::label("Agree"),
checked: true,
}),
)
.step(
Step::new("click Bump by id → clicks == 1")
.action(Action::ClickById(bump_id))
.assert(Assert::State {
pointer: "/clicks".into(),
expected: json!(1),
}),
)
.step(
Step::new("inject Text into focused field")
.action(Action::InjectEvents(vec![
egui::Event::AccessKitActionRequest(egui::accesskit::ActionRequest {
action: egui::accesskit::Action::Focus,
target_tree: egui::accesskit::TreeId::ROOT,
target_node: egui::accesskit::NodeId(
robot.node_id("Name").unwrap_or(0),
),
data: None,
}),
]))
.assert(Assert::None),
)
.step(
// WRONG on purpose → a red cell, proving a failed FieldValue is a
// recorded failure, not a panic.
Step::new("wrong field value → red cell").assert(Assert::FieldValue {
target: Target::label("Name"),
value: "NOPE".into(),
}),
);
let rows = robot.run_scenario(&scenario);
assert_eq!(rows.len(), 5, "one row per step");
assert_eq!(
rows[0].status,
status::PASS,
"set text: {}",
rows[0].message
);
assert_eq!(
rows[1].status,
status::PASS,
"set bool: {}",
rows[1].message
);
assert_eq!(
rows[2].status,
status::PASS,
"click by id: {}",
rows[2].message
);
assert_eq!(rows[3].status, status::PASS, "inject: {}", rows[3].message);
assert_eq!(
rows[4].status,
status::FAIL,
"wrong field value is a red cell"
);
assert!(
rows[4].message.contains("NOPE"),
"records why: {}",
rows[4].message
);
}
// ─── L0 atom-walk: error_atoms() scans the live tree for error atoms ───────
/// A two-label egui UI: one label carries an error-marker substring
/// (`workspace `` is not served`), the other is clean. `error_atoms` walks the
/// AccessKit tree and returns EXACTLY the one error atom's text. This is the
/// reusable scan the viz atom-walk uses to catch sub-panel errors mechanically.
#[test]
fn error_atoms_returns_only_the_error_marker_node() {
let mut harness = Harness::new_ui_state(
|ui, _app: &mut Counter| {
ui.label("all good here");
ui.label("NotFound: workspace `` is not served");
},
Counter::default(),
);
let _ = harness.run_ok();
let atoms = error_atoms(&harness);
assert_eq!(
atoms.len(),
1,
"exactly one error atom found, got {atoms:?}"
);
assert!(
atoms[0].contains("is not served"),
"the returned atom is the `is not served` error text: {atoms:?}"
);
}
/// Bug #22: a lowercase pane error render (`open warehouse failed: …`) is now
/// caught by `error_atoms`, while a benign `failed: <n>` count column is NOT
/// (no blanket "failed" marker).
#[test]
fn error_atoms_catches_lowercase_pane_failures_not_count_columns() {
let mut harness = Harness::new_ui_state(
|ui, _app: &mut Counter| {
ui.label("open warehouse failed: catalog.redb locked");
ui.label("passed: 12 failed: 0"); // a count column, NOT an error
},
Counter::default(),
);
let _ = harness.run_ok();
let atoms = error_atoms(&harness);
assert_eq!(atoms.len(), 1, "only the real failure atom, got {atoms:?}");
assert!(atoms[0].contains("open warehouse failed"), "{atoms:?}");
}
/// `all_atoms` walks the live tree and returns the interactive/labelled atoms
/// with their roles — a button, a plain label, a checkbox — deduped, dropping
/// text-less structural nodes. This is the per-tab discovered surface the
/// atom-coverage producer maps to rows.
#[test]
fn all_atoms_enumerates_interactive_and_labelled_nodes() {
let mut harness = Harness::new_ui_state(
|ui, app: &mut Counter| {
ui.label("Status: idle");
if ui.button("Run").clicked() {}
ui.checkbox(&mut app.agree, "Agree");
},
Counter::default(),
);
let _ = harness.run_ok();
let atoms = all_atoms(&harness);
// The button is discovered as a Button-role atom with its text.
assert!(
atoms
.iter()
.any(|a| a.text == "Run" && a.role.contains("Button")),
"Run button discovered with role: {atoms:?}"
);
// The plain label is discovered as a Label-role atom.
assert!(
atoms
.iter()
.any(|a| a.text.contains("Status") && a.role.contains("Label")),
"status label discovered: {atoms:?}"
);
// The checkbox is discovered (CheckBox role).
assert!(
atoms.iter().any(|a| a.text == "Agree"),
"Agree checkbox discovered: {atoms:?}"
);
// `atom_labels` is the deduped text view used by the drift check.
let labels = atom_labels(&harness);
assert!(labels.contains(&"Run".to_string()));
assert!(labels.contains(&"Agree".to_string()));
}
/// A clean UI (no error-marker substrings anywhere) → `error_atoms` is empty.
/// This is the green-state proof: the empty Vec is what an error-free surface
/// returns, so `error_atoms(&h).is_empty()` is a meaningful pass.
#[test]
fn error_atoms_is_empty_for_a_clean_ui() {
let mut harness = Harness::new_ui_state(
|ui, _app: &mut Counter| {
ui.label("all good here");
if ui.button("Run").clicked() {}
},
Counter::default(),
);
let _ = harness.run_ok();
let atoms = error_atoms(&harness);
assert!(
atoms.is_empty(),
"a clean UI has no error atoms, got {atoms:?}"
);
}
}
#[cfg(test)]
mod clipped_probe {
//! **`korpui-31` — the red proof for the visibility + landing gates.**
//!
//! MEASURED before the fix, verbatim from this same fixture: node present `true`,
//! bounding box `y 411..429`, surface `[[0 0] - [200 100]]`, `label_visible` →
//! **true**, `click_label` → **Ok**, `/clicks` → **0**. The framework told the
//! drive the user could see and press a control that is 311 px below the window
//! and then reported the press a success. Both halves are asserted red here.
use super::*;
use serde_json::{Value, json};
#[derive(Default)]
struct Counter {
clicks: u32,
}
impl Observable for Counter {
fn state_json(&self) -> Value {
json!({ "clicks": self.clicks })
}
}
/// `offscreen = true` pushes the button 400 px down a 200x100 surface, so it is
/// laid out and in the AccessKit tree but nowhere the user or a pointer can reach.
fn session(offscreen: bool) -> RobotSession<'static, Counter> {
let pad = if offscreen { 400.0 } else { 0.0 };
let harness = egui_kittest::Harness::builder()
.with_size(egui::vec2(200.0, 100.0))
.build_ui_state(
move |ui, app: &mut Counter| {
ui.allocate_space(egui::vec2(1.0, pad));
if ui.button("Bump").clicked() {
app.clicks += 1;
}
},
Counter::default(),
);
RobotSession::new(harness, "robotui-selftest")
}
/// The control case: the SAME fixture with the button on the surface must stay
/// green, or the gate above is just a way of failing.
#[test]
fn an_onscreen_widget_is_visible_and_its_click_lands() {
let mut robot = session(false);
robot.step();
assert!(robot.label_visible("Bump"), "the button is on the surface");
robot.click_label("Bump").expect("an on-surface click lands");
assert_eq!(
robot.state_at("/clicks"),
Some(json!(1)),
"the click reached the widget"
);
}
/// RED: an off-surface widget is NOT visible, and clicking it is an ERROR — not
/// a silent success over a counter that never moved.
#[test]
fn an_offscreen_widget_is_not_visible_and_cannot_be_clicked() {
let mut robot = session(true);
robot.step();
// The node IS in the tree — this is exactly why the old `is_some()` answered
// true. Assert that, so the test still describes the trap it closes.
assert!(
robot.harness.query_by_label("Bump").is_some(),
"the widget is in the AccessKit tree — presence was never the question"
);
let why = robot
.label_visibility("Bump")
.expect_err("an off-surface widget is not visible");
assert!(
why.contains("does not meet the rendered surface"),
"the reason names the surface it missed: {why}"
);
assert!(!robot.label_visible("Bump"));
let err = robot
.click_label("Bump")
.expect_err("clicking an off-surface widget must be an error");
assert!(err.to_string().contains("Bump"), "names the widget: {err}");
assert_eq!(
robot.state_at("/clicks"),
Some(json!(0)),
"and the counter proves the click really could not have landed"
);
}
/// The step-level shape: the same miss as a `Scenario` must produce a RED matrix
/// row, not a pass. This is what `korpui-31` said the fleet never got.
#[test]
fn an_offscreen_click_is_a_red_matrix_row() {
let mut robot = session(true);
let rows = robot.run_scenario(
&Scenario::new("offscreen")
.step(
Step::new("the button is visible")
.assert(Assert::LabelVisible("Bump".into())),
)
.step(Step::new("click it").action(Action::ClickLabel("Bump".into()))),
);
assert_eq!(rows.len(), 2);
assert!(
rows.iter().all(|r| r.status == status::FAIL),
"both the assertion and the action must be red: {:?}",
rows.iter().map(|r| (&r.test_name, &r.status, &r.message)).collect::<Vec<_>>()
);
}
}
#[cfg(test)]
mod label_tie {
//! **The control-vs-caption label tie** — a name painted BOTH as the control
//! and as the heading of what it selects.
//!
//! MEASURED on nornir's viz 2026-08-22, which is where this came from: the 🎛
//! Settings header tab and the Settings pane heading both carry `🎛 Settings`;
//! every 🔨 Build row carries its variant name as the selectable `Button` AND
//! as the card title `Label`. `kittest::query_by_label` is `get`-flavoured, so
//! it PANICS on two matches — and `robot_settings_tab`, `robot_viz` and
//! `viz_build_robot` all died in that panic the first time they compiled after
//! the egui realign, never reaching the assertion they exist for.
//!
//! RED PROOF: with `resolve_by_label` reverted to `harness.query_by_label`,
//! `a_control_wins_a_tie_with_its_own_caption` panics inside kittest with
//! "Found two or more nodes matching the query" instead of clicking.
use super::*;
use serde_json::{Value, json};
#[derive(Default)]
struct Twice {
clicks: u32,
}
impl Observable for Twice {
fn state_json(&self) -> Value {
json!({ "clicks": self.clicks })
}
}
/// `Mode::Tie` paints `Save` as a caption AND as the button under it — the
/// real shape. `Mode::TwoButtons` paints it as two BUTTONS, which is a genuine
/// app-side ambiguity a drive must refuse rather than guess at.
enum Mode {
Tie,
TwoButtons,
CaptionOnly,
}
fn session(mode: Mode) -> RobotSession<'static, Twice> {
let harness = egui_kittest::Harness::builder()
.with_size(egui::vec2(300.0, 200.0))
.build_ui_state(
move |ui, app: &mut Twice| match mode {
Mode::Tie => {
ui.label("Save");
if ui.button("Save").clicked() {
app.clicks += 1;
}
}
Mode::TwoButtons => {
if ui.button("Save").clicked() {
app.clicks += 1;
}
if ui.button("Save").clicked() {
app.clicks += 1;
}
}
Mode::CaptionOnly => {
ui.label("Save");
}
},
Twice::default(),
);
RobotSession::new(harness, "robotui-selftest")
}
/// The case the three nornir suites hit: caption + control share a name. The
/// drive must press the CONTROL — and the applied proof is the counter, not
/// that `click_label` returned `Ok`.
#[test]
fn a_control_wins_a_tie_with_its_own_caption() {
let mut robot = session(Mode::Tie);
robot.run(2);
assert_eq!(robot.state()["clicks"], json!(0), "starts unclicked");
robot.click_label("Save").expect("the control is pressable");
assert_eq!(
robot.state()["clicks"],
json!(1),
"the BUTTON was pressed, not the caption — a caption click leaves this 0"
);
assert!(
robot.label_visible("Save"),
"the same tie must resolve for the visibility oracle too, or `LabelVisible` \
and `ClickLabel` disagree about the same widget"
);
}
/// Two real CONTROLS sharing a label is an app defect. Resolving it silently
/// would hide exactly the kind of duplicate-button bug a robot suite is for,
/// so it stays an error — and an error, not a panic.
#[test]
fn two_pressable_widgets_with_one_label_stay_an_error() {
let mut robot = session(Mode::TwoButtons);
robot.run(2);
let err = robot
.click_label("Save")
.expect_err("two buttons with one label must not resolve");
assert!(
err.to_string().contains("MORE THAN ONE pressable"),
"the error must name the ambiguity: {err}"
);
assert_eq!(
robot.state()["clicks"],
json!(0),
"and nothing may have been pressed while refusing"
);
}
/// **The asymmetry, asserted rather than assumed.** A label carried by exactly
/// ONE node resolves whatever its role is — that is the pre-existing behaviour
/// of every label-addressed call and this change deliberately did not touch it,
/// because tightening "a lone node must be pressable" would newly refuse clicks
/// that korp/holger/dwarves drives make today and pass. So `click_label` on a
/// lone caption still returns `Ok` — and PRESSES NOTHING, which is the part
/// that matters and the part asserted here (the counter is the oracle, never
/// the `Ok`).
///
/// Only the ≥2 case refuses a caption, because there the alternative is a
/// coin-flip between nodes. Named so the next reader sees the seam instead of
/// discovering it: if the lone case should refuse too, that is a separate,
/// deliberate widening across five consumer apps, not a tie-break detail.
#[test]
fn a_lone_caption_resolves_but_presses_nothing() {
let mut robot = session(Mode::CaptionOnly);
robot.run(2);
assert!(
robot.click_label("Save").is_ok(),
"a lone node resolves regardless of role — the long-standing contract"
);
assert_eq!(
robot.state()["clicks"],
json!(0),
"but nothing was pressed: a caption is not a control, and the APPLIED \
outcome says so even where the return value does not"
);
}
}