src/viz/saga_replay.rs
//! **`saga-replay` (egui)** — route a recorded saga `ReplayPlan` through the
//! nornir viz so it replays either HEADLESS or **VISIBLY**, the egui sibling of
//! the WEB path (`crate::robot::replay_plan_to_web_scenario`).
//!
//! ## Why this exists
//! A recorded session ([`nornir_saga::replay::ReplayPlan`]) already had ONE web
//! converter (`replay_plan_to_web_scenario` → a WebDriver `Scenario`) — so "saga
//! replays on web". The egui-visible half was never wired: the shared
//! [`nornir_robotui`] crate ALREADY ships both a plan→egui-`Scenario` converter
//! ([`nornir_robotui::replay::plan_to_scenario`]) AND the two-mode runner
//! ([`nornir_robotui::run_scenario_mode`], headless [`RunMode::Headless`] +
//! visible [`RunMode::Watch`]), but nornir never routed the viz through it. This
//! module is that routing.
//!
//! ## The SAME shared `Scenario` drives BOTH surfaces
//! * WEB — `crate::robot::replay_plan_to_web_scenario(&plan)` → WebDriver drive.
//! * EGUI — `nornir_robotui::replay::plan_to_scenario(&plan)` → THIS runner, in
//! the live nornir viz, headless (matrix) or visible (a real eframe window).
//!
//! Both start from the identical [`ReplayPlan`], so one recorded journey now
//! replays on web AND on egui.
//!
//! ## The viz glue
//! nornir's viz is a **Ui-level** app (`UrdrThreadsApp::draw_ui(&mut egui::Ui)`
//! lays out top/side/central panels on the ctx), while `run_scenario_mode` hands
//! the consumer a `&mut egui::Ui`. The one-liner — `app.draw_ui(ui)`
//! — is sound in BOTH backends: kittest's `new_ui_state` and eframe's `App::ui`
//! each give a background root `Ui` (neither pre-allocates a `CentralPanel`), so
//! the viz's own `CentralPanel::show(ctx)` / `TopBottomPanel::show(ctx)` claim the
//! full viewport exactly as in the live window and in `tests/robot_viz.rs` (which
//! drives the same viz via the ctx form). The `Observable` oracle is the viz's
//! own `state_json()` — no twinned oracle (mirrors `viz::robot_remote`).
use anyhow::Result;
use nornir_robotui::egui;
use nornir_robotui::{
Observable, RunMode, RunOutcome, Scenario, Targets, WatchConfig, run_scenario_mode,
};
use nornir_saga::replay::ReplayPlan;
use crate::viz::UrdrThreadsApp;
/// The viz shell wrapped as an [`Observable`] — the robot reads exactly what the
/// shell renders through the SAME `state_json()` the reference test
/// (`tests/robot_viz.rs`) and the wire-remote mount (`viz::robot_remote`) use (no
/// twinned oracle).
struct VizApp(UrdrThreadsApp);
impl Observable for VizApp {
fn state_json(&self) -> serde_json::Value {
self.0.state_json()
}
}
/// Build an OFFLINE-SAFE viz app to replay against (mirrors
/// `viz::robot_remote::robot_session`): a throwaway warehouse root, no server
/// endpoint, the `nornir` repo only, and the durable action-log sink skipped so
/// its drain thread doesn't hold the catalog lock for the app's lifetime.
fn viz_app() -> VizApp {
// Single-threaded replay mount — setting a process global is safe here.
// TODO: Audit that the environment access only happens in single-threaded code.
unsafe { std::env::set_var("NORNIR_VIZ_NO_DURABLE_ACTIONLOG", "1") };
let warehouse_root =
std::env::temp_dir().join(format!("nornir-saga-egui-{}", std::process::id()));
let _ = std::fs::create_dir_all(&warehouse_root);
let mut app = UrdrThreadsApp::with_repos(
warehouse_root,
"nornir".to_string(),
std::path::PathBuf::new(),
vec!["nornir".to_string()],
);
// Age `started_at` past the opaque intro splash so the tab bar is hit-testable.
//
// ⚠ Same dead call, same story as `crate::viz::robot_remote`: this was
// `app.inject_test_results_for_test(Vec::new())`, an injector `ce553b9` deleted
// with the 🧪 Test tab. A hard E0599 that sat on origin/main because `saga-egui`,
// like `robot-remote`, is in no default feature set and no CI command built it —
// so the whole module had not been compiled since. Two copies of one break, both
// invisible for the same reason.
app.age_past_splash_for_test();
VizApp(app)
}
/// The draw closure: one `draw_ui(ui)` renders the WHOLE viz (28 tabs) fullscreen in
/// either backend. There is no Ui→ctx bridge any more — egui 0.35 made `Panel::show`
/// take a `&mut Ui`, so the viz lays its panels out on the Ui it is handed. `Targets` is unused here — headless resolves widgets via the
/// AccessKit tree; the visible Watch cursor dwells on labels the viz doesn't
/// record a rect for (see the module TODO).
fn draw(ui: &mut egui::Ui, app: &mut VizApp, _targets: &mut Targets) {
app.0.draw_ui(ui);
}
/// Pick the run mode. Priority: an explicit `Some(mode)` from the caller (a CLI
/// flag) wins; else the `NORNIR_ROBOT_MODE` env (`watch`/`visible`/`on`/`1` →
/// [`RunMode::Watch`]; `headless`/`off`/`0` → [`RunMode::Headless`]); else the
/// shared `ROBOTUI_WATCH` toggle ([`RunMode::from_env`]); default headless.
pub fn resolve_mode(explicit: Option<RunMode>) -> RunMode {
if let Some(m) = explicit {
return m;
}
match std::env::var("NORNIR_ROBOT_MODE") {
Ok(v) => match v.trim().to_ascii_lowercase().as_str() {
"watch" | "visible" | "visual" | "gui" | "on" | "yes" | "1" | "true" => RunMode::Watch,
"headless" | "off" | "no" | "0" | "false" | "ci" => RunMode::Headless,
// Unknown value: fall through to the shared toggle.
_ => RunMode::from_env(),
},
Err(_) => RunMode::from_env(),
}
}
/// Convert a recorded [`ReplayPlan`] into the SHARED egui [`Scenario`] — the egui
/// mirror of `crate::robot::replay_plan_to_web_scenario`. Kept as a named function
/// so the (identical) starting point of the web and egui paths is obvious.
pub fn replay_plan_to_egui_scenario(plan: &ReplayPlan) -> Scenario {
nornir_robotui::replay::plan_to_scenario(plan)
}
/// **Route a saga `ReplayPlan` through the nornir viz** in the chosen mode — the
/// egui answer to "saga replays on web". Converts the plan to the shared
/// `Scenario`, then drives the live viz via [`run_scenario_mode`]:
///
/// * [`RunMode::Headless`] — off-screen egui_kittest drive → [`RunOutcome::Headless`]
/// matrix rows (CI; widgets resolved via AccessKit).
/// * [`RunMode::Watch`] — a REAL, visible eframe window that replays the steps
/// paced so a human WATCHES the viz, returning [`RunOutcome::Watched`].
///
/// Must be called on the MAIN thread (Watch runs winit's native event loop).
pub fn run_saga_plan_in_viz(plan: &ReplayPlan, mode: RunMode) -> Result<RunOutcome> {
let scenario = replay_plan_to_egui_scenario(plan);
run_saga_scenario_in_viz(&scenario, mode)
}
/// Like [`run_saga_plan_in_viz`] but takes the already-converted shared
/// [`Scenario`] — the exact `run_scenario_mode(<viz Observable>, <viz draw>, …)`
/// call the task asks for. Uses `nornir` as the repo tag and
/// [`WatchConfig::from_env`] for the visible pacing knobs.
pub fn run_saga_scenario_in_viz(scenario: &Scenario, mode: RunMode) -> Result<RunOutcome> {
run_scenario_mode(
viz_app(),
draw,
scenario,
"nornir",
mode,
WatchConfig::from_env().with_title("nornir viz — saga replay"),
)
}
/// A built-in synthetic [`ReplayPlan`] so the egui replay is demoable with NO
/// recorded `saga_events` in the warehouse (the `--demo` path). It navigates a
/// handful of REAL viz tab labels so the HEADLESS drive's `ClickLabel` actions
/// resolve against the live AccessKit tree (the same labels
/// `tests/robot_viz.rs` clicks).
pub fn demo_plan() -> ReplayPlan {
use nornir_saga::event::{SagaEvent, SessionCtx};
let ctx = SessionCtx::new("saga-egui-demo", "alice", "nornir", "1.0", "sha");
// component == the AccessKit label the tab renders (what plan_to_scenario
// turns into `Action::ClickLabel`). These are real nornir-viz tab headers.
let labels = [
"🧬 nornir",
"🏛 Arch",
"📚 Knowledge",
"🚀 Release",
"🧪 Test",
];
let mut events = Vec::new();
for (i, label) in labels.iter().enumerate() {
let mut ev = SagaEvent::nav("/", label, "open", "{}").in_session(&ctx);
ev.seq = i as u64;
events.push(ev);
}
ReplayPlan::for_session("saga-egui-demo", &events)
}
#[cfg(test)]
mod tests {
use super::*;
/// The SAME `ReplayPlan` that drives the web converter also converts to the
/// shared egui `Scenario` — one nav step becomes one `ClickLabel` step, so the
/// egui and web paths start from identical data (the whole point).
#[test]
fn demo_plan_converts_to_shared_egui_scenario() {
let plan = demo_plan();
let scenario = replay_plan_to_egui_scenario(&plan);
assert_eq!(scenario.suite, "saga-replay:saga-egui-demo");
assert_eq!(scenario.steps.len(), 5, "one egui step per recorded nav");
// And the SAME plan feeds the WEB converter (proves one plan → both surfaces).
let web = crate::robot::replay_plan_to_web_scenario(&plan);
assert_eq!(web.name, "saga-replay:saga-egui-demo");
}
#[test]
fn resolve_mode_reads_env_and_explicit() {
assert_eq!(resolve_mode(Some(RunMode::Watch)), RunMode::Watch);
assert_eq!(resolve_mode(Some(RunMode::Headless)), RunMode::Headless);
// Env-driven (guarded: these mutate process env, so keep them serial-safe
// by asserting immediately after each set).
unsafe { std::env::set_var("NORNIR_ROBOT_MODE", "visible") };
assert_eq!(resolve_mode(None), RunMode::Watch);
unsafe { std::env::set_var("NORNIR_ROBOT_MODE", "headless") };
assert_eq!(resolve_mode(None), RunMode::Headless);
unsafe { std::env::remove_var("NORNIR_ROBOT_MODE") };
}
}