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/njord.rs

//! **Njord-support replay primitives** (feature `web`, OFF by default).
//!
//! A PII-redacted web replay (Njord first, then the suite) needs three things the
//! lean driver seam ([`crate::ui::driver`]) deliberately does not carry, so the
//! non-web build links none of them:
//!
//! 1. **A DOM query/assert surface** ([`DomAssertDriver`] / [`DomAssert`] /
//!    [`DomSnapshot`]) so a replay *verifies* the page (element present / text /
//!    value), not just *performs* gestures — Njord can then drop its private
//!    fantoccini client (saga-design §Njord #1).
//! 2. **An input-injection seam** ([`RedactedInputProvider`] / [`RedactedInputs`])
//!    for the values a redacted tape cannot carry: form field values + the emailed
//!    verification code (`$SAGA_TEST_CODE` / a [`MailCapture`] hook). The tape
//!    already models the edit as [`UiMsg::FieldEdit`]; [`inject_inputs`] rewrites
//!    those with the caller-supplied secrets before driving (saga-design §Njord #2).
//! 3. **The blessed `data-saga` / `data-saga-field` locator convention**
//!    ([`SAGA_ATTR`] / [`SAGA_FIELD_ATTR`] + [`saga_selector`] /
//!    [`saga_field_selector`]) so every suite app tags controls consistently and
//!    injected values target the right widget (saga-design §Njord #3).
//!
//! Feature-gated HARD behind `web`: an egui app, the lean core, and Njord's own
//! build (whose axum 0.7 clashes with saga's axum 0.8 — never enable this there)
//! link nothing here. Njord's own integration is a host follow-up; this is the
//! saga-side capability it consumes.

use std::collections::BTreeMap;

use anyhow::Result;

use crate::ui::driver::{DriveAction, WebReplayDriver};
use crate::ui::msg::{Saga, TimedUiMsg, UiMsg};

// ─── the blessed locator convention (data-saga / data-saga-field) ────────────

/// The activation-locator attribute: `data-saga="submit"` tags a control the tape
/// records + drives by name (matches Njord's delegated `[data-saga]` click seam).
pub const SAGA_ATTR: &str = "data-saga";

/// The **field**-locator attribute: `data-saga-field="email"` tags a form input so
/// an injected [`UiMsg::FieldEdit`] value lands in the right widget (saga-design
/// §Njord #3 — dedicated field locators, distinct from activation locators).
pub const SAGA_FIELD_ATTR: &str = "data-saga-field";

/// The CSS selector for a `data-saga` activation locator (`[data-saga="name"]`).
pub fn saga_selector(name: &str) -> String {
    format!("[{SAGA_ATTR}=\"{name}\"]")
}

/// The CSS selector for a `data-saga-field` form-field locator
/// (`[data-saga-field="field"]`).
pub fn saga_field_selector(field: &str) -> String {
    format!("[{SAGA_FIELD_ATTR}=\"{field}\"]")
}

// ─── DOM query / assert surface ──────────────────────────────────────────────

/// The observed state of one queried element — what a [`DomAssertDriver::query`]
/// reads off the live page so a [`DomAssert`] can be checked.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct DomSnapshot {
    /// The selector this snapshot answers.
    pub selector: String,
    /// Whether an element matching the selector exists.
    pub present: bool,
    /// The element's text content, when present + readable.
    pub text: Option<String>,
    /// The element's `value` (form inputs), when present + readable.
    pub value: Option<String>,
}

impl DomSnapshot {
    /// A snapshot for a selector that matched nothing.
    pub fn absent(selector: &str) -> DomSnapshot {
        DomSnapshot { selector: selector.to_string(), present: false, text: None, value: None }
    }

    /// Does the live element satisfy `assertion`?
    pub fn satisfies(&self, assertion: &DomAssert) -> bool {
        match assertion {
            DomAssert::Present { .. } => self.present,
            DomAssert::Absent { .. } => !self.present,
            DomAssert::TextContains { needle, .. } => {
                self.present && self.text.as_deref().is_some_and(|t| t.contains(needle))
            }
            DomAssert::ValueEquals { value, .. } => {
                self.present && self.value.as_deref() == Some(value.as_str())
            }
        }
    }
}

/// A DOM assertion a replay checks against the live page — so replay *verifies*,
/// not just *performs*. Every variant names the selector it targets.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DomAssert {
    /// An element matching `selector` exists.
    Present { selector: String },
    /// No element matches `selector`.
    Absent { selector: String },
    /// The element's text contains `needle`.
    TextContains { selector: String, needle: String },
    /// The element's `value` equals `value` exactly.
    ValueEquals { selector: String, value: String },
}

impl DomAssert {
    /// The selector this assertion queries.
    pub fn selector(&self) -> &str {
        match self {
            DomAssert::Present { selector }
            | DomAssert::Absent { selector }
            | DomAssert::TextContains { selector, .. }
            | DomAssert::ValueEquals { selector, .. } => selector,
        }
    }
}

/// A [`WebReplayDriver`] that can also **query / assert** the DOM. The lean driver
/// only *performs* gestures; this extension lets a replay verify the page reached
/// the expected state (saga-design §Njord #1). A real WebDriver driver implements
/// [`query`](Self::query) with an `execute`d `querySelector`; [`MockDomDriver`]
/// serves a preset DOM for tests.
pub trait DomAssertDriver: WebReplayDriver {
    /// Read the current state of the element matching `selector` (absent if none).
    fn query(&mut self, selector: &str) -> Result<DomSnapshot>;

    /// Query the assertion's selector and check it. Default: [`query`](Self::query)
    /// then [`DomSnapshot::satisfies`]. Returns whether the assertion held.
    fn assert(&mut self, assertion: &DomAssert) -> Result<bool> {
        let snap = self.query(assertion.selector())?;
        Ok(snap.satisfies(assertion))
    }
}

/// A test double implementing both [`WebReplayDriver`] (records dispatched
/// gestures, like [`MockDriver`](crate::ui::MockDriver)) and [`DomAssertDriver`]
/// (serves a preset DOM), so a test can drive a replay AND assert the page.
#[derive(Debug, Default, Clone)]
pub struct MockDomDriver {
    /// The URL passed to [`open`](WebReplayDriver::open).
    pub opened: Option<String>,
    /// The gestures dispatched, in order.
    pub actions: Vec<DriveAction>,
    /// Whether [`finish`](WebReplayDriver::finish) was called.
    pub finished: bool,
    dom: BTreeMap<String, DomSnapshot>,
}

impl MockDomDriver {
    /// A fresh driver over an empty DOM.
    pub fn new() -> MockDomDriver {
        MockDomDriver::default()
    }

    /// Seed the preset DOM: `selector` now resolves to `snapshot` on [`query`].
    pub fn with_element(mut self, selector: &str, snapshot: DomSnapshot) -> MockDomDriver {
        self.dom.insert(selector.to_string(), snapshot);
        self
    }
}

impl WebReplayDriver for MockDomDriver {
    fn open(&mut self, url: &str) -> Result<()> {
        self.opened = Some(url.to_string());
        Ok(())
    }
    fn dispatch(&mut self, action: &DriveAction) -> Result<()> {
        self.actions.push(action.clone());
        Ok(())
    }
    fn finish(&mut self) -> Result<()> {
        self.finished = true;
        Ok(())
    }
}

impl DomAssertDriver for MockDomDriver {
    fn query(&mut self, selector: &str) -> Result<DomSnapshot> {
        Ok(self.dom.get(selector).cloned().unwrap_or_else(|| DomSnapshot::absent(selector)))
    }
}

// ─── input injection (PII-redacted replay) ───────────────────────────────────

/// The placeholder a redacted tape carries where the emailed verification code
/// belongs. At replay, [`inject_inputs`] swaps it for the real code sourced from
/// the [`RedactedInputProvider`] (explicit → `$SAGA_TEST_CODE` env → mail-capture).
pub const TEST_CODE_TOKEN: &str = "$SAGA_TEST_CODE";

/// A hook that fetches the latest emailed verification code — an embedded
/// mail-capture (mailpit et al.) so the code is recovered automatically instead
/// of being pasted by hand (saga-design §Njord #2 / §7).
pub trait MailCapture {
    /// The most recent verification code, or `None` if none has arrived yet.
    fn latest_code(&self) -> Result<Option<String>>;
}

/// Supplies the inputs a **PII-redacted** tape cannot carry: per-field form values
/// and the emailed verification code. A redacted replay drives nav + clicks from
/// the tape but pulls form values (email/password) + the code from here.
pub trait RedactedInputProvider {
    /// The value to type into the field identified by `target` (a locator /
    /// selector, e.g. [`saga_field_selector`]), or `None` to keep the recorded one.
    fn field_value(&self, target: &str) -> Option<String>;
    /// The emailed verification code the [`TEST_CODE_TOKEN`] resolves to.
    fn test_code(&self) -> Option<String>;
}

/// The default [`RedactedInputProvider`]: a caller-supplied field map plus a test
/// code resolved **explicit → `$SAGA_TEST_CODE` env → [`MailCapture`] hook**.
#[derive(Default)]
pub struct RedactedInputs {
    fields: BTreeMap<String, String>,
    test_code: Option<String>,
    mail: Option<Box<dyn MailCapture>>,
}

impl RedactedInputs {
    /// An empty provider (no fields, no code).
    pub fn new() -> RedactedInputs {
        RedactedInputs::default()
    }

    /// Supply the value for a field `target` (its locator / selector).
    pub fn with_field(mut self, target: &str, value: &str) -> RedactedInputs {
        self.fields.insert(target.to_string(), value.to_string());
        self
    }

    /// Supply the verification code explicitly (wins over env + mail-capture).
    pub fn with_test_code(mut self, code: &str) -> RedactedInputs {
        self.test_code = Some(code.to_string());
        self
    }

    /// Attach a [`MailCapture`] hook — the last-resort source of the code.
    pub fn with_mail_capture(mut self, mail: Box<dyn MailCapture>) -> RedactedInputs {
        self.mail = Some(mail);
        self
    }

    /// Resolve the verification code: explicit value, else `$SAGA_TEST_CODE` from
    /// the environment, else the mail-capture hook.
    fn resolve_test_code(&self) -> Option<String> {
        if let Some(code) = &self.test_code {
            return Some(code.clone());
        }
        if let Ok(code) = std::env::var("SAGA_TEST_CODE") {
            if !code.is_empty() {
                return Some(code);
            }
        }
        if let Some(mail) = &self.mail {
            if let Ok(Some(code)) = mail.latest_code() {
                return Some(code);
            }
        }
        None
    }
}

impl RedactedInputProvider for RedactedInputs {
    fn field_value(&self, target: &str) -> Option<String> {
        self.fields.get(target).cloned()
    }
    fn test_code(&self) -> Option<String> {
        self.resolve_test_code()
    }
}

/// Rewrite a saga's [`UiMsg::FieldEdit`] movements with caller-supplied,
/// PII-redacted values before a replay drives them. Non-field movements pass
/// through untouched. For each field edit:
///
/// * a recorded value equal to [`TEST_CODE_TOKEN`] resolves to
///   [`RedactedInputProvider::test_code`] (the emailed code);
/// * otherwise a [`RedactedInputProvider::field_value`] for the edit's `target`
///   wins over the recorded (redacted) placeholder;
/// * if neither is supplied, the recorded value is kept.
///
/// This is the bridge that turns the tape's `FieldEdit` model into a replay that
/// types real secrets the tape never stored (saga-design §Njord #2).
pub fn inject_inputs(saga: &Saga, inputs: &dyn RedactedInputProvider) -> Saga {
    let mut out = Saga::new(&saga.title);
    for ev in &saga.events {
        let msg = match &ev.msg {
            UiMsg::FieldEdit { target, value } => {
                UiMsg::field_edit(target, &resolve_field(target, value, inputs))
            }
            other => other.clone(),
        };
        out.add(TimedUiMsg { t_ms: ev.t_ms, msg, state_json: ev.state_json.clone() });
    }
    out
}

/// Resolve the value to type into `target`: the `$SAGA_TEST_CODE` token → the
/// captured code; else a provider value for the field; else the recorded value.
fn resolve_field(target: &str, recorded: &str, inputs: &dyn RedactedInputProvider) -> String {
    if recorded.trim() == TEST_CODE_TOKEN {
        if let Some(code) = inputs.test_code() {
            return code;
        }
    }
    if let Some(v) = inputs.field_value(target) {
        return v;
    }
    recorded.to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── locator convention ───────────────────────────────────────────────────

    #[test]
    fn locator_helpers_build_the_blessed_selectors() {
        assert_eq!(SAGA_ATTR, "data-saga");
        assert_eq!(SAGA_FIELD_ATTR, "data-saga-field");
        assert_eq!(saga_selector("submit"), r#"[data-saga="submit"]"#);
        assert_eq!(saga_field_selector("email"), r#"[data-saga-field="email"]"#);
    }

    // ── DOM query / assert ───────────────────────────────────────────────────

    #[test]
    fn dom_assert_present_absent_text_and_value() {
        let mut d = MockDomDriver::new()
            .with_element(
                r#"[data-saga="banner"]"#,
                DomSnapshot {
                    selector: r#"[data-saga="banner"]"#.to_string(),
                    present: true,
                    text: Some("Offert sparad".to_string()),
                    value: None,
                },
            )
            .with_element(
                r#"[data-saga-field="email"]"#,
                DomSnapshot {
                    selector: r#"[data-saga-field="email"]"#.to_string(),
                    present: true,
                    text: None,
                    value: Some("alice@example.com".to_string()),
                },
            );

        // Present + text-contains on a live element.
        assert!(d
            .assert(&DomAssert::Present { selector: saga_selector("banner") })
            .unwrap());
        assert!(d
            .assert(&DomAssert::TextContains {
                selector: saga_selector("banner"),
                needle: "sparad".to_string(),
            })
            .unwrap());
        // Value equals on a field.
        assert!(d
            .assert(&DomAssert::ValueEquals {
                selector: saga_field_selector("email"),
                value: "alice@example.com".to_string(),
            })
            .unwrap());
        // Absent selector: Present fails, Absent holds. RED-when-broken.
        assert!(!d
            .assert(&DomAssert::Present { selector: saga_selector("missing") })
            .unwrap());
        assert!(d
            .assert(&DomAssert::Absent { selector: saga_selector("missing") })
            .unwrap());
        // A wrong text/value must NOT pass.
        assert!(!d
            .assert(&DomAssert::TextContains {
                selector: saga_selector("banner"),
                needle: "misslyckades".to_string(),
            })
            .unwrap());
    }

    #[test]
    fn mock_dom_driver_still_records_gestures() {
        use crate::ui::{drive_saga, UiMsg};
        let saga = Saga::new("t").push(UiMsg::nav("/kund").at(0));
        let mut d = MockDomDriver::new();
        let n = drive_saga(&saga, "http://localhost:3000", &mut d).unwrap();
        assert_eq!(n, 1);
        assert_eq!(d.opened.as_deref(), Some("http://localhost:3000"));
        assert!(d.finished);
        assert_eq!(d.actions, vec![DriveAction::Navigate { route: "/kund".into() }]);
    }

    // ── input injection ──────────────────────────────────────────────────────

    #[test]
    fn inject_replaces_field_values_and_resolves_the_test_code() {
        // A redacted tape: an email field (placeholder) and the verification code
        // token. nav/click movements must pass through untouched.
        let email_sel = saga_field_selector("email");
        let code_sel = saga_field_selector("code");
        let saga = Saga::new("signup")
            .push(UiMsg::nav("/signup").at(0))
            .push(UiMsg::field_edit(&email_sel, "«redacted»").at(10))
            .push(UiMsg::click(0.0, 0.0, "[data-saga=\"send\"]").at(20))
            .push(UiMsg::field_edit(&code_sel, TEST_CODE_TOKEN).at(30));

        let inputs = RedactedInputs::new()
            .with_field(&email_sel, "alice@example.org")
            .with_test_code("123456");

        let injected = inject_inputs(&saga, &inputs);
        assert_eq!(injected.len(), 4, "movement count preserved");
        // nav + click untouched.
        assert_eq!(injected.events[0].msg, UiMsg::nav("/signup"));
        assert_eq!(injected.events[2].msg, UiMsg::click(0.0, 0.0, "[data-saga=\"send\"]"));
        // email field got the injected value; code token resolved.
        assert_eq!(
            injected.events[1].msg,
            UiMsg::field_edit(&email_sel, "alice@example.org")
        );
        assert_eq!(injected.events[3].msg, UiMsg::field_edit(&code_sel, "123456"));
    }

    #[test]
    fn unmapped_field_keeps_its_recorded_value() {
        let saga = Saga::new("t").push(UiMsg::field_edit("#note", "hello").at(0));
        let injected = inject_inputs(&saga, &RedactedInputs::new());
        assert_eq!(injected.events[0].msg, UiMsg::field_edit("#note", "hello"));
    }

    struct StubMail(Option<String>);
    impl MailCapture for StubMail {
        fn latest_code(&self) -> Result<Option<String>> {
            Ok(self.0.clone())
        }
    }

    #[test]
    fn mail_capture_supplies_the_code_when_no_explicit_one() {
        // No explicit code, no env → the mail-capture hook is the source.
        let inputs =
            RedactedInputs::new().with_mail_capture(Box::new(StubMail(Some("998877".to_string()))));
        // Guard against a stray env var in the test environment.
        if std::env::var("SAGA_TEST_CODE").is_err() {
            assert_eq!(inputs.test_code().as_deref(), Some("998877"));
        }
        // Explicit code wins over the hook.
        let explicit = RedactedInputs::new()
            .with_test_code("000000")
            .with_mail_capture(Box::new(StubMail(Some("998877".to_string()))));
        assert_eq!(explicit.test_code().as_deref(), Some("000000"));
    }
}