dwarves-gui/src/robot_mode.rs
//! **`--robot-mode` — the ONE place that decides which robot mode was asked for.**
//!
//! `--robot-mode` accepted exactly one value until 2026-08-07: `watch`. Every other
//! value — including the `headless` the mode enum has always had
//! ([`RunMode::Headless`], and the DEFAULT arm of
//! [`run_scenario_mode`](nornir_robotui::run_scenario_mode)) — fell through the
//! parser and landed on the normal GUI bring-up. So the documented headless
//! invocation **opened a real window on the operator's display**, which is the one
//! thing a headless flag exists to promise it will not do, and a typo
//! (`--robot-mode headles`) did the same thing just as quietly.
//!
//! Both failures are the same bug: a flag whose unrecognised values were silently
//! ignored rather than refused. So this module owns the whole decision —
//!
//! * `headless` → the off-screen `egui_kittest` drive, which emits one
//! [`TestResultRow`](nornir_robotui::TestResultRow) per step and, critically,
//! **exits non-zero when any row is red** ([`run_headless`]). A drive that
//! printed failures and exited 0 would be the `nornir-bench` silent green again.
//! * `watch` → the visible paced window ([`crate::robot_watch`]).
//! * anything else → a NAMED error and exit 2. Never a window.
//!
//! Gated on `any(robot-remote, watch)` — the same cfg as [`crate::robot_shell`] and
//! [`crate::scenarios`] — because the headless drive needs only those two plus
//! `egui_kittest` (a non-optional dep of `nornir-robotui`), NOT the eframe/winit
//! stack `watch` pulls. A build with neither feature has no drive to reach at all,
//! and `main` refuses `--robot-mode` outright there rather than opening the GUI.
use std::path::PathBuf;
use nornir_robotui::{status, Scenario};
use crate::robot_shell::{build_shell_session, build_shell_session_at};
/// Which robot mode the CLI/env asked for.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RobotRun {
/// Off-screen `egui_kittest` drive → matrix rows → exit code.
Headless,
/// A real visible paced window, replaying `Some(scenario.toml)` or the built-in
/// flattened tab tour (`None`).
Watch(Option<PathBuf>),
}
/// Every value `--robot-mode` accepts, for the error message and the docs to share
/// one list.
const MODES: &str = "headless, watch";
/// **Flatten the recorded per-tab library into ONE scenario** — every tab's
/// `select → interact → assert` steps concatenated in shipped tab order, so a single
/// drive walks the whole roster tab by tab.
///
/// Shared by BOTH modes (L5): the watch window and the headless drive replay the
/// SAME steps, so watching one is evidence about the other.
pub fn all_tabs_scenario() -> Scenario {
let mut scenario = Scenario::new("dwarves-all-tabs");
for tab in crate::scenarios::dwarves_tab_scenarios() {
for step in tab.steps {
scenario = scenario.step(step);
}
}
scenario
}
/// Map ONE mode spelling onto a mode, or name it as unknown. `seam` is the flag or env
/// var the value came from, so the error says which input to fix.
///
/// Shared by the flag and the env selector deliberately (L5): the reason
/// `--robot-mode headless` opened a window was two spellings of "is this watch?" that
/// did not agree, and `NORNIR_ROBOT_MODE=headles` must not resurrect it.
/// The `Watch` arm carries no path — only `--robot-watch <p>` supplies one.
fn classify(seam: &str, value: &str) -> Result<RobotRun, String> {
if value.eq_ignore_ascii_case("watch") {
Ok(RobotRun::Watch(None))
} else if value.eq_ignore_ascii_case("headless") {
Ok(RobotRun::Headless)
} else {
Err(format!(
"{seam}: unknown mode {value:?} (expected one of: {MODES})"
))
}
}
/// Decide, from CLI args + env, which robot mode was requested.
///
/// `Ok(None)` = none asked for (fall through to the normal GUI / the
/// `--robot-remote` mount). `Err(msg)` = a `--robot-mode` whose value is not a mode;
/// the caller must exit non-zero, NOT open a window.
///
/// Triggers: `--robot-mode <mode>` / `--robot-mode=<mode>`, `--robot-watch
/// [scenario.toml]`, `NORNIR_ROBOT_MODE=<mode>`, `ROBOTUI_WATCH` truthy.
/// An explicit CLI flag beats env; `watch` beats `headless` when both are named, so
/// adding `--robot-watch` to a command always gets you the window you just asked to see.
pub fn requested() -> Result<Option<RobotRun>, String> {
let args: Vec<String> = std::env::args().skip(1).collect();
let mut watch = false;
let mut headless = false;
let mut path: Option<PathBuf> = None;
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a == "--robot-watch" {
watch = true;
// An optional trailing scenario path (anything not starting with `-`).
if let Some(next) = args.get(i + 1) {
if !next.starts_with('-') {
path = Some(PathBuf::from(next));
i += 1;
}
}
} else if a == "--robot-mode" {
let Some(v) = args.get(i + 1) else {
return Err(format!("--robot-mode needs a mode (one of: {MODES})"));
};
match classify("--robot-mode", v)? {
RobotRun::Watch(_) => watch = true,
RobotRun::Headless => headless = true,
}
i += 1;
} else if let Some(v) = a.strip_prefix("--robot-mode=") {
match classify("--robot-mode", v)? {
RobotRun::Watch(_) => watch = true,
RobotRun::Headless => headless = true,
}
}
i += 1;
}
// Env selectors, consulted only when no CLI flag decided (an explicit flag wins).
if !watch && !headless {
if let Ok(mode) = std::env::var("NORNIR_ROBOT_MODE") {
let mode = mode.trim();
if !mode.is_empty() {
// Same validation as the flag: `NORNIR_ROBOT_MODE=headles` must not
// quietly become "open the GUI".
match classify("NORNIR_ROBOT_MODE", mode)? {
RobotRun::Watch(_) => watch = true,
RobotRun::Headless => headless = true,
}
}
}
// robotui's own toggle is watch-or-nothing by construction.
if matches!(
std::env::var("ROBOTUI_WATCH")
.ok()
.map(|s| s.trim().to_ascii_lowercase())
.as_deref(),
Some("1" | "true" | "watch" | "yes" | "on")
) {
watch = true;
}
}
if watch {
Ok(Some(RobotRun::Watch(path)))
} else if headless {
Ok(Some(RobotRun::Headless))
} else {
Ok(None)
}
}
/// **Run the flattened tab tour OFF-SCREEN** and report how many steps went red.
///
/// Prints one `status suite/step` line per step (plus the failure message on a red
/// one) so the run is readable in a terminal and greppable in a log, then returns the
/// red count. `Ok(0)` is the only green; the caller turns anything else into a
/// non-zero exit — see [`crate::robot_mode`]'s note on silent greens.
///
/// Needs no display, no windowing stack and no GPU: `egui_kittest` drives the shell
/// through AccessKit off-screen. That is what makes it the CI path — and also what
/// makes it NOT sufficient UI proof on its own, since nothing here ever rasterises a
/// pixel. A UI claim still needs a real window and a screenshot.
pub fn run_headless() -> Result<u32, String> {
let scenario = all_tabs_scenario();
eprintln!(
"dwarves-gui: robot-mode headless — driving {} step(s) off-screen (no window)",
scenario.steps.len()
);
// NOT `run_scenario_mode(RunMode::Headless)`: that arm builds its harness at
// kittest's default size and takes no size from `WatchConfig`, which clips this
// shell's 24-tab strip out of the AccessKit tree and reddens every tab-select step
// for a reason that has nothing to do with the product. `build_shell_session` is the
// sized, settled surface the wire-remote mount already drives — see
// [`crate::robot_shell::ROBOT_SURFACE`].
let rows = build_shell_session("dwarves").run_scenario(&scenario);
let mut red = 0u32;
for row in &rows {
if status::is_red(&row.status) {
red += 1;
eprintln!(
" {:<7} {}/{}: {}",
row.status, row.suite, row.test_name, row.message
);
} else {
eprintln!(" {:<7} {}/{}", row.status, row.suite, row.test_name);
}
}
eprintln!(
"dwarves-gui: robot-mode headless — {}/{} step(s) red",
red,
rows.len()
);
Ok(red)
}
/// `--robot-dump <dir>` / `--robot-dump=<dir>` on the command line, if given.
pub fn dump_dir() -> Option<PathBuf> {
let args: Vec<String> = std::env::args().skip(1).collect();
args.iter().enumerate().find_map(|(i, a)| {
if a == "--robot-dump" {
args.get(i + 1)
.filter(|n| !n.starts_with('-'))
.map(PathBuf::from)
} else {
a.strip_prefix("--robot-dump=").map(PathBuf::from)
}
})
}
/// What the live tour does on one tab after selecting it: the messages it sends to the
/// pane's own JSON seam (the headless twins of what the pane does on its first frames)
/// and the file its `state_json` is dumped to.
struct LiveStop {
tab: &'static str,
slug: &'static str,
/// `(messages, dump file stem)` — one dump after each batch.
reads: &'static [(&'static [&'static str], &'static str)],
}
/// The tabs the live tour reads something on. Every OTHER tab is selected and dumped
/// as it opens.
const LIVE_STOPS: &[LiveStop] = &[
LiveStop {
tab: "Lenses",
slug: "lenses",
reads: &[(&["\"scan\""], "lenses")],
},
LiveStop {
tab: "Data view",
slug: "data-view",
reads: &[(&["\"scan\""], "data-view")],
},
LiveStop {
tab: "Warehouse",
slug: "warehouse",
reads: &[
(&[r#"{"read":true}"#], "warehouse"),
(
&[r#"{"world_target":100000}"#, r#"{"world":true}"#],
"warehouse-world",
),
(&[r#"{"world":false}"#], "warehouse-after"),
],
},
LiveStop {
tab: "Coverage",
slug: "coverage",
reads: &[(&["\"scan\""], "coverage")],
},
LiveStop {
tab: "Coderoom",
slug: "coderoom",
reads: &[
(&[r#"{"reread":true}"#], "coderoom"),
(&[r#"{"mode":"3d"}"#, r#"{"reread":true}"#], "coderoom-3d"),
],
},
];
/// **The LIVE headless tour** — `--robot-mode headless --robot-dump <dir>`.
///
/// The recorded tour ([`run_headless`]) drives a HERMETIC shell (a dead endpoint), which
/// is right for CI and useless for the question "is what the pane draws what the server
/// says". This drives the same shell, off-screen, against a REAL forge (`endpoint`,
/// normally `$DWARVES_SERVER`): it clicks every tab of the shipped roster, lets it draw,
/// performs each code-graph pane's read through the pane's own JSON seam, and writes
/// every pane's `state_json` to `<dir>/<slug>.json` — the numbers a person then compares
/// with the same question asked straight of the server.
///
/// On the Data view it also clicks the first node of the code room's call graph (or
/// `$DWARVES_ROBOT_SELECT_NODE`) so the 📄 source pane's answer is in the dump.
///
/// Returns the number of tabs that could not be selected (non-zero = a red exit).
pub fn run_headless_live(endpoint: &str, dir: &std::path::Path) -> Result<u32, String> {
use facett_core::Facet;
use nornir_robotui::{Action, Assert, Step};
std::fs::create_dir_all(dir).map_err(|e| format!("create {}: {e}", dir.display()))?;
eprintln!("dwarves-gui: robot-mode headless LIVE — driving the shell against {endpoint}, dumping to {}", dir.display());
let mut session = build_shell_session_at(endpoint, "dwarves");
let write = |name: &str, v: &serde_json::Value| -> Result<(), String> {
let path = dir.join(format!("{name}.json"));
std::fs::write(&path, serde_json::to_string_pretty(v).unwrap_or_default())
.map_err(|e| format!("write {}: {e}", path.display()))
};
let mut red = 0u32;
for (slug, label) in crate::DWARVES_TABS {
let select = Scenario::new(format!("live/{label}")).step(
Step::new(format!("select the {label} tab"))
.action(Action::ClickLabel(label.to_string()))
.assert(Assert::State {
pointer: "/active_tab".to_string(),
expected: serde_json::json!(label),
}),
);
let rows = session.run_scenario(&select);
if rows.iter().any(|r| status::is_red(&r.status)) {
red += 1;
eprintln!(" RED {label}: the tab could not be selected");
continue;
}
session.run(3);
match LIVE_STOPS.iter().find(|s| s.tab == *label) {
None => write(slug, &session.state()[*slug])?,
Some(stop) => {
for (messages, file) in stop.reads {
for m in *messages {
Facet::update_json(&mut session.app_mut().0, m);
}
session.run(3);
write(file, &session.state()[stop.slug])?;
}
if stop.slug == "data-view" {
// click a node of the code room's call graph, so the source pane asks
let state = session.state();
let labels = state["data-view"]["graph_panes"]
.as_array()
.and_then(|g| g.iter().find(|p| p["title"] == crate::dataview::ROOM_CALLS))
.map(|p| p["labels"].clone())
.unwrap_or(serde_json::Value::Null);
let want = std::env::var("DWARVES_ROBOT_SELECT_NODE")
.ok()
.or_else(|| labels[0].as_str().map(str::to_string));
if let Some(node) = want {
Facet::update_json(
&mut session.app_mut().0,
&serde_json::json!({ "select_node": node }).to_string(),
);
session.run(3);
write(
"data-view-source",
&session.state()["data-view"]["graph_source"],
)?;
}
}
}
}
eprintln!(" dumped {label}");
}
eprintln!(
"dwarves-gui: robot-mode headless LIVE — {red} tab(s) red, {} tab(s) dumped",
crate::DWARVES_TABS.len() as u32 - red
);
Ok(red)
}