crates/nornir-saga/src/replay.rs
//! The **replay plan** — turn an ordered stream of captured [`SagaEvent`]s into a
//! toolkit-neutral [`ReplayStep`] sequence the matrix drives as a robot test.
//!
//! A recorded session is a noisy stream: `nav`/`click` events interleaved with
//! `log`/`error`/`state` context. Replay keeps only the **actionable** events
//! ([`EventKind::is_actionable`] — `Nav` + `Click`), sorts them by capture `seq`
//! (so ordering is independent of wall-clock skew), and maps each to a
//! [`ReplayStep`]: a `Navigate` (a route change) or an `Activate` (a click on a
//! component). Each step also carries the `expect_state` JSON the original event
//! observed, so a driver can assert "after this step the app shows what the user
//! saw" — the inject-assert LAW applied to a replay.
//!
//! This module is **driver-agnostic**: it produces plain data. The egui driver
//! (kittest / `nornir-robotui`) and the web driver (WebDriver / fantoccini) each
//! consume the SAME [`ReplayPlan`]; that split lives outside this lean crate.
use serde::{Deserialize, Serialize};
use crate::event::{EventKind, SagaEvent};
/// One toolkit-neutral step of a replay. Built from an actionable [`SagaEvent`];
/// a driver maps it to a concrete gesture (egui AccessKit click / WebDriver
/// `goto`+`click`). Carries the route + component + action so the driver can
/// locate the target, plus the `expect_state` the original session observed so
/// the step can be asserted (not just performed).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ReplayStep {
/// What gesture this step is.
pub kind: StepKind,
/// The route / view the step targets (e.g. `/offert/123` or `GraphView`).
pub route: String,
/// The component the step targets (e.g. `OffertForm`, `🧪 Test`).
pub component: String,
/// The action label (e.g. `submit`, `open`, `select_row`) — the verb a
/// driver matches a widget/locator against.
pub action: String,
/// The readable state JSON the ORIGINAL session observed at this point. A
/// driver asserts the replayed app reaches the same state (empty = nothing
/// to assert, just perform).
pub expect_state: String,
/// The capture `seq` of the source event — preserves the original order and
/// lets a failed step name the exact recorded moment it diverged from.
pub source_seq: u64,
}
/// Which gesture a [`ReplayStep`] is — the toolkit-neutral verb set. `Nav` →
/// `Navigate`, `Click` → `Activate`; logs/errors/state are context and never
/// become steps.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StepKind {
/// Go to a route / view (the backbone of replay).
Navigate,
/// Activate (click) a component.
Activate,
}
impl StepKind {
/// Stable lowercase label.
pub fn as_str(self) -> &'static str {
match self {
StepKind::Navigate => "navigate",
StepKind::Activate => "activate",
}
}
}
impl ReplayStep {
/// Build a step from an actionable event, or `None` if the event is context
/// (`Log`/`Error`/`State`).
pub fn from_event(e: &SagaEvent) -> Option<ReplayStep> {
let kind = match e.kind {
EventKind::Nav => StepKind::Navigate,
EventKind::Click => StepKind::Activate,
_ => return None,
};
Some(ReplayStep {
kind,
route: e.route.clone(),
component: e.component.clone(),
action: e.action.clone(),
expect_state: e.state_json.clone(),
source_seq: e.seq,
})
}
}
/// A whole session's replay: its `session_id` + the ordered actionable steps.
/// `nornir-saga`'s contribution stops here (plain data); a driver turns the
/// steps into a robot test, and a failed step is a RED matrix row.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub struct ReplayPlan {
/// The session these steps were recorded under.
pub session_id: String,
/// The app the session was recorded against (e.g. `njord`, `facett`).
pub app: String,
/// The actionable steps, in capture order.
pub steps: Vec<ReplayStep>,
}
impl ReplayPlan {
/// Build the replay plan for ONE session out of a (possibly mixed-session,
/// unordered) slice of events: filter to `session_id`, sort by capture
/// `seq`, keep only actionable events, map each to a [`ReplayStep`].
pub fn for_session(session_id: &str, events: &[SagaEvent]) -> ReplayPlan {
let mut mine: Vec<&SagaEvent> =
events.iter().filter(|e| e.session_id == session_id).collect();
mine.sort_by_key(|e| e.seq);
let app = mine.first().map(|e| e.app.clone()).unwrap_or_default();
let steps = mine.iter().filter_map(|e| ReplayStep::from_event(e)).collect();
ReplayPlan {
session_id: session_id.to_string(),
app,
steps,
}
}
/// Build replay plans for EVERY session present in `events`, one plan per
/// distinct `session_id`, ordered by each session's first capture `seq` (so
/// the oldest session is first). Empty plans (a session with no actionable
/// events) are kept — a session that only logged is still a real session.
pub fn all_sessions(events: &[SagaEvent]) -> Vec<ReplayPlan> {
// First-seen order of session ids by minimum seq.
let mut order: Vec<(u64, String)> = Vec::new();
for e in events {
if !order.iter().any(|(_, id)| id == &e.session_id) {
let min_seq = events
.iter()
.filter(|x| x.session_id == e.session_id)
.map(|x| x.seq)
.min()
.unwrap_or(e.seq);
order.push((min_seq, e.session_id.clone()));
}
}
order.sort_by_key(|(seq, _)| *seq);
order
.into_iter()
.map(|(_, id)| ReplayPlan::for_session(&id, events))
.collect()
}
/// Is this plan replayable (has at least one actionable step)?
pub fn is_replayable(&self) -> bool {
!self.steps.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::SessionCtx;
fn seq(mut e: SagaEvent, n: u64) -> SagaEvent {
e.seq = n;
e
}
#[test]
fn only_nav_and_click_become_steps() {
let ctx = SessionCtx::new("s1", "u", "njord", "1.0", "sha");
let events = vec![
seq(SagaEvent::nav("/a", "A", "open", r#"{"v":1}"#).in_session(&ctx), 0),
seq(SagaEvent::log("info", "noise").in_session(&ctx), 1),
seq(SagaEvent::click("/a", "Btn", "submit", r#"{"v":2}"#).in_session(&ctx), 2),
seq(SagaEvent::error("A", "boom").in_session(&ctx), 3),
seq(SagaEvent::state("A", r#"{"v":3}"#).in_session(&ctx), 4),
];
let plan = ReplayPlan::for_session("s1", &events);
assert_eq!(plan.steps.len(), 2, "only the nav + click are steps");
assert_eq!(plan.app, "njord");
assert_eq!(plan.steps[0].kind, StepKind::Navigate);
assert_eq!(plan.steps[0].route, "/a");
assert_eq!(plan.steps[0].expect_state, r#"{"v":1}"#);
assert_eq!(plan.steps[1].kind, StepKind::Activate);
assert_eq!(plan.steps[1].component, "Btn");
assert_eq!(plan.steps[1].action, "submit");
assert!(plan.is_replayable());
}
#[test]
fn steps_are_sorted_by_capture_seq_not_input_order() {
let ctx = SessionCtx::new("s1", "u", "njord", "1.0", "sha");
// Fed out of order; replay must re-sort by seq.
let events = vec![
seq(SagaEvent::click("/a", "Two", "x", "{}").in_session(&ctx), 2),
seq(SagaEvent::nav("/a", "Zero", "open", "{}").in_session(&ctx), 0),
seq(SagaEvent::click("/a", "One", "y", "{}").in_session(&ctx), 1),
];
let plan = ReplayPlan::for_session("s1", &events);
let comps: Vec<&str> = plan.steps.iter().map(|s| s.component.as_str()).collect();
assert_eq!(comps, vec!["Zero", "One", "Two"], "re-sorted by seq");
// source_seq preserved so a failed step names the recorded moment.
assert_eq!(plan.steps[0].source_seq, 0);
assert_eq!(plan.steps[2].source_seq, 2);
}
#[test]
fn for_session_scopes_to_one_session() {
let a = SessionCtx::new("sA", "u", "njord", "1.0", "sha");
let b = SessionCtx::new("sB", "u", "njord", "1.0", "sha");
let events = vec![
seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&a), 0),
seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&b), 1),
seq(SagaEvent::click("/a", "A2", "go", "{}").in_session(&a), 2),
];
let plan = ReplayPlan::for_session("sA", &events);
assert_eq!(plan.steps.len(), 2, "only session A's events");
assert!(plan.steps.iter().all(|s| s.route == "/a"));
}
#[test]
fn all_sessions_splits_and_orders_by_first_seq() {
let a = SessionCtx::new("sA", "u", "njord", "1.0", "sha");
let b = SessionCtx::new("sB", "u", "njord", "1.0", "sha");
let events = vec![
seq(SagaEvent::nav("/b", "B", "open", "{}").in_session(&b), 5),
seq(SagaEvent::nav("/a", "A", "open", "{}").in_session(&a), 1),
seq(SagaEvent::click("/b", "B2", "x", "{}").in_session(&b), 6),
];
let plans = ReplayPlan::all_sessions(&events);
assert_eq!(plans.len(), 2);
// sA started at seq 1, sB at seq 5 → sA first.
assert_eq!(plans[0].session_id, "sA");
assert_eq!(plans[1].session_id, "sB");
assert_eq!(plans[1].steps.len(), 2, "sB has two actionable events");
}
#[test]
fn plan_json_roundtrips() {
let ctx = SessionCtx::new("s1", "u", "njord", "1.0", "sha");
let events = vec![seq(
SagaEvent::nav("/a", "A", "open", r#"{"v":1}"#).in_session(&ctx),
0,
)];
let plan = ReplayPlan::for_session("s1", &events);
let json = serde_json::to_string(&plan).expect("serialize");
let back: ReplayPlan = serde_json::from_str(&json).expect("deserialize");
assert_eq!(back, plan, "ReplayPlan survives a JSON roundtrip");
}
}