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

nordisk/dwarves

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