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

nordisk/edda

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() },
            ]
        );
    }
}