crates/nornir-saga/src/ui/driver.rs
//! **The `WebReplayDriver` seam** — "actually move the REAL web page".
//!
//! Replay through the [`Viewer`](crate::ui::Viewer) animates a *model* of the
//! page. To move the *real* page, each movement is turned into a concrete
//! [`DriveAction`] and dispatched at a live browser through a
//! [`WebReplayDriver`]. This module is driver-agnostic: it defines the seam plus
//! two dependency-free drivers —
//!
//! * [`NoopDriver`] — dispatches nothing (the safe default when no browser is
//! available; replay still runs, the page just isn't touched);
//! * [`MockDriver`] — records every dispatched [`DriveAction`] so a test can
//! assert the exact gesture sequence.
//!
//! A **real browser** driver (WebDriver/CDP) lives behind the off-by-default
//! `drive` feature ([`webdriver`](crate::ui::driver::webdriver)); the seam +
//! mock land first and are always tested. The cleanest real target — re-
//! injecting `Msg`s into a live Elm Njord — is a Njord-side follow-on (see the
//! spec) and is **not** built here.
//!
//! Two capture worlds reach this seam:
//! * the egui/facett [`UiMsg`] movement stream → [`drive_saga`] (a [`Saga`]);
//! * a **web app's** captured `SagaEvent` nav/click stream → [`ReplayPlan`] →
//! [`drive_plan`], via [`DriveAction::from_step`]. This is the path a
//! **Leptos+Axum Njord** uses: it records `nav`/`click` with `record()` (never
//! `UiMsg`s), so its warehouse sessions drive a real browser only through this
//! `ReplayPlan` bridge.
use crate::replay::{ReplayPlan, ReplayStep, StepKind};
use crate::ui::msg::{Saga, UiMsg};
/// A concrete gesture to dispatch at a live page, derived from a [`UiMsg`]. A
/// [`WebReplayDriver`] turns each into a real DOM / WebDriver action.
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum DriveAction {
/// Move the pointer to an absolute viewport position.
MoveTo { x: f64, y: f64 },
/// Click at a position, naming the target element (selector / id).
ClickAt { x: f64, y: f64, target: String },
/// Scroll the viewport to an offset.
ScrollTo { x: f64, y: f64 },
/// Focus a target element.
FocusOn { target: String },
/// Navigate to a route (relative to the page's base URL).
Navigate { route: String },
/// Type a value into a target field.
TypeInto { target: String, value: String },
/// Press a key.
Key { key: String },
}
impl DriveAction {
/// Map a movement to its gesture, or `None` for non-drivable movements
/// (a [`UiMsg::Note`] is annotation, not a page action).
pub fn from_msg(m: &UiMsg) -> Option<DriveAction> {
Some(match m {
UiMsg::PointerMove { x, y } => DriveAction::MoveTo { x: *x, y: *y },
UiMsg::Click { x, y, target } => {
DriveAction::ClickAt { x: *x, y: *y, target: target.clone() }
}
UiMsg::Scroll { x, y } => DriveAction::ScrollTo { x: *x, y: *y },
UiMsg::Focus { target } => DriveAction::FocusOn { target: target.clone() },
UiMsg::Nav { route } => DriveAction::Navigate { route: route.clone() },
UiMsg::FieldEdit { target, value } => {
DriveAction::TypeInto { target: target.clone(), value: value.clone() }
}
UiMsg::Key { key } => DriveAction::Key { key: key.clone() },
UiMsg::Note { .. } => return None,
})
}
/// Map a replay **step** — built from a captured [`SagaEvent`](crate::event::SagaEvent)
/// `nav`/`click` (the path a web app like Njord records via
/// [`record`](crate::record) → [`ReplayPlan`]) — to a concrete gesture.
///
/// This is the bridge from the toolkit-neutral [`ReplayPlan`] world to the
/// live [`WebReplayDriver`]: a session captured as `SagaEvent`s (NOT the egui
/// [`UiMsg`] movement stream) can now drive a real browser. Without it the
/// documented `ReplayStep[] → a robot driver` flow had no code path — the
/// WebDriver driver was only reachable from a [`Saga`] of [`UiMsg`]s.
///
/// * [`StepKind::Navigate`] → [`DriveAction::Navigate`] on the step's route.
/// * [`StepKind::Activate`] → [`DriveAction::ClickAt`] targeting the step's
/// `component` as a CSS selector. A captured nav/click carries no cursor
/// coords, so `x`/`y` are `0.0` and the driver locates by `target` (the
/// WebDriver driver prefers the selector over element-from-point). A
/// saga-capturing web app should therefore record a locator-shaped
/// `component` (e.g. `#save-offert`, `[data-saga="submit"]`).
pub fn from_step(step: &ReplayStep) -> DriveAction {
match step.kind {
StepKind::Navigate => DriveAction::Navigate { route: step.route.clone() },
StepKind::Activate => {
DriveAction::ClickAt { x: 0.0, y: 0.0, target: step.component.clone() }
}
}
}
}
/// The seam that moves a real page. An implementation dispatches each
/// [`DriveAction`] at a live browser/DOM. Object-safe so a caller can hold a
/// `Box<dyn WebReplayDriver>` and swap the mock/no-op for a real driver.
pub trait WebReplayDriver {
/// Called once before the first action — open/attach to the page at `url`.
fn open(&mut self, _url: &str) -> anyhow::Result<()> {
Ok(())
}
/// Dispatch one gesture at the live page.
fn dispatch(&mut self, action: &DriveAction) -> anyhow::Result<()>;
/// Called once after the last action.
fn finish(&mut self) -> anyhow::Result<()> {
Ok(())
}
}
/// Dispatches nothing — the safe default when no browser is drivable. Replay
/// still runs end-to-end; the page simply isn't touched.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopDriver;
impl WebReplayDriver for NoopDriver {
fn dispatch(&mut self, _action: &DriveAction) -> anyhow::Result<()> {
Ok(())
}
}
/// Records every dispatched [`DriveAction`] (and the opened URL) so a test can
/// assert the exact gesture sequence a replay produced.
#[derive(Debug, Default, Clone)]
pub struct MockDriver {
/// The URL passed to [`open`](WebReplayDriver::open), if any.
pub opened: Option<String>,
/// The gestures dispatched, in order.
pub actions: Vec<DriveAction>,
/// Whether [`finish`](WebReplayDriver::finish) was called.
pub finished: bool,
}
impl MockDriver {
/// A fresh recorder.
pub fn new() -> MockDriver {
MockDriver::default()
}
/// The recorded gestures.
pub fn actions(&self) -> &[DriveAction] {
&self.actions
}
}
impl WebReplayDriver for MockDriver {
fn open(&mut self, url: &str) -> anyhow::Result<()> {
self.opened = Some(url.to_string());
Ok(())
}
fn dispatch(&mut self, action: &DriveAction) -> anyhow::Result<()> {
self.actions.push(action.clone());
Ok(())
}
fn finish(&mut self) -> anyhow::Result<()> {
self.finished = true;
Ok(())
}
}
/// Drive a whole saga at a live page through `driver`: `open(url)`, dispatch
/// each drivable movement as a [`DriveAction`], then `finish()`. Returns the
/// number of gestures dispatched (non-drivable `Note`s are skipped). This is
/// what `nornir saga replay --drive <url>` calls — passing a real driver, the
/// mock, or the no-op.
///
/// Time-scaling/pacing is the caller's concern (it holds the runtime); this fn
/// dispatches the ordered gestures back-to-back so it stays sync + testable.
pub fn drive_saga(
saga: &Saga,
url: &str,
driver: &mut dyn WebReplayDriver,
) -> anyhow::Result<usize> {
driver.open(url)?;
let mut n = 0;
for ev in &saga.events {
if let Some(action) = DriveAction::from_msg(&ev.msg) {
driver.dispatch(&action)?;
n += 1;
}
}
driver.finish()?;
Ok(n)
}
/// Drive a whole [`ReplayPlan`] (a session captured as `SagaEvent`s → nav/click
/// [`ReplayStep`]s) at a live page through `driver` — the web counterpart of
/// [`drive_saga`], and the LAST MILE that turns a real user's recorded Njord
/// session into a robot test against the live UI: `open(url)`, dispatch each
/// step as a [`DriveAction`] (via [`DriveAction::from_step`]), then `finish()`.
/// Returns the number of gestures dispatched (== the plan's actionable step
/// count). This is what `nornir saga replay --plan <session> --drive <url>`
/// calls (a real WebDriver driver, the mock, or the no-op).
pub fn drive_plan(
plan: &ReplayPlan,
url: &str,
driver: &mut dyn WebReplayDriver,
) -> anyhow::Result<usize> {
driver.open(url)?;
for step in &plan.steps {
driver.dispatch(&DriveAction::from_step(step))?;
}
driver.finish()?;
Ok(plan.steps.len())
}
// ─── real-browser driver (off-by-default `drive` feature) ───────────────────
#[cfg(feature = "drive")]
pub mod webdriver;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drive_action_maps_every_drivable_msg_and_skips_notes() {
assert_eq!(
DriveAction::from_msg(&UiMsg::nav("/x")),
Some(DriveAction::Navigate { route: "/x".into() })
);
assert_eq!(
DriveAction::from_msg(&UiMsg::click(1.0, 2.0, "#b")),
Some(DriveAction::ClickAt { x: 1.0, y: 2.0, target: "#b".into() })
);
assert_eq!(DriveAction::from_msg(&UiMsg::note("hi")), None, "notes are not gestures");
}
#[test]
fn mock_driver_records_the_expected_dispatched_actions() {
let saga = Saga::new("t")
.push(UiMsg::nav("/shop").at(0))
.push(UiMsg::note("skip me").at(10))
.push(UiMsg::pointer_move(5.0, 6.0).at(20))
.push(UiMsg::click(5.0, 6.0, "#buy").at(30));
let mut mock = MockDriver::new();
let n = drive_saga(&saga, "http://localhost:8080", &mut mock).unwrap();
assert_eq!(n, 3, "3 drivable movements (the note is skipped)");
assert_eq!(mock.opened.as_deref(), Some("http://localhost:8080"));
assert!(mock.finished, "finish() was called");
assert_eq!(
mock.actions(),
&[
DriveAction::Navigate { route: "/shop".into() },
DriveAction::MoveTo { x: 5.0, y: 6.0 },
DriveAction::ClickAt { x: 5.0, y: 6.0, target: "#buy".into() },
]
);
}
#[test]
fn noop_driver_runs_without_touching_anything() {
let saga = Saga::new("t").push(UiMsg::nav("/x").at(0));
let mut noop = NoopDriver;
assert_eq!(drive_saga(&saga, "http://x", &mut noop).unwrap(), 1);
}
#[test]
fn drive_action_maps_a_replay_step_nav_and_activate() {
use crate::event::{SagaEvent, SessionCtx};
let ctx = SessionCtx::new("s1", "u", "njord", "1.0", "sha");
// A nav step and a click step (as Njord captures them).
let events = vec![
SagaEvent::nav("/offert/7", "OffertView", "open", r#"{"id":7}"#).in_session(&ctx),
SagaEvent::click("/offert/7", "#save-offert", "submit", r#"{"ok":true}"#)
.in_session(&ctx),
];
let plan = ReplayPlan::for_session("s1", &events);
assert_eq!(
DriveAction::from_step(&plan.steps[0]),
DriveAction::Navigate { route: "/offert/7".into() },
"nav step → Navigate on its route"
);
assert_eq!(
DriveAction::from_step(&plan.steps[1]),
DriveAction::ClickAt { x: 0.0, y: 0.0, target: "#save-offert".into() },
"click step → ClickAt targeting the component selector, no coords"
);
}
#[test]
fn drive_plan_drives_a_captured_session_end_to_end() {
use crate::event::{SagaEvent, SessionCtx};
let ctx = SessionCtx::new("sess-42", "bob", "njord", "1.0", "sha");
// A realistic recorded Njord journey: nav → click → nav, with context
// events (log/error/state) that must NOT become drivable gestures.
let events = vec![
SagaEvent::nav("/", "Home", "open", "{}").in_session(&ctx),
SagaEvent::log("info", "loaded").in_session(&ctx),
SagaEvent::click("/", "#new-offert", "activate", "{}").in_session(&ctx),
SagaEvent::error("OffertForm", "boom").in_session(&ctx),
SagaEvent::nav("/offert/new", "OffertForm", "open", "{}").in_session(&ctx),
];
let plan = ReplayPlan::for_session("sess-42", &events);
let mut mock = MockDriver::new();
let n = drive_plan(&plan, "http://localhost:3000", &mut mock).unwrap();
assert_eq!(n, 3, "3 actionable steps drove (log/error/state skipped)");
assert_eq!(mock.opened.as_deref(), Some("http://localhost:3000"));
assert!(mock.finished, "finish() was called");
assert_eq!(
mock.actions(),
&[
DriveAction::Navigate { route: "/".into() },
DriveAction::ClickAt { x: 0.0, y: 0.0, target: "#new-offert".into() },
DriveAction::Navigate { route: "/offert/new".into() },
]
);
}
}