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

nordisk/nornir

src/robot.rs

//! UI-robot tester — drive a real browser (WebDriver via `fantoccini`) through
//! declarative TOML scenarios and persist the results.
//!
//! Design (workspace_njord/UI-ROBOT-TESTS.md §7, decisions confirmed 2026-06-09):
//! - **Scenarios are data** (`robot/scenarios.toml` in the workspace repo) — the
//!   app under test stays code-only; locators anchor on ARIA role + accessible
//!   name + headings + form `name=` + URL, never CSS classes.
//! - **The scenario set is a plan-tests-DAG**: each scenario's `needs = [...]`
//!   names other scenarios; the runner topo-orders them (Kahn) and a failure
//!   **skips all transitive dependents** — the funnel's plan-DAG semantics
//!   applied to tests (a `plan-tests-DAG` next to the `plan-software-DAG`).
//! - **Results persist like the bench**: one `Report` JSON line appended to
//!   `robot_history.jsonl` next to the scenario file (the bench_history.jsonl
//!   pattern); Iceberg `robot_*` tables are the planned follow-up so the viz
//!   renders the test-DAG green/red.
//!
//! Feature `robot` (pulls `fantoccini`). Endpoint-agnostic: point `--webdriver`
//! at geckodriver (`:4444`), chromedriver (`:9515`), or a Playwright server.

#[cfg(feature = "robot")]
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::path::Path;

use anyhow::{Context, Result, anyhow, bail};
use serde::{Deserialize, Serialize};

// ── Scenario format ──────────────────────────────────────────────────────────

#[derive(Debug, Deserialize)]
pub struct ScenarioFile {
    #[serde(default, rename = "scenario")]
    pub scenarios: Vec<Scenario>,
}

#[derive(Debug, Deserialize)]
pub struct Scenario {
    pub name: String,
    /// P0..P3 — priority tier (filterable).
    #[serde(default = "default_tier")]
    pub tier: String,
    /// Catalogue area A..M.
    #[serde(default)]
    pub area: String,
    /// plan-tests-DAG edges: scenarios that must PASS before this one runs.
    #[serde(default)]
    pub needs: Vec<String>,
    pub steps: Vec<Step>,
}

fn default_tier() -> String {
    "P1".into()
}

/// One keyword step. Externally-tagged: `{ goto = "/kund" }`,
/// `{ click_role = { role = "link", name = "Logga in" } }`, …
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Step {
    /// Navigate to a path (joined onto the base URL) or absolute URL.
    Goto(String),
    /// Click an element by ARIA role + accessible name.
    ClickRole { role: String, name: String },
    /// Type into a form control located by its `name=` attribute.
    FillField { name: String, value: String },
    /// Assert an `<hN>` heading containing `text` exists.
    AssertHeading { level: u8, text: String },
    /// Assert the current URL contains the fragment.
    AssertUrlContains(String),
    /// Assert the page body contains the text.
    AssertText(String),
    /// Assert the page body does NOT contain the text (negative / leak guard —
    /// e.g. a public page must not render an authenticated heading).
    AssertNotText(String),
    /// Wait (poll, no raw sleeps) until the body contains the text.
    WaitText {
        text: String,
        #[serde(default = "default_timeout")]
        timeout_ms: u64,
    },
}

fn default_timeout() -> u64 {
    5000
}

pub fn parse_scenarios(path: &Path) -> Result<Vec<Scenario>> {
    let text = std::fs::read_to_string(path)
        .with_context(|| format!("read scenarios {}", path.display()))?;
    let file: ScenarioFile =
        toml::from_str(&text).with_context(|| format!("parse {}", path.display()))?;
    if file.scenarios.is_empty() {
        bail!("{} declares no [[scenario]] entries", path.display());
    }
    Ok(file.scenarios)
}

// ── plan-tests-DAG: topo order + skip propagation ───────────────────────────

/// Kahn topo order over `needs` edges. Errors on unknown names or cycles.
pub fn topo_order(scenarios: &[Scenario]) -> Result<Vec<usize>> {
    let idx: HashMap<&str, usize> = scenarios
        .iter()
        .enumerate()
        .map(|(i, s)| (s.name.as_str(), i))
        .collect();
    let mut indeg = vec![0usize; scenarios.len()];
    let mut dependents: Vec<Vec<usize>> = vec![Vec::new(); scenarios.len()];
    for (i, s) in scenarios.iter().enumerate() {
        for need in &s.needs {
            let &j = idx
                .get(need.as_str())
                .ok_or_else(|| anyhow!("scenario `{}` needs unknown `{need}`", s.name))?;
            indeg[i] += 1;
            dependents[j].push(i);
        }
    }
    let mut ready: Vec<usize> = (0..scenarios.len()).filter(|&i| indeg[i] == 0).collect();
    let mut order = Vec::with_capacity(scenarios.len());
    while let Some(i) = ready.pop() {
        order.push(i);
        for &d in &dependents[i] {
            indeg[d] -= 1;
            if indeg[d] == 0 {
                ready.push(d);
            }
        }
    }
    if order.len() != scenarios.len() {
        bail!("scenario needs-graph has a cycle");
    }
    Ok(order)
}

// ── Result model (persisted) ────────────────────────────────────────────────

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum Status {
    Pass,
    Fail,
    Skip,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct TestCase {
    pub name: String,
    pub tier: String,
    pub area: String,
    pub status: Status,
    pub duration_ms: u64,
    pub steps: usize,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub failure: Option<String>,
}

#[derive(Debug, Serialize, Deserialize)]
pub struct Report {
    pub repo: String,
    pub target_url: String,
    pub webdriver: String,
    pub started_at: String,
    pub git_sha: String,
    pub total: usize,
    pub passed: usize,
    pub failed: usize,
    pub skipped: usize,
    pub cases: Vec<TestCase>,
}

/// Append one Report as a JSON line (the bench_history.jsonl pattern).
pub fn append_history(path: &Path, report: &Report) -> Result<()> {
    use std::io::Write;
    let mut f = std::fs::OpenOptions::new()
        .create(true)
        .append(true)
        .open(path)
        .with_context(|| format!("open {}", path.display()))?;
    writeln!(f, "{}", serde_json::to_string(report)?)?;
    Ok(())
}

// ── Saga replay → WebDriver scenario (EPIC SAGA / task #25) ──────────────────
//
// The **web half** of the design's replay-driver split (egui → kittest /
// nornir-robotui; web → WebDriver via fantoccini, here). A recorded `saga_events`
// session is read back into a `nornir_saga::ReplayPlan` (toolkit-neutral); this
// turns that plan into the SAME `Scenario`/`Step` shape `run_scenarios` already
// drives — so replaying a captured Njord (Leptos+Axum) journey is just feeding
// the converted scenario into the existing WebDriver runner. A step that can't be
// reproduced fails like any scenario step → a RED row.
//
// Scaffold today: the conversion + a `Goto`/`ClickRole` mapping are wired and
// unit-tested; pointing it at a live geckodriver/chromedriver (phase 2, once the
// Njord adapter emits `saga_events`) needs only `run_scenarios(&[scenario], …)`.

use nornir_saga::replay::{ReplayPlan, ReplayStep, StepKind};

/// Convert a recorded saga [`ReplayPlan`] into a WebDriver [`Scenario`] (this
/// module's TOML/`run_scenarios` shape). One robot scenario per session:
///
///   * [`StepKind::Navigate`] → [`Step::Goto`] the recorded `route`, then assert
///     the URL contains it.
///   * [`StepKind::Activate`] → [`Step::ClickRole`] a `button` named by the
///     event's `action` (falling back to `component`).
///
/// The result drops straight into [`run_scenarios`] against a live Leptos app.
pub fn replay_plan_to_web_scenario(plan: &ReplayPlan) -> Scenario {
    let steps = plan.steps.iter().flat_map(web_steps_for).collect();
    Scenario {
        name: format!("saga-replay:{}", plan.session_id),
        tier: "P1".into(),
        area: "saga".into(),
        needs: Vec::new(),
        steps,
    }
}

/// The WebDriver [`Step`]s one replay step expands to (a nav also asserts its
/// route landed; a click locates the target by ARIA role + name).
fn web_steps_for(step: &ReplayStep) -> Vec<Step> {
    match step.kind {
        StepKind::Navigate => {
            let route = if step.route.is_empty() {
                "/".to_string()
            } else {
                step.route.clone()
            };
            vec![Step::Goto(route.clone()), Step::AssertUrlContains(route)]
        }
        StepKind::Activate => {
            let name = if !step.action.is_empty() {
                step.action.clone()
            } else {
                step.component.clone()
            };
            vec![Step::ClickRole {
                role: "button".into(),
                name,
            }]
        }
    }
}

// ── Driver + runner (feature `robot`) ───────────────────────────────────────

#[cfg(feature = "robot")]
pub use drive::run_scenarios;

#[cfg(feature = "robot")]
mod drive {
    use super::*;
    use fantoccini::{Client, ClientBuilder, Locator};

    /// XPath escaping for a single-quoted literal.
    fn xq(s: &str) -> String {
        if !s.contains('\'') {
            format!("'{s}'")
        } else {
            format!("concat('{}')", s.replace('\'', "', \"'\", '"))
        }
    }

    /// ARIA role + accessible-name locator: explicit `role=` plus the implicit
    /// role's native tags (link→a, button→button/input[submit]) — never classes.
    fn role_xpath(role: &str, name: &str) -> String {
        let n = xq(name);
        let by_attr = format!(
            "//*[@role={r}][normalize-space()={n} or @aria-label={n}]",
            r = xq(role),
            n = n
        );
        match role {
            "link" => format!("{by_attr} | //a[normalize-space()={n}]"),
            "button" => format!(
                "{by_attr} | //button[normalize-space()={n}] | //input[@type='submit'][@value={n}]"
            ),
            _ => by_attr,
        }
    }

    async fn run_step(c: &Client, base: &str, step: &Step) -> Result<()> {
        match step {
            Step::Goto(path) => {
                let url = if path.starts_with("http") {
                    path.clone()
                } else {
                    format!("{}{}", base.trim_end_matches('/'), path)
                };
                c.goto(&url).await?;
            }
            Step::ClickRole { role, name } => {
                let xp = role_xpath(role, name);
                c.wait()
                    .for_element(Locator::XPath(&xp))
                    .await
                    .with_context(|| format!("no {role} named `{name}`"))?
                    .click()
                    .await?;
            }
            Step::FillField { name, value } => {
                let css = format!("[name='{name}']");
                c.wait()
                    .for_element(Locator::Css(&css))
                    .await
                    .with_context(|| format!("no field name={name}"))?
                    .send_keys(value)
                    .await?;
            }
            Step::AssertHeading { level, text } => {
                let xp = format!("//h{level}[contains(normalize-space(), {})]", xq(text));
                c.find(Locator::XPath(&xp))
                    .await
                    .with_context(|| format!("missing <h{level}> containing `{text}`"))?;
            }
            Step::AssertUrlContains(frag) => {
                let url = c.current_url().await?.to_string();
                if !url.contains(frag.as_str()) {
                    bail!("url `{url}` does not contain `{frag}`");
                }
            }
            Step::AssertText(text) => {
                let body = c.source().await?;
                if !body.contains(text.as_str()) {
                    bail!("page does not contain `{text}`");
                }
            }
            Step::AssertNotText(text) => {
                let body = c.source().await?;
                if body.contains(text.as_str()) {
                    bail!("page unexpectedly contains `{text}`");
                }
            }
            Step::WaitText { text, timeout_ms } => {
                let deadline =
                    std::time::Instant::now() + std::time::Duration::from_millis(*timeout_ms);
                loop {
                    if c.source().await?.contains(text.as_str()) {
                        break;
                    }
                    if std::time::Instant::now() >= deadline {
                        bail!("timed out waiting for `{text}`");
                    }
                    tokio::time::sleep(std::time::Duration::from_millis(150)).await;
                }
            }
        }
        Ok(())
    }

    /// Run `scenarios` in plan-tests-DAG order against `webdriver`/`base_url`.
    /// A failed scenario marks every transitive dependent `Skip`.
    pub fn run_scenarios(
        scenarios: &[Scenario],
        webdriver: &str,
        base_url: &str,
        repo: &str,
        git_sha: &str,
    ) -> Result<Report> {
        let order = topo_order(scenarios)?;
        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()
            .context("build runtime for robot run")?;
        rt.block_on(async {
            // rustls (the native roots), not native-tls: no system openssl in the
            // build images (Cargo.toml's fantoccini note).
            let client = ClientBuilder::rustls()
                .context("rustls client for the WebDriver (native root certificates)")?
                .connect(webdriver)
                .await
                .with_context(|| {
                    format!(
                        "connect WebDriver at {webdriver} (start geckodriver/chromedriver first)"
                    )
                })?;
            let mut status: BTreeMap<String, Status> = BTreeMap::new();
            let mut cases = Vec::new();
            for &i in &order {
                let sc = &scenarios[i];
                // plan-tests-DAG: any non-passed need ⇒ skip.
                let blocked = sc
                    .needs
                    .iter()
                    .any(|n| status.get(n.as_str()) != Some(&Status::Pass));
                if blocked {
                    status.insert(sc.name.clone(), Status::Skip);
                    cases.push(TestCase {
                        name: sc.name.clone(),
                        tier: sc.tier.clone(),
                        area: sc.area.clone(),
                        status: Status::Skip,
                        duration_ms: 0,
                        steps: sc.steps.len(),
                        failure: Some("skipped: a needed scenario did not pass".into()),
                    });
                    continue;
                }
                let t0 = std::time::Instant::now();
                let mut failure = None;
                for (si, step) in sc.steps.iter().enumerate() {
                    if let Err(e) = run_step(&client, base_url, step).await {
                        failure = Some(format!("step {}: {e:#}", si + 1));
                        break;
                    }
                }
                let st = if failure.is_none() {
                    Status::Pass
                } else {
                    Status::Fail
                };
                eprintln!(
                    "robot: {} {} ({} ms){}",
                    match st {
                        Status::Pass => "✓",
                        Status::Fail => "✗",
                        Status::Skip => "→",
                    },
                    sc.name,
                    t0.elapsed().as_millis(),
                    failure
                        .as_deref()
                        .map(|f| format!(" — {f}"))
                        .unwrap_or_default(),
                );
                status.insert(sc.name.clone(), st.clone());
                cases.push(TestCase {
                    name: sc.name.clone(),
                    tier: sc.tier.clone(),
                    area: sc.area.clone(),
                    status: st,
                    duration_ms: t0.elapsed().as_millis() as u64,
                    steps: sc.steps.len(),
                    failure,
                });
            }
            client.close().await.ok();
            let (mut p, mut f, mut s) = (0, 0, 0);
            for c in &cases {
                match c.status {
                    Status::Pass => p += 1,
                    Status::Fail => f += 1,
                    Status::Skip => s += 1,
                }
            }
            Ok(Report {
                repo: repo.into(),
                target_url: base_url.into(),
                webdriver: webdriver.into(),
                started_at: chrono::Utc::now().to_rfc3339(),
                git_sha: git_sha.into(),
                total: cases.len(),
                passed: p,
                failed: f,
                skipped: s,
                cases,
            })
        })
    }
}

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

    fn sc(name: &str, needs: &[&str]) -> Scenario {
        Scenario {
            name: name.into(),
            tier: "P0".into(),
            area: "A".into(),
            needs: needs.iter().map(|s| s.to_string()).collect(),
            steps: vec![Step::Goto("/".into())],
        }
    }

    #[test]
    fn parses_the_toml_format() {
        let toml = r#"
[[scenario]]
name = "smoke"
tier = "P0"
area = "A"
steps = [
  { goto = "/" },
  { assert_heading = { level = 1, text = "Tillsynia" } },
  { click_role = { role = "link", name = "Logga in" } },
  { fill_field = { name = "email", value = "a@b.se" } },
  { assert_url_contains = "/login" },
  { wait_text = { text = "Välkommen" } },
]

[[scenario]]
name = "after-login"
needs = ["smoke"]
steps = [
  { assert_text = "Dashboard" },
  { assert_not_text = "404 — Sidan hittades inte" },
]
"#;
        let f: ScenarioFile = toml::from_str(toml).unwrap();
        assert_eq!(f.scenarios.len(), 2);
        assert_eq!(f.scenarios[0].steps.len(), 6);
        assert_eq!(f.scenarios[1].needs, vec!["smoke"]);
        match &f.scenarios[0].steps[5] {
            Step::WaitText { timeout_ms, .. } => assert_eq!(*timeout_ms, 5000),
            other => panic!("wrong step: {other:?}"),
        }
        // the negative-assertion step (njord-robot enhancement) round-trips.
        match &f.scenarios[1].steps[1] {
            Step::AssertNotText(t) => assert_eq!(t, "404 — Sidan hittades inte"),
            other => panic!("wrong step: {other:?}"),
        }
    }

    /// EPIC SAGA (task #25) — the web replay driver scaffold: a recorded
    /// `ReplayPlan` converts to the WebDriver `Scenario` shape (nav → Goto +
    /// AssertUrlContains; click → ClickRole), so a captured Njord journey is
    /// replayable against a live Leptos app via the existing `run_scenarios`.
    #[test]
    fn saga_replay_plan_converts_to_web_scenario() {
        use nornir_saga::event::{SagaEvent, SessionCtx};
        use nornir_saga::replay::ReplayPlan;

        let ctx = SessionCtx::new("s1", "u", "njord", "1.0", "sha");
        let mut nav = SagaEvent::nav("/offert/7", "OffertForm", "open", "{}").in_session(&ctx);
        let mut click =
            SagaEvent::click("/offert/7", "OffertForm", "submit", "{}").in_session(&ctx);
        nav.seq = 0;
        click.seq = 1;
        let plan = ReplayPlan::for_session("s1", &[nav, click]);

        let scenario = replay_plan_to_web_scenario(&plan);
        assert_eq!(scenario.name, "saga-replay:s1");
        // nav → Goto + AssertUrlContains; click → ClickRole(button, "submit").
        assert_eq!(
            scenario.steps.len(),
            3,
            "nav expands to 2 steps, click to 1"
        );
        match &scenario.steps[0] {
            Step::Goto(p) => assert_eq!(p, "/offert/7"),
            other => panic!("expected Goto, got {other:?}"),
        }
        match &scenario.steps[1] {
            Step::AssertUrlContains(p) => assert_eq!(p, "/offert/7"),
            other => panic!("expected AssertUrlContains, got {other:?}"),
        }
        match &scenario.steps[2] {
            Step::ClickRole { role, name } => {
                assert_eq!(role, "button");
                assert_eq!(name, "submit");
            }
            other => panic!("expected ClickRole, got {other:?}"),
        }
        // The converted scenario is a valid plan-tests-DAG node (topo-orderable).
        assert!(topo_order(&[scenario]).is_ok());
    }

    #[test]
    fn topo_respects_needs_and_detects_cycles() {
        let v = vec![sc("c", &["b"]), sc("a", &[]), sc("b", &["a"])];
        let order = topo_order(&v).unwrap();
        let pos = |n: &str| order.iter().position(|&i| v[i].name == n).unwrap();
        assert!(pos("a") < pos("b") && pos("b") < pos("c"));

        let cyc = vec![sc("x", &["y"]), sc("y", &["x"])];
        assert!(topo_order(&cyc).is_err());

        let unknown = vec![sc("x", &["nope"])];
        assert!(topo_order(&unknown).is_err());
    }
}