1use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
12#[serde(deny_unknown_fields)]
13pub struct Script {
14 pub name: String,
15 #[serde(default, skip_serializing_if = "Option::is_none")]
17 pub note: Option<String>,
18 #[serde(default = "default_backend")]
19 pub backend: String,
20 #[serde(default = "default_size")]
21 pub size: [f32; 2],
22 #[serde(default = "default_ppp")]
23 pub ppp: f32,
24 #[serde(default)]
26 pub seed: bool,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub document: Option<String>,
30 #[serde(default = "default_timeout")]
31 pub timeout_ms: u64,
32 pub steps: Vec<Step>,
33}
34
35fn default_backend() -> String {
36 "headless".into()
37}
38fn default_size() -> [f32; 2] {
39 [1400.0, 960.0]
40}
41fn default_ppp() -> f32 {
42 1.0
43}
44fn default_timeout() -> u64 {
45 120_000
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
49#[serde(deny_unknown_fields)]
50pub struct Step {
51 pub tool: String,
52 #[serde(default)]
53 pub args: Value,
54 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub expect: Vec<Expect>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
58 pub save: Option<String>,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
60 pub compare: Option<Compare>,
61 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub note: Option<String>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub expect_error: Option<String>,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
71#[serde(deny_unknown_fields)]
72pub struct Compare {
73 pub baseline: String,
75 #[serde(default = "default_max_diff")]
76 pub max_diff_ratio: f64,
77}
78
79fn default_max_diff() -> f64 {
80 0.002
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, schemars::JsonSchema)]
86#[serde(deny_unknown_fields)]
87pub struct Expect {
88 pub path: String,
89 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "present")]
90 pub eq: Option<Value>,
91 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "present")]
92 pub ne: Option<Value>,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
94 pub gt: Option<f64>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub gte: Option<f64>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub lt: Option<f64>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub lte: Option<f64>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub near: Option<f64>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub tol: Option<f64>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub contains: Option<Value>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub matches: Option<String>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub exists: Option<bool>,
111 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub len: Option<usize>,
113}
114
115fn present<'de, D>(deserializer: D) -> Result<Option<Value>, D::Error>
123where
124 D: serde::Deserializer<'de>,
125{
126 Value::deserialize(deserializer).map(Some)
127}
128
129pub mod exit {
131 pub const PASS: i32 = 0;
132 pub const EXPECTATION_FAILED: i32 = 1;
133 pub const TOOL_ERROR: i32 = 2;
134 pub const HOST_ERROR: i32 = 3;
135 pub const TIMEOUT: i32 = 4;
136}
137
138impl Script {
139 pub fn parse(text: &str) -> Result<Self, String> {
140 let s: Script = serde_json::from_str(text).map_err(|e| format!("script parse: {e}"))?;
141 if s.steps.is_empty() {
142 return Err("script has no steps".into());
143 }
144 for (i, step) in s.steps.iter().enumerate() {
145 for (j, e) in step.expect.iter().enumerate() {
146 if e.operator_count() != 1 {
147 return Err(format!(
148 "step {i} (`{}`) expect {j}: exactly one operator is required",
149 step.tool
150 ));
151 }
152 if !e.path.starts_with('/') && !e.path.is_empty() {
153 return Err(format!(
154 "step {i} expect {j}: `path` must be a JSON pointer starting with `/` (got `{}`)",
155 e.path
156 ));
157 }
158 }
159 }
160 Ok(s)
161 }
162
163 pub fn json_schema() -> Value {
166 serde_json::to_value(schemars::schema_for!(Script)).unwrap_or(Value::Null)
167 }
168}
169
170impl Expect {
171 fn operator_count(&self) -> usize {
172 [
173 self.eq.is_some(),
174 self.ne.is_some(),
175 self.gt.is_some(),
176 self.gte.is_some(),
177 self.lt.is_some(),
178 self.lte.is_some(),
179 self.near.is_some(),
180 self.contains.is_some(),
181 self.matches.is_some(),
182 self.exists.is_some(),
183 self.len.is_some(),
184 ]
185 .iter()
186 .filter(|b| **b)
187 .count()
188 }
189
190 pub fn check(&self, result: &Value) -> Result<(), String> {
193 let actual = result.pointer(&self.path);
194 if let Some(exists) = self.exists {
195 return if actual.is_some() == exists {
196 Ok(())
197 } else {
198 Err(format!("{}: exists == {} expected {exists}", self.path, actual.is_some()))
199 };
200 }
201 let Some(actual) = actual else {
202 return Err(format!("{}: no value at that path", self.path));
203 };
204 let num = |v: &Value| v.as_f64();
205 let shown = {
206 let t = actual.to_string();
207 if t.chars().count() > 160 {
208 format!("{}…", t.chars().take(160).collect::<String>())
209 } else {
210 t
211 }
212 };
213 let fail = |what: &str| Err(format!("{}: {what}; actual {shown}", self.path));
214 if let Some(e) = &self.eq {
215 return if actual == e { Ok(()) } else { fail(&format!("expected {e}")) };
216 }
217 if let Some(e) = &self.ne {
218 return if actual != e { Ok(()) } else { fail(&format!("expected not {e}")) };
219 }
220 if let Some(b) = self.gt {
221 return match num(actual) { Some(a) if a > b => Ok(()), _ => fail(&format!("expected > {b}")) };
222 }
223 if let Some(b) = self.gte {
224 return match num(actual) { Some(a) if a >= b => Ok(()), _ => fail(&format!("expected >= {b}")) };
225 }
226 if let Some(b) = self.lt {
227 return match num(actual) { Some(a) if a < b => Ok(()), _ => fail(&format!("expected < {b}")) };
228 }
229 if let Some(b) = self.lte {
230 return match num(actual) { Some(a) if a <= b => Ok(()), _ => fail(&format!("expected <= {b}")) };
231 }
232 if let Some(b) = self.near {
233 let tol = self.tol.unwrap_or(1e-6);
234 return match num(actual) {
235 Some(a) if (a - b).abs() <= tol * b.abs().max(1.0) => Ok(()),
236 _ => fail(&format!("expected within {tol} (relative) of {b}")),
237 };
238 }
239 if let Some(needle) = &self.contains {
240 let ok = match (actual, needle) {
241 (Value::String(s), Value::String(n)) => s.contains(n.as_str()),
242 (Value::Array(a), n) => a.contains(n),
243 (Value::Object(o), Value::String(n)) => o.contains_key(n),
244 _ => false,
245 };
246 return if ok { Ok(()) } else { fail(&format!("expected to contain {needle}")) };
247 }
248 if let Some(pattern) = &self.matches {
249 let Some(s) = actual.as_str() else { return fail("expected a string to match") };
250 return if glob_match(pattern, s) { Ok(()) } else { fail(&format!("expected to match `{pattern}`")) };
251 }
252 if let Some(n) = self.len {
253 let l = match actual {
254 Value::Array(a) => Some(a.len()),
255 Value::String(s) => Some(s.chars().count()),
256 Value::Object(o) => Some(o.len()),
257 _ => None,
258 };
259 return match l { Some(l) if l == n => Ok(()), _ => fail(&format!("expected length {n}")) };
260 }
261 Err(format!("{}: no operator", self.path))
262 }
263}
264
265pub fn glob_match(pattern: &str, text: &str) -> bool {
268 fn rec(p: &[char], t: &[char]) -> bool {
269 match (p.first(), t.first()) {
270 (None, None) => true,
271 (Some('*'), _) => rec(&p[1..], t) || (!t.is_empty() && rec(p, &t[1..])),
272 (Some('?'), Some(_)) => rec(&p[1..], &t[1..]),
273 (Some(a), Some(b)) if a == b => rec(&p[1..], &t[1..]),
274 _ => false,
275 }
276 }
277 let p: Vec<char> = pattern.chars().collect();
278 let t: Vec<char> = text.chars().collect();
279 rec(&p, &t)
280}
281
282