use std::collections::HashMap;
#[cfg(feature = "robot")]
use std::collections::BTreeMap;
use std::path::Path;
use anyhow::{anyhow, bail, Context, Result};
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
pub struct ScenarioFile {
#[serde(default, rename = "scenario")]
pub scenarios: Vec<Scenario>,
}
#[derive(Debug, Deserialize)]
pub struct Scenario {
pub name: String,
#[serde(default = "default_tier")]
pub tier: String,
#[serde(default)]
pub area: String,
#[serde(default)]
pub needs: Vec<String>,
pub steps: Vec<Step>,
}
fn default_tier() -> String {
"P1".into()
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Step {
Goto(String),
ClickRole { role: String, name: String },
FillField { name: String, value: String },
AssertHeading { level: u8, text: String },
AssertUrlContains(String),
AssertText(String),
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)
}
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)
}
#[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>,
}
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(())
}
#[cfg(feature = "robot")]
pub use drive::run_scenarios;
#[cfg(feature = "robot")]
mod drive {
use super::*;
use fantoccini::{Client, ClientBuilder, Locator};
fn xq(s: &str) -> String {
if !s.contains('\'') {
format!("'{s}'")
} else {
format!("concat('{}')", s.replace('\'', "', \"'\", '"))
}
}
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::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(())
}
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 {
let client = ClientBuilder::native()
.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];
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" } ]
"#;
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:?}"),
}
}
#[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());
}
}