use std::io::Read;
use serde_json::{Value, json};
use crate::words::{Args, quote, split};
use crate::{Ctx, Done, Fail, Res, target, usage_of};
const MAX_SCRIPT: u64 = 16 * 1024 * 1024;
fn failed(error: String) -> Fail {
Fail::Failed { error, detail: None, hint: None }
}
#[derive(Debug, Clone, PartialEq)]
struct Filter {
key: String,
value: String,
contains: bool,
}
fn parse_selector(words: &[String]) -> Result<Vec<Filter>, String> {
if words.is_empty() {
return Err("a selector needs at least one filter, like role=button".into());
}
words
.iter()
.map(|w| {
let (k, v, contains) = match (w.split_once("~="), w.split_once('=')) {
(Some((k, v)), _) if !k.contains('=') => (k, v, true),
(_, Some((k, v))) => (k, v, false),
_ => return Err(format!("`{w}` is not a filter (role=…, name=…, id=…, value=…; ~= for a substring)")),
};
if !["role", "name", "id", "value"].contains(&k) {
return Err(format!("unknown filter `{k}` (role, name, id, value)"));
}
Ok(Filter { key: k.into(), value: v.into(), contains })
})
.collect()
}
fn matches(e: &Value, filters: &[Filter]) -> bool {
filters.iter().all(|f| {
let got = e.get(&f.key).and_then(Value::as_str).unwrap_or("");
if f.contains { got.contains(&f.value) } else { got == f.value }
})
}
fn compare(n: u64, op: &str, want: u64) -> Option<bool> {
Some(match op {
"==" | "=" => n == want,
"!=" => n != want,
">=" => n >= want,
"<=" => n <= want,
">" => n > want,
"<" => n < want,
_ => return None,
})
}
impl Ctx<'_, '_, '_> {
pub(crate) fn batch(&mut self, mut a: Args) -> Res {
let verbose = a.flag(&["--verbose", "-v"]);
let file = a.positional().ok_or_else(|| Fail::Usage("batch needs a script (or - for stdin)".into(), usage_of("batch")))?;
a.finish().map_err(|e| Fail::Usage(e, usage_of("batch")))?;
if self.in_batch {
return Err(failed("a batch cannot run another batch".into()));
}
let mut text = String::new();
let read = if file == "-" {
(&mut *self.io.input).take(MAX_SCRIPT).read_to_string(&mut text)
} else {
std::fs::File::open(&file).and_then(|f| f.take(MAX_SCRIPT).read_to_string(&mut text))
};
read.map_err(|e| Fail::Usage(format!("cannot read {file}: {e}"), usage_of("batch")))?;
self.in_batch = true;
let r = self.run_script(&file, &text, verbose);
self.in_batch = false;
self.batch_trace = None;
r
}
pub(crate) fn run_script(&mut self, file: &str, text: &str, verbose: bool) -> Res {
let mut steps = Vec::new();
let mut passed = 0usize;
for (i, line) in text.lines().enumerate() {
let n = i.saturating_add(1);
let words = match split(line) {
Ok(w) if w.is_empty() => continue,
Ok(w) => w,
Err(e) => return Err(self.step_failed(file, n, line.trim(), &e, None, passed, steps)),
};
let shown = words.iter().map(|w| quote(w)).collect::<Vec<_>>().join(" ");
let r = self.step(&words);
match r {
Ok(d) => {
passed = passed.saturating_add(1);
if verbose && !self.json {
let _ = writeln!(self.io.out, "ok {n:>4} {shown}");
for l in d.text.lines() {
let _ = writeln!(self.io.out, " {l}");
}
}
steps.push(json!({"line": n, "command": shown, "ok": true, "result": d.result}));
}
Err(f) => {
let detail = match &f {
Fail::Failed { detail, .. } => detail.clone(),
Fail::Usage(..) => None,
};
return Err(self.step_failed(file, n, &shown, &f.text(), detail, passed, steps));
}
}
}
let text = format!("batch: {passed} steps passed ({file})");
Ok(Done::new(json!({"passed": passed, "steps": steps}), text))
}
fn step_failed(
&mut self,
file: &str,
line: usize,
shown: &str,
error: &str,
detail: Option<Value>,
passed: usize,
mut steps: Vec<Value>,
) -> Fail {
steps.push(json!({"line": line, "command": shown, "ok": false, "error": error}));
if !self.json {
let _ = writeln!(self.io.err, "FAIL {file}:{line}: {shown}\n {error}");
}
Fail::Failed {
error: format!("{file}:{line}: {error}"),
detail: Some(json!({"line": line, "command": shown, "passed": passed, "steps": steps, "detail": detail})),
hint: Some(format!("{passed} step(s) passed before line {line}")),
}
}
fn step(&mut self, words: &[String]) -> Res {
let Some((verb, rest)) = words.split_first() else { return Ok(Done::new(Value::Null, "")) };
match verb.as_str() {
"assert" | "expect" => self.assert(rest),
"echo" => {
let t = rest.join(" ");
if !self.json {
let _ = writeln!(self.io.out, "{t}");
}
Ok(Done::new(json!(t), ""))
}
"sleep" => {
let ms = rest.first().and_then(|m| m.parse::<u64>().ok()).ok_or_else(|| failed("sleep needs milliseconds".into()))?;
self.pause(ms);
Ok(Done::new(json!({"slept_ms": ms}), ""))
}
"open" | "connect" | "close" if self.inproc.is_none() => {
let r = self.run_words(words);
if verb != "open" && verb != "connect" {
self.conn = None;
}
r
}
_ => self.run_words(words),
}
}
fn read(&mut self, t: &str, what: &str) -> Result<Option<Value>, Fail> {
let t = target::parse(t).map_err(failed)?;
match self.call("ui.get", json!({"target": t, "what": what})) {
Ok(v) => Ok(Some(v.get("value").cloned().unwrap_or(Value::Null))),
Err(Fail::Failed { error, detail, .. })
if error.starts_with("not_found")
|| error.starts_with("stale_ref")
|| detail.as_ref().and_then(|d| d.get("kind")).and_then(Value::as_str).is_some_and(|k| k == "not_found" || k == "stale_ref") =>
{
Ok(None)
}
Err(e) => Err(e),
}
}
fn elements(&mut self) -> Result<Vec<Value>, Fail> {
let v = self.call("ui.snapshot", json!({}))?;
Ok(v.get("elements").and_then(Value::as_array).cloned().unwrap_or_default())
}
fn assert(&mut self, w: &[String]) -> Res {
let Some((kind, rest)) = w.split_first() else { return Err(failed("assert what? (text, visible, count, …)".into())) };
let arg = |i: usize, what: &str| rest.get(i).cloned().ok_or_else(|| failed(format!("assert {kind} needs {what}")));
match kind.as_str() {
"text" | "value" | "contains" => {
let (t, want) = (arg(0, "a target")?, arg(1, "the expected text")?);
if rest.len() > 2 {
return Err(failed(format!("assert {kind}: quote the expected text (\"…\")")));
}
let what = if kind == "value" { "value" } else { "text" };
let got = self.read(&t, what)?.ok_or_else(|| failed(format!("{t} is not there")))?;
let got_s = match &got {
Value::String(s) => s.clone(),
Value::Null => String::new(),
v => v.to_string(),
};
let ok = if kind == "contains" { got_s.contains(&want) } else { got_s.trim() == want.trim() };
if ok {
Ok(Done::new(json!({"got": got}), ""))
} else if kind == "contains" {
Err(failed(format!("expected {t} to contain {}, got {}", quote(&want), quote(&got_s))))
} else {
Err(failed(format!("expected {kind} of {t} to be {}, got {}", quote(&want), quote(&got_s))))
}
}
"visible" | "hidden" | "enabled" | "disabled" | "focused" | "checked" | "unchecked" => {
let t = arg(0, "a target")?;
if rest.len() > 1 {
return Err(failed(format!("assert {kind} takes one target")));
}
let (what, want) = match kind.as_str() {
"visible" => ("visible", true),
"hidden" => ("visible", false),
"enabled" => ("enabled", true),
"disabled" => ("enabled", false),
"focused" => ("focused", true),
"checked" => ("checked", true),
_ => ("checked", false),
};
match self.read(&t, what)? {
None if kind == "hidden" => Ok(Done::new(json!({"present": false}), "")),
None => Err(failed(format!("expected {t} to be {kind}, but it is not there"))),
Some(v) if v.as_bool() == Some(want) => Ok(Done::new(json!({what: v}), "")),
Some(v) => Err(failed(format!("expected {t} to be {kind}, but {what} is {v}"))),
}
}
"count" => {
let op_at = rest
.iter()
.position(|x| compare(0, x, 0).is_some())
.ok_or_else(|| failed("assert count <selector…> <op> <n>, op one of == != >= <= > <".into()))?;
let filters = parse_selector(rest.get(..op_at).unwrap_or(&[])).map_err(failed)?;
let op = rest.get(op_at).cloned().unwrap_or_default();
let want: u64 = rest
.get(op_at.saturating_add(1))
.and_then(|n| n.parse().ok())
.ok_or_else(|| failed(format!("assert count: a number after {op}")))?;
if rest.len() > op_at.saturating_add(2) {
return Err(failed("assert count: unexpected words after the number".into()));
}
let n = self.elements()?.iter().filter(|e| matches(e, &filters)).count() as u64;
if compare(n, &op, want) == Some(true) {
Ok(Done::new(json!({"count": n}), ""))
} else {
Err(failed(format!("expected count {op} {want}, got {n}")))
}
}
"exists" | "missing" => {
let filters = parse_selector(rest).map_err(failed)?;
let n = self.elements()?.iter().filter(|e| matches(e, &filters)).count();
match (kind.as_str(), n) {
("exists", 0) => Err(failed(format!("expected an element matching {}, found none", rest.join(" ")))),
("missing", n) if n > 0 => Err(failed(format!("expected no element matching {}, found {n}", rest.join(" ")))),
_ => Ok(Done::new(json!({"count": n}), "")),
}
}
"screenshot" | "snapshot" => {
let mut snaps = crate::snapshot::Snapshots::from_env();
let mut words: Vec<&String> = Vec::new();
let mut it = rest.iter();
while let Some(w) = it.next() {
if let Some(v) = w.strip_prefix("--tolerance=") {
snaps.tolerance = Some(crate::snapshot::Tolerance::parse(v).map_err(failed)?);
} else if w == "--tolerance" {
let v = it.next().ok_or_else(|| failed("--tolerance needs channel=N,pixels=N".into()))?;
snaps.tolerance = Some(crate::snapshot::Tolerance::parse(v).map_err(failed)?);
} else {
words.push(w);
}
}
let (name, tgt) = match words.as_slice() {
[name] => (name.as_str(), None),
[name, t] => (name.as_str(), Some(target::parse(t).map_err(failed)?)),
_ => return Err(failed("assert screenshot <name> [target] [--tolerance channel=N,pixels=N]".into())),
};
let mut p = json!({});
if let Some(t) = tgt {
p["target"] = t;
}
let v = self.call("ui.screenshot", p)?;
let out = snaps.check(name, &v).map_err(failed)?;
Ok(Done::new(out.to_json(), out.text()))
}
other => Err(failed(format!(
"unknown assertion `{other}` (text, value, contains, visible, hidden, enabled, disabled, focused, checked, unchecked, count, exists, missing, screenshot)"
))),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selectors() {
let f = parse_selector(&["role=button".into(), "name~=Sav".into()]).unwrap();
assert!(matches(&json!({"role": "button", "name": "Save"}), &f));
assert!(!matches(&json!({"role": "button", "name": "Load"}), &f));
assert!(!matches(&json!({"role": "label", "name": "Save"}), &f));
assert!(parse_selector(&[]).is_err());
assert!(parse_selector(&["colour=red".into()]).is_err());
assert!(parse_selector(&["button".into()]).is_err());
assert_eq!(parse_selector(&["name=a=b".into()]).unwrap()[0].value, "a=b");
}
#[test]
fn comparisons() {
assert_eq!(compare(3, ">=", 3), Some(true));
assert_eq!(compare(2, ">", 3), Some(false));
assert_eq!(compare(2, "!=", 3), Some(true));
assert_eq!(compare(2, "~", 3), None);
}
}