tests/saga_e2e.rs
//! **THE SAGA PROOF (end-to-end)** — the whole record → persist → replay →
//! verdict loop, driven through nornir's REAL viz with real AccessKit clicks.
//!
//! This closes the S4 blocker: nornir's own egui viz now *records* a user journey
//! (feature `saga`, at the tab nav/click choke in `app.rs`), the journey persists
//! to the `saga_events` warehouse table (the same sink the Test tab's ⏹ Stop
//! toggle drives), and the recorded session REPLAYS as a robot `Scenario` whose
//! steps are GREEN against a fresh app — and RED the instant a recorded widget
//! vanishes (the inject-assert LAW). One test = the proof saga works.
//!
//! Run: `cargo test --features "saga" --test saga_e2e`
#![cfg(all(feature = "viz", feature = "saga"))]
use std::path::PathBuf;
use egui_kittest::Harness;
use nornir::viz::UrdrThreadsApp;
use nornir::warehouse::iceberg::IcebergWarehouse;
use nornir::warehouse::saga_events::{
IcebergSagaSink, SagaSink, drain_saga_events, list_saga_sessions, session_replay_plan,
};
use nornir_robotui::egui;
use nornir_robotui::replay::{plan_to_scenario, plan_to_scenario_with, step_label};
use nornir_robotui::{Observable, RobotSession, status};
/// Newtype for the orphan rule (see `robot_viz.rs`): delegate `Observable` to the
/// viz's existing `state_json()`.
struct VizApp(UrdrThreadsApp);
impl Observable for VizApp {
fn state_json(&self) -> serde_json::Value {
self.0.state_json()
}
}
/// A fat (local) viz app over a fresh temp warehouse. Local mode so the saga
/// events persist to a real on-disk `saga_events` table.
fn app_on(warehouse: PathBuf) -> UrdrThreadsApp {
// Free the durable action-log lock so the warehouse writer/reader can open.
unsafe { std::env::set_var("NORNIR_VIZ_NO_DURABLE_ACTIONLOG", "1") };
let _ = std::fs::create_dir_all(&warehouse);
UrdrThreadsApp::with_repos(
warehouse,
"nornir".to_string(),
PathBuf::new(),
vec!["nornir".to_string()],
)
}
fn robot_over(app: UrdrThreadsApp) -> RobotSession<'static, VizApp> {
let harness = Harness::builder()
.with_size(egui::Vec2::new(1400.0, 900.0))
.build_ui_state(|ui, app: &mut VizApp| app.0.draw_ui(ui), VizApp(app));
RobotSession::new(harness, "nornir")
}
fn tmp(tag: &str) -> PathBuf {
std::env::temp_dir().join(format!("nornir-saga-e2e-{tag}-{}", std::process::id()))
}
#[test]
fn saga_records_a_journey_persists_replays_green_and_reds_on_a_broken_label() {
// ── 1. Drive a REAL journey through AccessKit header clicks ───────────────
let _ = drain_saga_events(); // clear any stale capture first
let mut robot = robot_over(app_on(tmp("rec")));
robot.run(4); // settle layout + dismiss the splash
for label in ["🧪 Test", "🗂 Funnel", "🧵 Timeline"] {
robot
.click_label(label)
.unwrap_or_else(|e| panic!("click {label}: {e}"));
robot.run(2);
}
// The viz recorded each tab switch into the process-global saga buffer.
let events = drain_saga_events();
assert!(
events.len() >= 3,
"each tab switch records a nav event (got {})",
events.len()
);
let session_id = events[0].session_id.clone();
assert!(!session_id.is_empty(), "recorded events carry a session id");
assert!(
events.iter().all(|e| e.session_id == session_id),
"one viz window is one saga session"
);
// The `component` is the header button's AccessKit label — so a replay's
// ClickLabel(component) re-drives the exact same widget.
assert!(
events.iter().any(|e| e.component == "🧪 Test"),
"component is the tab's AccessKit label, not its debug id"
);
// ── 2. Persist through the SAME sink the ⏹ Stop toggle uses; read it back ─
let wh_dir = tmp("rec");
let wh = IcebergWarehouse::open(&wh_dir).expect("open warehouse");
IcebergSagaSink::new(&wh)
.write(&events)
.expect("write saga_events");
let sessions = wh.block_on(list_saga_sessions(&wh)).expect("list sessions");
assert!(
sessions
.iter()
.any(|s| s.session_id == session_id && s.replayable),
"≥1 replayable session landed in the warehouse: {:?}",
sessions.iter().map(|s| &s.session_id).collect::<Vec<_>>()
);
// ── 3. Replay-as-verdict: recorded plan → Scenario → run vs a FRESH app ───
let plan = wh
.block_on(session_replay_plan(&wh, &session_id))
.expect("replay plan");
assert!(plan.steps.len() >= 3, "one replay step per recorded nav");
let scenario = plan_to_scenario(&plan);
let mut fresh = robot_over(app_on(tmp("green")));
fresh.run(4);
let rows = fresh.run_scenario(&scenario);
assert!(!rows.is_empty(), "the replay produced matrix rows");
for r in &rows {
assert_eq!(
r.status,
status::PASS,
"GREEN replay step {:?}: {}",
r.test_name,
r.message
);
}
// ── 4. Inject-assert LAW (RED): remap the first step's label to a widget ──
// that does not exist → its ClickLabel must RED (a real regression), never
// a panic.
let target_seq = plan.steps[0].source_seq;
let broken = plan_to_scenario_with(&plan, |st| {
if st.source_seq == target_seq {
"🛸 Nonexistent".to_string()
} else {
step_label(st).to_string()
}
});
let mut red = robot_over(app_on(tmp("red")));
red.run(4);
let red_rows = red.run_scenario(&broken);
assert!(
red_rows.iter().any(|r| r.status == status::FAIL),
"a vanished widget is a RED replay row (proves replay catches regressions)"
);
let _ = drain_saga_events(); // leave the buffer clean for any sibling
}