nornir-robotui
A reusable robot-UI testing framework for egui apps. It auto-clicks around, scrolls, presses buttons, types — and watches the test matrix fill up.
Three jobs, each on a confirmed nordisk seam:
- Drive real egui interactions with
egui_kittest(AccessKit-based):click_label/click_button,type_into,key,scroll,drag,step/run, and (behind thesnapshotfeature) a wgpu PNGsnapshot. - Observe / assert via the nordisk components-expose-readable-data pattern: your app implements the one-method [
Observable] trait (fn state_json(&self) -> serde_json::Value) — the same blob nornir's viz dumps to$NORNIR_VIZ_STATEand facett'sFacets expose. The framework reads it back to assert what the user would see, not pixels. - Emit results as
nornir-testmatrixrows: each(action → expected outcome)step is one matrix cell (pass/fail), so a robot run literally fills the test matrix.
Quick start
use nornir_robotui::{RobotSession, Observable, Scenario, Step, Action, Assert};
use egui_kittest::Harness;
use serde_json::{json, Value};
// 1. Your app implements Observable — 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.
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>, aspect = "robot-ui"
run_scenario returns one TestResultRow per step (all sharing one run_id), ready to ship to any TestSink — a JsonFileSink, or nornir's Iceberg warehouse.
Two modes over ONE scenario — headless matrix + visible watch
The SAME Scenario runs two ways, chosen with RunMode:
RunMode::Headless(default, lean) — the drive above → matrix rows, for CI.RunMode::Watch(featurewatch) — a real, visible eframe window that replays the scenario paced so a human can WATCH the robot move: a cursor travels, clicks, types, scrolls, driven by syntheticRawInputevents.eframe/winit/gloware behind thewatchfeature, so the lean matrix harness never pulls a windowing stack.
Write ONE draw(ui, app, targets) closure + ONE Scenario, then pick the mode — an API param, ROBOTUI_WATCH=1 (RunMode::from_env), or the CLI:
use nornir_robotui::{run_scenario_mode, RunMode, Targets, WatchConfig};
let draw = |ui: &mut egui::Ui, app: &mut MyApp, t: &mut Targets| {
if t.record("Bump", ui.button("Bump")).clicked() { app.clicks += 1; }
};
run_scenario_mode(MyApp { clicks: 0 }, draw, &scenario, "my-repo",
RunMode::from_env(), WatchConfig::default())?; // ROBOTUI_WATCH=1 -> a window
# Watch the shipped demo move (opens a window on your display):
cargo run -p nornir-robotui --features watch --bin robotui -- \
watch crates/nornir-robotui/.nornir/scenarios/bump-demo.toml
# Headless (matrix rows): robotui run <scenario.toml>
See .nornir/robotui-watch-mode.md for the full watch design + adoption.
Features
- default — light core: drive + observe + emit matrix rows, no GPU.
snapshot— pullsegui_kittest's wgpu renderer soRobotSession::snapshot(name)andrender()produce real PNG proof. Opt-in so headless CI stays GPU-free.watch— the live replay backend: addseframe(glow) + eguiserdeso aScenarioruns in a visible window and round-trips through TOML, plus therobotuibin (robotui run|watch <scenario.toml>). Opt-in so the lean/CI build stays windowing-free.
Actions & assertions
| Action | what it does |
Action::ClickLabel(s) / ClickButton(s) |
click a widget by its AccessKit label / button text |
Action::TypeInto { label, text } |
focus a field (labelled via .labelled_by(label.id)) and type |
Action::Key(egui::Key) |
press a key |
Action::Scroll(egui::Vec2) / Drag { from, to } |
scroll / drag-pan |
Action::Step / None |
run a frame / no-op (assertion-only step) |
| Assert | passes when |
Assert::State { pointer, expected } |
state_json at the JSON pointer equals expected |
Assert::StateContains { pointer, needle } |
the stringified value contains needle |
Assert::LabelVisible(s) |
a widget with that label is in the tree |
Assert::Drew |
the UI drew at least one widget |
Assert::None |
always (action-only step) |
Adopting it (korp / facett-demo / mannequin / viz)
impl Observable for YourApp { fn state_json(&self) -> Value { /* what you render */ } }— if your app already exposesstate_json()(nornir's viz, facettFacets), it's a one-line delegate.- Build a
Harnessover your app (Harness::new_ui_statefor one widget, orHarness::builder().build_state(|ctx, app| app.draw_ui(ctx), app)for a full app), wrap it inRobotSession::new(harness, "<repo>"). - Write a
Scenariothat drives every tab/button and asserts the post-actionstate_json, thenrun_scenario→ ship the rows to yourTestSink.
See .nornir/robot-ui.md for the full design + idiot guide, and the robot_viz reference test in the nornir root crate for a real multi-tab drive.