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"));
}
}