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