1use crate::check::{check_all, interpolate, Expect};
9use crate::config::QaConfig;
10use crate::control::{self, Control, LaunchSpec, Mode};
11use crate::engine::{CliOptions, EngineTarget, Sidecar};
12use crate::mock::{MockServer, Route};
13use crate::page;
14use crate::process::Tracker;
15use crate::util::{err, sleep_ms, Error, Result};
16use crate::workspace::QaWorkspace;
17use crate::{media, util};
18use serde::Deserialize;
19use serde_json::{json, Map, Value};
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::time::{Duration, Instant};
23
24#[derive(Debug, Clone, Deserialize)]
25pub struct Scenario {
26 pub name: String,
27 #[serde(default = "fast")]
28 pub tier: String,
29 #[serde(default = "engine")]
31 pub target: String,
32 #[serde(default)]
34 pub requires: Vec<String>,
35 #[serde(default)]
38 pub optional: bool,
39 pub timeout_s: Option<u64>,
40 #[serde(default, rename = "step")]
41 pub steps: Vec<Step>,
42 #[serde(skip)]
43 pub file: PathBuf,
44}
45
46fn fast() -> String {
47 "fast".into()
48}
49fn engine() -> String {
50 "engine".into()
51}
52
53#[derive(Debug, Clone, Deserialize)]
54pub struct Step {
55 pub kind: String,
56 pub label: Option<String>,
58 pub save: Option<String>,
59 #[serde(default)]
60 pub expect: Vec<Expect>,
61 pub until_timeout_s: Option<u64>,
63 pub interval_ms: Option<u64>,
64 #[serde(flatten)]
65 pub params: BTreeMap<String, Value>,
66}
67
68impl Scenario {
69 pub fn load(path: &Path) -> Result<Self> {
70 let text = std::fs::read_to_string(path)
71 .map_err(|e| Error(format!("cannot read {}: {e}", path.display())))?;
72 let mut s: Scenario =
73 toml::from_str(&text).map_err(|e| Error(format!("{}: {e}", path.display())))?;
74 s.file = path.to_path_buf();
75 crate::config::validate_tier_list(&s.tier)?;
76 if !["engine", "ui", "browser"].contains(&s.target.as_str()) {
77 return err(format!(
78 "{}: target must be engine|ui|browser",
79 path.display()
80 ));
81 }
82 if s.steps.is_empty() {
83 return err(format!("{}: scenario has no steps", path.display()));
84 }
85 Ok(s)
86 }
87}
88
89pub fn discover(dirs: &[PathBuf]) -> Result<Vec<PathBuf>> {
90 let mut out = vec![];
91 for d in dirs {
92 if d.is_file() {
93 out.push(d.clone());
94 continue;
95 }
96 let Ok(rd) = std::fs::read_dir(d) else {
97 continue;
98 };
99 for e in rd.flatten() {
100 let p = e.path();
101 if p.file_name()
102 .and_then(|n| n.to_str())
103 .map(|n| n.ends_with(".scenario.toml"))
104 .unwrap_or(false)
105 {
106 out.push(p);
107 }
108 }
109 }
110 out.sort();
111 Ok(out)
112}
113
114pub fn tier_selected(scenario_tier: &str, selected: &str) -> bool {
116 let sel: Vec<&str> = selected.split(',').map(str::trim).collect();
117 sel.contains(&"all")
118 || scenario_tier
119 .split(',')
120 .map(str::trim)
121 .any(|t| sel.contains(&t))
122}
123
124pub fn unmet_requirement(requires: &[String], app_root: &Path) -> Option<String> {
127 unmet_requirement_with(requires, app_root, &|k| {
128 std::env::var(k).ok().filter(|v| !v.is_empty())
129 })
130}
131
132pub fn unmet_requirement_with(
137 requires: &[String],
138 app_root: &Path,
139 lookup: &dyn Fn(&str) -> Option<String>,
140) -> Option<String> {
141 for r in requires {
142 let (kind, value) = r.split_once(':').unwrap_or((r.as_str(), ""));
143 let missing = match kind {
144 "env" => lookup(value)
145 .is_none()
146 .then(|| format!("missing env {value}")),
147 "file" => (!app_root.join(value).exists() && !Path::new(value).exists())
148 .then(|| format!("missing file {value}")),
149 "cmd" => (!command_exists(value)).then(|| format!("missing command {value}")),
150 "platform" => (crate::util::platform().ok() != Some(value))
151 .then(|| format!("platform is not {value}")),
152 other => Some(format!("unknown requirement kind '{other}'")),
153 };
154 if missing.is_some() {
155 return missing;
156 }
157 }
158 None
159}
160
161fn command_exists(name: &str) -> bool {
162 if name.contains(std::path::MAIN_SEPARATOR) {
163 return Path::new(name).exists();
164 }
165 let exts: Vec<String> = if cfg!(windows) {
166 vec![".exe".into(), ".cmd".into(), ".bat".into(), String::new()]
167 } else {
168 vec![String::new()]
169 };
170 std::env::var_os("PATH")
171 .map(|p| {
172 std::env::split_paths(&p)
173 .any(|d| exts.iter().any(|e| d.join(format!("{name}{e}")).is_file()))
174 })
175 .unwrap_or(false)
176}
177
178pub struct Ctx<'a> {
181 pub cfg: &'a QaConfig,
182 pub engine: Option<EngineTarget>,
183 pub ui_binary: Option<PathBuf>,
184 pub ws: &'a QaWorkspace,
185 pub tracker: &'a Tracker,
186 pub scenario: String,
187 pub data_dir: PathBuf,
188 pub log_dir: PathBuf,
189 pub vars: BTreeMap<String, Value>,
190 pub sidecars: BTreeMap<String, Sidecar>,
191 pub mocks: BTreeMap<String, MockServer>,
192 pub ui: Option<(Control, Mode)>,
193 #[cfg(feature = "browser")]
194 pub browser: Option<crate::browser::BrowserTarget>,
195 pub artifacts: Vec<(String, PathBuf)>,
197 step_no: usize,
198}
199
200impl<'a> Ctx<'a> {
201 pub fn new(
202 cfg: &'a QaConfig,
203 engine: Option<EngineTarget>,
204 ui_binary: Option<PathBuf>,
205 ws: &'a QaWorkspace,
206 tracker: &'a Tracker,
207 scenario: &str,
208 ) -> Result<Self> {
209 let data_dir = ws.scenario_dir(scenario)?;
210 let log_dir = ws
211 .evidence_dir
212 .join(data_dir.file_name().unwrap_or_default());
213 std::fs::create_dir_all(&log_dir)?;
214 let mut vars = BTreeMap::new();
215 vars.insert("data_dir".into(), json!(data_dir.to_string_lossy()));
216 vars.insert("evidence_dir".into(), json!(log_dir.to_string_lossy()));
217 vars.insert("app_root".into(), json!(cfg.app_root.to_string_lossy()));
218 vars.insert(
219 "engine".into(),
220 json!(engine
221 .as_ref()
222 .map(|e| e.binary.to_string_lossy().to_string())),
223 );
224 Ok(Self {
225 cfg,
226 engine,
227 ui_binary,
228 ws,
229 tracker,
230 scenario: scenario.into(),
231 data_dir,
232 log_dir,
233 vars,
234 sidecars: BTreeMap::new(),
235 mocks: BTreeMap::new(),
236 ui: None,
237 #[cfg(feature = "browser")]
238 browser: None,
239 artifacts: vec![],
240 step_no: 0,
241 })
242 }
243
244 fn engine(&self) -> Result<&EngineTarget> {
245 self.engine.as_ref().ok_or_else(|| Error("no engine binary configured (set [engine].binary, RIGHTKIT_QA_ENGINE, or pass --build)".into()))
246 }
247
248 fn ui(&self) -> Result<&Control> {
249 self.ui
250 .as_ref()
251 .map(|(c, _)| c)
252 .ok_or_else(|| Error("no UI app launched; add a ui_launch step first".into()))
253 }
254
255 fn artifact(&mut self, name: &str, path: PathBuf) {
256 self.artifacts
257 .push((format!("{}/{}", self.scenario, name), path));
258 }
259
260 pub fn teardown(&mut self) -> Vec<String> {
263 let mut problems = vec![];
264 #[cfg(feature = "browser")]
265 if let Some(mut b) = self.browser.take() {
266 b.stop();
267 }
268 if let Some((mut c, mode)) = self.ui.take() {
269 match c.stop() {
270 Ok(r) => {
271 if !r.process_gone || !r.endpoint_closed {
272 problems.push(format!(
273 "UI app pid {} not fully stopped (process_gone={}, endpoint_closed={})",
274 r.pid, r.process_gone, r.endpoint_closed
275 ));
276 }
277 if mode != Mode::Visible && !r.owned_never_frontmost {
278 problems.push(format!(
279 "owned UI process reached foreground: {:?}",
280 r.owned_frontmost_pids
281 ));
282 }
283 }
284 Err(e) => problems.push(e.0),
285 }
286 }
287 for (_, mut s) in std::mem::take(&mut self.sidecars) {
288 s.close();
289 }
290 self.mocks.clear();
291 if let Err(e) = self.tracker.assert_no_orphans() {
292 problems.push(e.0);
293 }
294 problems
295 }
296}
297
298pub(crate) fn pv<'p>(p: &'p Map<String, Value>, k: &str) -> Option<&'p Value> {
301 p.get(k).filter(|v| !v.is_null())
302}
303pub(crate) fn ps(p: &Map<String, Value>, k: &str) -> Result<String> {
304 pv(p, k)
305 .and_then(Value::as_str)
306 .map(String::from)
307 .ok_or_else(|| Error(format!("step needs string '{k}'")))
308}
309pub(crate) fn ps_opt(p: &Map<String, Value>, k: &str) -> Option<String> {
310 pv(p, k).and_then(Value::as_str).map(String::from)
311}
312pub(crate) fn pf(p: &Map<String, Value>, k: &str) -> Result<f64> {
313 pv(p, k)
314 .and_then(Value::as_f64)
315 .ok_or_else(|| Error(format!("step needs number '{k}'")))
316}
317pub(crate) fn pf_or(p: &Map<String, Value>, k: &str, d: f64) -> f64 {
318 pv(p, k).and_then(Value::as_f64).unwrap_or(d)
319}
320pub(crate) fn pb(p: &Map<String, Value>, k: &str) -> bool {
321 pv(p, k).and_then(Value::as_bool).unwrap_or(false)
322}
323pub(crate) fn dur(p: &Map<String, Value>, k: &str, default_s: f64) -> Duration {
324 Duration::from_millis((pf_or(p, k, default_s) * 1000.0) as u64)
325}
326fn resolve(ctx: &Ctx, s: &str) -> PathBuf {
327 let p = Path::new(s);
328 if p.is_absolute() {
329 p.to_path_buf()
330 } else {
331 ctx.data_dir.join(p)
332 }
333}
334fn env_pairs(p: &Map<String, Value>) -> Vec<(String, String)> {
335 pv(p, "env")
336 .and_then(Value::as_object)
337 .map(|m| {
338 m.iter()
339 .map(|(k, v)| {
340 (
341 k.clone(),
342 v.as_str()
343 .map(String::from)
344 .unwrap_or_else(|| v.to_string()),
345 )
346 })
347 .collect()
348 })
349 .unwrap_or_default()
350}
351fn cli_opts(ctx: &Ctx, p: &Map<String, Value>) -> CliOptions {
352 CliOptions {
353 data_dir: Some(ctx.data_dir.clone()),
354 env: env_pairs(p),
355 input: pv(p, "input").cloned(),
356 timeout: Some(dur(p, "timeout_s", 120.0)),
357 }
358}
359
360#[derive(Debug, Clone)]
363pub struct StepRecord {
364 pub index: usize,
365 pub kind: String,
366 pub name: String,
367 pub ok: bool,
368 pub ms: u128,
369 pub error: Option<String>,
370}
371
372pub fn run_step(ctx: &mut Ctx, step: &Step) -> Result<Value> {
374 ctx.step_no += 1;
375 let started = Instant::now();
376 let until = step.until_timeout_s.map(Duration::from_secs);
377 let interval = Duration::from_millis(step.interval_ms.unwrap_or(500));
378 loop {
379 let attempt: Result<Value> = (|| {
380 let resolved = interpolate(
381 &Value::Object(step.params.clone().into_iter().collect()),
382 &ctx.vars,
383 )?;
384 let params = resolved.as_object().cloned().unwrap_or_default();
385 let result = exec(ctx, &step.kind, ¶ms)?;
386 let expects: Vec<Expect> = step
387 .expect
388 .iter()
389 .map(|e| resolve_expect(e, &ctx.vars))
390 .collect::<Result<_>>()?;
391 check_all(&result, &expects)?;
392 Ok(result)
393 })();
394 match attempt {
395 Ok(v) => {
396 if let Some(name) = &step.save {
397 ctx.vars.insert(name.clone(), v.clone());
398 }
399 return Ok(v);
400 }
401 Err(e) => match until {
402 Some(limit) if started.elapsed() < limit => std::thread::sleep(interval),
403 _ => {
404 return Err(Error(format!(
405 "step {} ({}): {}",
406 ctx.step_no, step.kind, e.0
407 )))
408 }
409 },
410 }
411 }
412}
413
414fn resolve_expect(e: &Expect, vars: &BTreeMap<String, Value>) -> Result<Expect> {
415 let mut out = e.clone();
416 if let Some(v) = &e.eq {
417 out.eq = Some(interpolate(v, vars)?);
418 }
419 if let Some(v) = &e.ne {
420 out.ne = Some(interpolate(v, vars)?);
421 }
422 if let Some(v) = &e.contains {
423 out.contains = Some(interpolate(v, vars)?);
424 }
425 Ok(out)
426}
427
428fn exec(ctx: &mut Ctx, kind: &str, p: &Map<String, Value>) -> Result<Value> {
429 match kind {
430 "cli" => {
432 let args: Vec<String> = match (pv(p, "args"), ps_opt(p, "command")) {
433 (Some(Value::Array(a)), _) => a
434 .iter()
435 .map(|v| {
436 v.as_str()
437 .map(String::from)
438 .unwrap_or_else(|| v.to_string())
439 })
440 .collect(),
441 (_, Some(c)) => c.split_whitespace().map(String::from).collect(),
442 _ => return err("cli step needs 'command' or 'args'"),
443 };
444 let r = ctx.engine()?.cli(&args, &cli_opts(ctx, p), ctx.tracker)?;
445 let log = ctx.log_dir.join(format!("{:02}-cli.log", ctx.step_no));
446 std::fs::write(
447 &log,
448 format!(
449 "$ {}\nexit {:?}\n--- stdout\n{}\n--- stderr\n{}\n",
450 args.join(" "),
451 r.code,
452 r.stdout,
453 r.stderr
454 ),
455 )?;
456 ctx.artifact(&format!("{:02}-cli.log", ctx.step_no), log);
457 let ok = r.code == Some(0) && r.json.get("error").map(Value::is_null).unwrap_or(true);
458 if !ok && !pb(p, "allow_error") {
459 return err(format!(
460 "engine `{}` failed (exit {:?}{}): stdout {} stderr tail {}",
461 args.join(" "),
462 r.code,
463 if r.timed_out { ", timed out" } else { "" },
464 crate::util::tail(&r.stdout, 600),
465 crate::util::tail(&r.stderr, 600)
466 ));
467 }
468 Ok(r.to_value())
469 }
470 "sidecar_start" => {
471 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
472 let s = ctx
473 .engine()?
474 .start_sidecar(&cli_opts(ctx, p), ctx.tracker)?;
475 let hello = s.hello.clone();
476 ctx.sidecars.insert(id, s);
477 Ok(hello)
478 }
479 "sidecar_command" => {
480 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
481 let name = ps(p, "name")?;
482 let input = pv(p, "input").cloned().unwrap_or_else(|| json!({}));
483 let timeout = dur(p, "timeout_s", 120.0);
484 let s = ctx
485 .sidecars
486 .get_mut(&id)
487 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?;
488 match s.command(&name, input, timeout) {
489 Ok(v) => Ok(v),
490 Err(e) if pb(p, "allow_error") => Ok(json!({"error": e.0})),
491 Err(e) => Err(e),
492 }
493 }
494 "sidecar_raw" => {
495 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
496 let frame = pv(p, "frame")
497 .cloned()
498 .ok_or_else(|| Error("sidecar_raw needs 'frame'".into()))?;
499 let timeout = dur(p, "timeout_s", 60.0);
500 ctx.sidecars
501 .get_mut(&id)
502 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?
503 .raw(frame, timeout)
504 }
505 "sidecar_close" => {
506 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
507 let mut s = ctx
508 .sidecars
509 .remove(&id)
510 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?;
511 let code = s.close();
512 Ok(json!({"exit_code": code, "stderr_tail": s.stderr_tail()}))
513 }
514 "mock_start" => {
516 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
517 let routes = pv(p, "routes")
518 .and_then(Value::as_array)
519 .map(|a| {
520 a.iter()
521 .map(|r| Route {
522 method: r["method"].as_str().map(String::from),
523 path: r["path"].as_str().unwrap_or("/").to_string(),
524 status: r["status"].as_u64().unwrap_or(200) as u16,
525 body: r
526 .get("json")
527 .or_else(|| r.get("body"))
528 .cloned()
529 .unwrap_or(Value::Null),
530 content_type: r["content_type"].as_str().map(String::from),
531 })
532 .collect()
533 })
534 .unwrap_or_default();
535 let m = MockServer::start(routes)?;
536 let base = m.base.clone();
537 ctx.mocks.insert(id, m);
538 Ok(json!({"base": base}))
539 }
540 "mock_requests" => {
541 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
542 let m = ctx
543 .mocks
544 .get(&id)
545 .ok_or_else(|| Error(format!("no mock '{id}'")))?;
546 Ok(json!({"requests": m.seen().iter().map(|s| s.to_value()).collect::<Vec<_>>()}))
547 }
548 "mock_stop" => {
549 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
550 ctx.mocks.remove(&id);
551 Ok(json!({"stopped": true}))
552 }
553 "write_wav" => {
555 let path = resolve(ctx, &ps(p, "path")?);
556 media::write_reference_wav(&path, pf_or(p, "seconds", 6.0))?;
557 Ok(json!({"path": path.to_string_lossy()}))
558 }
559 "write_file" => {
560 let path = resolve(ctx, &ps(p, "path")?);
561 if let Some(d) = path.parent() {
562 std::fs::create_dir_all(d)?;
563 }
564 std::fs::write(&path, ps_opt(p, "content").unwrap_or_default())?;
565 Ok(json!({"path": path.to_string_lossy()}))
566 }
567 "file_stat" => Ok(media::file_stat(&resolve(ctx, &ps(p, "path")?))),
568 "wav_stats" => media::wav_stats(&resolve(ctx, &ps(p, "path")?)),
569 "png_size" => media::png_size(&resolve(ctx, &ps(p, "path")?)),
570 "ffprobe" => match media::ffprobe(&resolve(ctx, &ps(p, "path")?), ctx.tracker) {
571 Ok(v) => Ok(v),
572 Err(e) if pb(p, "optional") => Ok(json!({"skipped": e.0})),
573 Err(e) => Err(e),
574 },
575 "set" => Ok(pv(p, "value").cloned().unwrap_or(Value::Null)),
577 "sleep" => {
578 sleep_ms(pf_or(p, "ms", 100.0) as u64);
579 Ok(json!({"slept_ms": pf_or(p, "ms", 100.0)}))
580 }
581 "assert_no_orphans" => {
582 ctx.tracker.assert_no_orphans()?;
583 Ok(json!({"alive": 0}))
584 }
585 "ui_launch" => {
587 if ctx.ui.is_some() {
588 return err("a UI app is already running in this scenario");
589 }
590 let binary = match ps_opt(p, "binary") {
591 Some(b) => ctx.cfg.app_root.join(b),
592 None => ctx.ui_binary.clone().ok_or_else(|| Error("no UI binary configured: set [ui].binaries, pass --ui-binary, or `binary` on the step".into()))?,
593 };
594 let mode = Mode::parse(&ps_opt(p, "mode").unwrap_or_else(|| ctx.cfg.ui.mode.clone()))?;
595 let mut env: Vec<(String, String)> = ctx
596 .cfg
597 .ui
598 .env
599 .iter()
600 .map(|(k, v)| (k.clone(), v.clone()))
601 .collect();
602 env.extend(ctx.cfg.env.iter().map(|(k, v)| (k.clone(), v.clone())));
603 if let Some(var) = &ctx.cfg.engine.data_dir_env {
604 env.push((var.clone(), ctx.data_dir.to_string_lossy().into()));
605 }
606 env.extend(env_pairs(p));
607 let spec = LaunchSpec {
608 binary,
609 mode,
610 env,
611 startup_timeout: Duration::from_secs(ctx.cfg.ui.startup_timeout_s.unwrap_or(90)),
612 label: format!("{} app", ctx.cfg.app),
613 };
614 let c = control::launch(&spec, ctx.ws, ctx.tracker)?;
615 let out = json!({"pid": c.pid, "service": c.service, "mode": format!("{mode:?}").to_lowercase(), "health": c.health()?, "log": c.log_path.to_string_lossy()});
616 let log = c.log_path.clone();
617 ctx.ui = Some((c, mode));
618 ctx.artifact("app.log", log);
619 Ok(out)
620 }
621 "ui_stop" => {
622 let (mut c, mode) = ctx
623 .ui
624 .take()
625 .ok_or_else(|| Error("no UI app running".into()))?;
626 let r = c.stop()?;
627 if !r.process_gone || !r.endpoint_closed {
628 return err(format!(
629 "app pid {} not fully stopped (process_gone={}, endpoint_closed={})",
630 r.pid, r.process_gone, r.endpoint_closed
631 ));
632 }
633 if mode != Mode::Visible && !r.owned_never_frontmost {
634 return err(format!(
635 "owned UI process reached foreground: {:?}",
636 r.owned_frontmost_pids
637 ));
638 }
639 Ok(
640 json!({"pid": r.pid, "endpoint_closed": r.endpoint_closed, "process_gone": r.process_gone, "frontmost_unchanged": r.frontmost_unchanged, "owned_never_frontmost": r.owned_never_frontmost, "owned_frontmost_pids": r.owned_frontmost_pids}),
641 )
642 }
643 "ui_auth_probe" => {
644 let c = ctx.ui()?;
645 Ok(json!({"unlisted_app_error": c.unlisted_app_probe()?}))
646 }
647 "ui_click" => {
648 let c = ctx.ui()?;
649 let button = ps_opt(p, "button").unwrap_or_else(|| "left".into());
650 let count = pf_or(p, "count", 1.0) as u32;
651 let (x, y) = match ps_opt(p, "selector") {
652 Some(sel) => c.center_of(&sel)?,
653 None => (pf(p, "x")?, pf(p, "y")?),
654 };
655 c.click(x, y, &button, count)?;
656 Ok(json!({"x": x, "y": y}))
657 }
658 "ui_move" => {
659 let c = ctx.ui()?;
660 let (x, y) = match ps_opt(p, "selector") {
661 Some(sel) => c.center_of(&sel)?,
662 None => (pf(p, "x")?, pf(p, "y")?),
663 };
664 c.move_to(x, y)?;
665 Ok(json!({"x": x, "y": y}))
666 }
667 "ui_drag" => {
668 let c = ctx.ui()?;
669 let from = match ps_opt(p, "from_selector") {
670 Some(s) => c.center_of(&s)?,
671 None => (pf(p, "x1")?, pf(p, "y1")?),
672 };
673 let to = match ps_opt(p, "to_selector") {
674 Some(s) => c.center_of(&s)?,
675 None => (pf(p, "x2")?, pf(p, "y2")?),
676 };
677 c.drag(from, to, pf_or(p, "steps", 12.0) as u32)?;
678 Ok(json!({"from": [from.0, from.1], "to": [to.0, to.1]}))
679 }
680 "ui_wheel" => {
681 let c = ctx.ui()?;
682 let (x, y) = match ps_opt(p, "selector") {
683 Some(sel) => c.center_of(&sel)?,
684 None => (pf_or(p, "x", 100.0), pf_or(p, "y", 100.0)),
685 };
686 c.wheel(x, y, pf_or(p, "dx", 0.0), pf_or(p, "dy", 0.0))?;
687 Ok(json!({"x": x, "y": y}))
688 }
689 "ui_key" => {
690 ctx.ui()?.key(&ps(p, "key")?)?;
691 Ok(json!({"ok": true}))
692 }
693 "ui_type" => {
694 ctx.ui()?.type_text(&ps(p, "text")?)?;
695 Ok(json!({"ok": true}))
696 }
697 "ui_eval" => ctx.ui()?.eval(&ps(p, "js")?),
698 "ui_wait_eval" => ctx
699 .ui()?
700 .wait_eval(&ps(p, "js")?, dur(p, "timeout_s", 20.0)),
701 "ui_dom" => Ok(json!({"elements": ctx.ui()?.dom(&ps(p, "selector")?)?})),
702 "ui_ax" => Ok(json!({"nodes": ctx.ui()?.ax()?})),
703 "ui_command" => ctx
704 .ui()?
705 .command(&ps(p, "name")?, pv(p, "args").unwrap_or(&Value::Null)),
706 "ui_screenshot" => {
707 let name = ps_opt(p, "name").unwrap_or_else(|| format!("shot-{:02}", ctx.step_no));
708 let path = ctx.log_dir.join(format!("{name}.png"));
709 ctx.ui()?.screenshot_to(&path)?;
710 let size = media::png_size(&path)?;
711 ctx.artifact(&format!("{name}.png"), path.clone());
712 Ok(json!({"path": path.to_string_lossy(), "size": size}))
713 }
714 "ui_measure_paint" => {
715 let c = ctx.ui()?;
716 let trigger_sel = ps(p, "click_selector")?;
717 let id = format!("paint-{}", util::new_id());
718 let m = page::measure_paint(
719 c,
720 &mut || c.click_selector(&trigger_sel),
721 &id,
722 ps_opt(p, "selector").as_deref(),
723 pf_or(p, "settle_ms", page::DEFAULT_SETTLE_MS as f64) as u64,
724 pf_or(p, "timeout_ms", page::DEFAULT_PAINT_TIMEOUT_MS as f64) as u64,
725 )?;
726 if let Some(budget) = pv(p, "budget_ms").and_then(Value::as_f64) {
727 page::assert_paint_within(&m, budget, &trigger_sel)?;
728 }
729 Ok(
730 json!({"duration_ms": m.duration_ms, "settle_ms": m.settle_ms, "mutations": m.mutations, "animation_frames": m.animation_frames}),
731 )
732 }
733 "ui_assert_pane" => {
734 let g = page::read_pane_geometry(ctx.ui()?, &ps(p, "selector")?)?;
735 page::assert_active_pane(&g)?;
736 Ok(
737 json!({"x": g.x, "y": g.y, "width": g.width, "height": g.height, "matches": g.matches}),
738 )
739 }
740 "ui_resolve_frame" => {
741 let path = page::resolve_unique_frame(
742 ctx.ui()?,
743 &ps(p, "selector")?,
744 pf_or(p, "max_depth", 4.0) as usize,
745 )?;
746 Ok(json!({"frame": path, "described": page::describe_frame(&path)}))
747 }
748 "ui_eval_in_frame" => {
749 let frame: Vec<usize> = pv(p, "frame")
750 .and_then(Value::as_array)
751 .map(|a| {
752 a.iter()
753 .filter_map(|i| i.as_u64().map(|i| i as usize))
754 .collect()
755 })
756 .unwrap_or_default();
757 page::eval_in_frame(ctx.ui()?, &frame, &ps(p, "js")?)
758 }
759 #[cfg(feature = "browser")]
760 k if k.starts_with("browser_") => crate::browser::exec(ctx, k, p),
761 other => err(format!("unknown step kind '{other}'")),
762 }
763}
764
765#[cfg(feature = "browser")]
766pub(crate) mod params {
767 pub(crate) use super::{dur, pb, pf_or, ps, ps_opt, pv};
768}
769
770#[cfg(test)]
771mod tests {
772 use super::*;
773
774 #[test]
775 fn parses_scenario_toml_with_flattened_params_and_expects() {
776 let text = r#"
777name = "engine doctor"
778tier = "fast"
779[[step]]
780kind = "cli"
781command = "doctor"
782save = "doc"
783 [[step.expect]]
784 path = "json.contract_version"
785 gte = 2
786[[step]]
787kind = "cli"
788args = ["spend", "status"]
789input = { a = 1 }
790"#;
791 let path = std::env::temp_dir().join(format!("rkqa-{}.scenario.toml", util::new_id()));
792 std::fs::write(&path, text).unwrap();
793 let s = Scenario::load(&path).unwrap();
794 assert_eq!(s.steps.len(), 2);
795 assert_eq!(s.steps[0].params["command"], json!("doctor"));
796 assert_eq!(s.steps[0].expect[0].gte, Some(2.0));
797 assert_eq!(s.steps[1].params["input"], json!({"a": 1}));
798 let _ = std::fs::remove_file(path);
799 }
800
801 #[test]
802 fn tier_selection_and_requirements() {
803 assert!(tier_selected("fast", "fast"));
804 assert!(!tier_selected("paid", "fast"));
805 assert!(tier_selected("paid", "all"));
806 assert!(tier_selected("local-models", "fast,local-models"));
807 assert_eq!(
808 unmet_requirement(&["env:RKQA_SURELY_UNSET_VAR".into()], Path::new("/")).as_deref(),
809 Some("missing env RKQA_SURELY_UNSET_VAR")
810 );
811 assert_eq!(
812 unmet_requirement(&["file:/definitely/not/here".into()], Path::new("/")).as_deref(),
813 Some("missing file /definitely/not/here")
814 );
815 assert!(unmet_requirement(&[], Path::new("/")).is_none());
816 }
817}