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

nordisk/nornir

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
}