Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

crates/nornir-robotui/README.md

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:

  1. Drive real egui interactions with egui_kittest (AccessKit-based): click_label / click_button, type_into, key, scroll, drag, step/run, and (behind the snapshot feature) a wgpu PNG snapshot.
  2. 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_STATE and facett's Facets expose. The framework reads it back to assert what the user would see, not pixels.
  3. Emit results as nornir-testmatrix rows: 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:

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

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)

  1. impl Observable for YourApp { fn state_json(&self) -> Value { /* what you render */ } } — if your app already exposes state_json() (nornir's viz, facett Facets), it's a one-line delegate.
  2. Build a Harness over your app (Harness::new_ui_state for one widget, or Harness::builder().build_state(|ctx, app| app.draw_ui(ctx), app) for a full app), wrap it in RobotSession::new(harness, "<repo>").
  3. Write a Scenario that drives every tab/button and asserts the post-action state_json, then run_scenario → ship the rows to your TestSink.

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.