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

nordisk/nornir

tests/saga_roundtrip.rs

//! **EPIC SAGA / task #25** — end-to-end round-trip of the record/replay
//! foundation through nornir's REAL warehouse + the egui replay driver:
//!
//!   synthesize SagaEvents → `IcebergSagaSink` (a tempdir warehouse) → read back
//!   → `ReplayPlan` → assert the `ReplayStep`s match the recorded journey → turn
//!   the plan into a robot `Scenario` and drive a tiny egui app → assert each
//!   replay step filled a matrix row (a recorded session became a robot test).
//!
//! This proves the seam the design promises: a recorded user session replays as a
//! deterministic robot UI test whose failures are RED matrix rows.

use egui_kittest::Harness;
use nornir::warehouse::iceberg::IcebergWarehouse;
use nornir::warehouse::saga_events::{
    EventKind, IcebergSagaSink, SagaEvent, SagaSelector, SagaSink, SessionCtx, StepKind,
    append_saga_events, query_saga_events, session_replay_plan,
};
use nornir_robotui::replay::plan_to_scenario;
use nornir_robotui::{Observable, RobotSession, status};
use serde_json::{Value, json};

/// A tiny egui app whose two buttons carry the SAME AccessKit labels the recorded
/// session's components use ("OffertForm", "Submit"), so the replayed scenario's
/// ClickLabel steps drive real widgets. `state_json` exposes the click count.
#[derive(Default)]
struct Journey {
    opened: u32,
    submitted: u32,
}

impl Observable for Journey {
    fn state_json(&self) -> Value {
        json!({ "opened": self.opened, "submitted": self.submitted })
    }
}

fn ctx() -> SessionCtx {
    SessionCtx::new("sess-rt", "alice", "njord", "0.9.0", "abc123")
}

/// The recorded journey: open the form (nav), a log line (noise), submit (click).
fn recorded_session() -> Vec<SagaEvent> {
    let c = ctx();
    let mut nav = SagaEvent::nav("/offert/7", "OffertForm", "open", r#"{"id":7}"#).in_session(&c);
    let mut log = SagaEvent::log("info", "loaded form").in_session(&c);
    let mut click =
        SagaEvent::click("/offert/7", "Submit", "submit", r#"{"ok":true}"#).in_session(&c);
    nav.seq = 0;
    nav.ts_micros = 100;
    log.seq = 1;
    log.ts_micros = 101;
    click.seq = 2;
    click.ts_micros = 102;
    vec![nav, log, click]
}

#[test]
fn record_sink_readback_replay_match_round_trip() {
    let dir = tempfile::tempdir().unwrap();
    let wh = IcebergWarehouse::open(dir.path()).unwrap();

    // 1. SINK the synthesized events through the warehouse-backed SagaSink trait.
    let events = recorded_session();
    let sink = IcebergSagaSink::new(&wh);
    sink.write(&events).expect("sink the recorded session");

    // 2. READ BACK — same events, sorted by (session, seq), payload intact.
    let back = wh
        .block_on(query_saga_events(
            &wh,
            &SagaSelector::Session("sess-rt".into()),
        ))
        .expect("read the session back");
    assert_eq!(back.len(), 3, "all three captured events round-tripped");
    assert!(
        back.windows(2).all(|w| w[0].seq < w[1].seq),
        "sorted by seq"
    );
    let click = back.iter().find(|e| e.component == "Submit").unwrap();
    assert_eq!(click.kind, EventKind::Click);
    assert_eq!(
        click.state_json, r#"{"ok":true}"#,
        "PII-free state round-trips exactly"
    );

    // 3. REPLAY PLAN — only the actionable (nav/click) events become steps.
    let plan = wh
        .block_on(session_replay_plan(&wh, "sess-rt"))
        .expect("build the replay plan");
    assert_eq!(plan.steps.len(), 2, "the log line is context, not a step");
    assert_eq!(plan.app, "njord");
    assert_eq!(plan.steps[0].kind, StepKind::Navigate);
    assert_eq!(plan.steps[0].component, "OffertForm");
    assert_eq!(plan.steps[1].kind, StepKind::Activate);
    assert_eq!(plan.steps[1].component, "Submit");
    assert_eq!(
        plan.steps[1].source_seq, 2,
        "the step names its recorded moment"
    );
}

#[test]
fn replay_plan_drives_egui_and_fills_the_matrix() {
    let dir = tempfile::tempdir().unwrap();
    let wh = IcebergWarehouse::open(dir.path()).unwrap();
    wh.block_on(append_saga_events(&wh, &recorded_session()))
        .unwrap();
    let plan = wh.block_on(session_replay_plan(&wh, "sess-rt")).unwrap();

    // The replay plan → a robot Scenario (the egui driver, nornir-robotui).
    let scenario = plan_to_scenario(&plan);
    assert_eq!(scenario.suite, "saga-replay:sess-rt");
    assert_eq!(
        scenario.steps.len(),
        2,
        "one robot step per replayable event"
    );

    // Drive the SAME-labelled egui app and fill the matrix.
    let harness = Harness::new_ui_state(
        |ui, app: &mut Journey| {
            if ui.button("OffertForm").clicked() {
                app.opened += 1;
            }
            if ui.button("Submit").clicked() {
                app.submitted += 1;
            }
        },
        Journey::default(),
    );
    let mut robot = RobotSession::new(harness, "saga-replay");
    let rows = robot.run_scenario(&scenario);

    // Each replay step became a matrix row, all green (the journey reproduced).
    assert_eq!(
        rows.len(),
        2,
        "the recorded session is now a 2-row matrix run"
    );
    assert!(rows.iter().all(|r| r.suite == "saga-replay:sess-rt"));
    assert!(rows.iter().all(|r| r.aspect == "robot-ui"));
    let run_id = &rows[0].run_id;
    assert!(rows.iter().all(|r| &r.run_id == run_id), "one matrix run");
    for r in &rows {
        assert_eq!(
            r.status,
            status::PASS,
            "step {:?} replayed: {}",
            r.test_name,
            r.message
        );
    }
    // The replay actually drove the real widgets.
    assert_eq!(
        robot.state_at("/opened"),
        Some(json!(1)),
        "the nav step clicked OffertForm"
    );
    assert_eq!(
        robot.state_at("/submitted"),
        Some(json!(1)),
        "the click step clicked Submit"
    );
}

/// A replay step whose target widget is GONE → a RED matrix row (the regression a
/// recorded session catches). Drive the same plan against an app missing the
/// Submit button: the activate step fails, proving a broken replay is red, not a panic.
#[test]
fn replay_against_a_regressed_app_yields_a_red_matrix_row() {
    let dir = tempfile::tempdir().unwrap();
    let wh = IcebergWarehouse::open(dir.path()).unwrap();
    wh.block_on(append_saga_events(&wh, &recorded_session()))
        .unwrap();
    let plan = wh.block_on(session_replay_plan(&wh, "sess-rt")).unwrap();
    let scenario = plan_to_scenario(&plan);

    // The app LOST the "Submit" button (a regression) — only OffertForm remains.
    let harness = Harness::new_ui_state(
        |ui, app: &mut Journey| {
            if ui.button("OffertForm").clicked() {
                app.opened += 1;
            }
        },
        Journey::default(),
    );
    let mut robot = RobotSession::new(harness, "saga-replay");
    let rows = robot.run_scenario(&scenario);

    assert_eq!(rows.len(), 2);
    assert_eq!(rows[0].status, status::PASS, "the nav step still works");
    assert_eq!(
        rows[1].status,
        status::FAIL,
        "the activate step is RED — the recorded journey caught the missing Submit button"
    );
    assert!(
        rows[1].message.contains("Submit"),
        "the red row names the vanished widget: {}",
        rows[1].message
    );
}