use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Script {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default = "default_backend")]
pub backend: String,
#[serde(default = "default_size")]
pub size: [f32; 2],
#[serde(default = "default_ppp")]
pub ppp: f32,
#[serde(default)]
pub seed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub document: Option<String>,
#[serde(default = "default_timeout")]
pub timeout_ms: u64,
pub steps: Vec<Step>,
}
fn default_backend() -> String {
"headless".into()
}
fn default_size() -> [f32; 2] {
[1400.0, 960.0]
}
fn default_ppp() -> f32 {
1.0
}
fn default_timeout() -> u64 {
120_000
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Step {
pub tool: String,
#[serde(default)]
pub args: Value,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub expect: Vec<Expect>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub save: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compare: Option<Compare>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expect_error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Compare {
pub baseline: String,
#[serde(default = "default_max_diff")]
pub max_diff_ratio: f64,
}
fn default_max_diff() -> f64 {
0.002
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct Expect {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "present")]
pub eq: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "present")]
pub ne: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gt: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub gte: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lt: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lte: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub near: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tol: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contains: Option<Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matches: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exists: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub len: Option<usize>,
}
fn present<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
Value::deserialize(deserializer).map(Some)
}
pub mod exit {
pub const PASS: i32 = 0;
pub const EXPECTATION_FAILED: i32 = 1;
pub const TOOL_ERROR: i32 = 2;
pub const HOST_ERROR: i32 = 3;
pub const TIMEOUT: i32 = 4;
}
impl Script {
pub fn parse(text: &str) -> Result<Self, String> {
let s: Script = serde_json::from_str(text).map_err(|e| format!("script parse: {e}"))?;
if s.steps.is_empty() {
return Err("script has no steps".into());
}
for (i, step) in s.steps.iter().enumerate() {
for (j, e) in step.expect.iter().enumerate() {
if e.operator_count() != 1 {
return Err(format!(
"step {i} (`{}`) expect {j}: exactly one operator is required",
step.tool
));
}
if !e.path.starts_with('/') && !e.path.is_empty() {
return Err(format!(
"step {i} expect {j}: `path` must be a JSON pointer starting with `/` (got `{}`)",
e.path
));
}
}
}
Ok(s)
}
pub fn json_schema() -> Value {
serde_json::to_value(schemars::schema_for!(Script)).unwrap_or(Value::Null)
}
}
impl Expect {
fn operator_count(&self) -> usize {
[
self.eq.is_some(),
self.ne.is_some(),
self.gt.is_some(),
self.gte.is_some(),
self.lt.is_some(),
self.lte.is_some(),
self.near.is_some(),
self.contains.is_some(),
self.matches.is_some(),
self.exists.is_some(),
self.len.is_some(),
]
.iter()
.filter(|b| **b)
.count()
}
pub fn check(&self, result: &Value) -> Result<(), String> {
let actual = result.pointer(&self.path);
if let Some(exists) = self.exists {
return if actual.is_some() == exists {
Ok(())
} else {
Err(format!("{}: exists == {} expected {exists}", self.path, actual.is_some()))
};
}
let Some(actual) = actual else {
return Err(format!("{}: no value at that path", self.path));
};
let num = |v: &Value| v.as_f64();
let shown = {
let t = actual.to_string();
if t.chars().count() > 160 {
format!("{}…", t.chars().take(160).collect::<String>())
} else {
t
}
};
let fail = |what: &str| Err(format!("{}: {what}; actual {shown}", self.path));
if let Some(e) = &self.eq {
return if actual == e { Ok(()) } else { fail(&format!("expected {e}")) };
}
if let Some(e) = &self.ne {
return if actual != e { Ok(()) } else { fail(&format!("expected not {e}")) };
}
if let Some(b) = self.gt {
return match num(actual) { Some(a) if a > b => Ok(()), _ => fail(&format!("expected > {b}")) };
}
if let Some(b) = self.gte {
return match num(actual) { Some(a) if a >= b => Ok(()), _ => fail(&format!("expected >= {b}")) };
}
if let Some(b) = self.lt {
return match num(actual) { Some(a) if a < b => Ok(()), _ => fail(&format!("expected < {b}")) };
}
if let Some(b) = self.lte {
return match num(actual) { Some(a) if a <= b => Ok(()), _ => fail(&format!("expected <= {b}")) };
}
if let Some(b) = self.near {
let tol = self.tol.unwrap_or(1e-6);
return match num(actual) {
Some(a) if (a - b).abs() <= tol * b.abs().max(1.0) => Ok(()),
_ => fail(&format!("expected within {tol} (relative) of {b}")),
};
}
if let Some(needle) = &self.contains {
let ok = match (actual, needle) {
(Value::String(s), Value::String(n)) => s.contains(n.as_str()),
(Value::Array(a), n) => a.contains(n),
(Value::Object(o), Value::String(n)) => o.contains_key(n),
_ => false,
};
return if ok { Ok(()) } else { fail(&format!("expected to contain {needle}")) };
}
if let Some(pattern) = &self.matches {
let Some(s) = actual.as_str() else { return fail("expected a string to match") };
return if glob_match(pattern, s) { Ok(()) } else { fail(&format!("expected to match `{pattern}`")) };
}
if let Some(n) = self.len {
let l = match actual {
Value::Array(a) => Some(a.len()),
Value::String(s) => Some(s.chars().count()),
Value::Object(o) => Some(o.len()),
_ => None,
};
return match l { Some(l) if l == n => Ok(()), _ => fail(&format!("expected length {n}")) };
}
Err(format!("{}: no operator", self.path))
}
}
pub fn glob_match(pattern: &str, text: &str) -> bool {
fn rec(p: &[char], t: &[char]) -> bool {
match (p.first(), t.first()) {
(None, None) => true,
(Some('*'), _) => rec(&p[1..], t) || (!t.is_empty() && rec(p, &t[1..])),
(Some('?'), Some(_)) => rec(&p[1..], &t[1..]),
(Some(a), Some(b)) if a == b => rec(&p[1..], &t[1..]),
_ => false,
}
}
let p: Vec<char> = pattern.chars().collect();
let t: Vec<char> = text.chars().collect();
rec(&p, &t)
}