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 app_evidence_dir = ws
216 .home
217 .join("evidence")
218 .join(data_dir.file_name().unwrap_or_default());
219 std::fs::create_dir_all(&app_evidence_dir)?;
220 let mut vars = BTreeMap::new();
221 vars.insert("data_dir".into(), json!(data_dir.to_string_lossy()));
222 vars.insert(
223 "evidence_dir".into(),
224 json!(app_evidence_dir.to_string_lossy()),
225 );
226 vars.insert("app_root".into(), json!(cfg.app_root.to_string_lossy()));
227 vars.insert(
228 "engine".into(),
229 json!(engine
230 .as_ref()
231 .map(|e| e.binary.to_string_lossy().to_string())),
232 );
233 Ok(Self {
234 cfg,
235 engine,
236 ui_binary,
237 ws,
238 tracker,
239 scenario: scenario.into(),
240 data_dir,
241 log_dir,
242 vars,
243 sidecars: BTreeMap::new(),
244 mocks: BTreeMap::new(),
245 ui: None,
246 #[cfg(feature = "browser")]
247 browser: None,
248 artifacts: vec![],
249 step_no: 0,
250 })
251 }
252
253 fn engine(&self) -> Result<&EngineTarget> {
254 self.engine.as_ref().ok_or_else(|| Error("no engine binary configured (set [engine].binary, RIGHTKIT_QA_ENGINE, or pass --build)".into()))
255 }
256
257 fn ui(&self) -> Result<&Control> {
258 self.ui
259 .as_ref()
260 .map(|(c, _)| c)
261 .ok_or_else(|| Error("no UI app launched; add a ui_launch step first".into()))
262 }
263
264 fn artifact(&mut self, name: &str, path: PathBuf) {
265 self.artifacts
266 .push((format!("{}/{}", self.scenario, name), path));
267 }
268
269 pub fn teardown(&mut self) -> Vec<String> {
272 let mut problems = vec![];
273 #[cfg(feature = "browser")]
274 if let Some(mut b) = self.browser.take() {
275 b.stop();
276 }
277 if let Some((mut c, mode)) = self.ui.take() {
278 match c.stop() {
279 Ok(r) => {
280 if !r.process_gone || !r.endpoint_closed {
281 problems.push(format!(
282 "UI app pid {} not fully stopped (process_gone={}, endpoint_closed={})",
283 r.pid, r.process_gone, r.endpoint_closed
284 ));
285 }
286 if mode != Mode::Visible && !r.owned_never_frontmost {
287 problems.push(format!(
288 "owned UI process reached foreground: {:?}",
289 r.owned_frontmost_pids
290 ));
291 }
292 }
293 Err(e) => problems.push(e.0),
294 }
295 }
296 for (_, mut s) in std::mem::take(&mut self.sidecars) {
297 s.close();
298 }
299 self.mocks.clear();
300 if let Err(e) = self.tracker.assert_no_orphans() {
301 problems.push(e.0);
302 }
303 problems
304 }
305}
306
307pub(crate) fn pv<'p>(p: &'p Map<String, Value>, k: &str) -> Option<&'p Value> {
310 p.get(k).filter(|v| !v.is_null())
311}
312pub(crate) fn ps(p: &Map<String, Value>, k: &str) -> Result<String> {
313 pv(p, k)
314 .and_then(Value::as_str)
315 .map(String::from)
316 .ok_or_else(|| Error(format!("step needs string '{k}'")))
317}
318pub(crate) fn ps_opt(p: &Map<String, Value>, k: &str) -> Option<String> {
319 pv(p, k).and_then(Value::as_str).map(String::from)
320}
321pub(crate) fn pf(p: &Map<String, Value>, k: &str) -> Result<f64> {
322 pv(p, k)
323 .and_then(Value::as_f64)
324 .ok_or_else(|| Error(format!("step needs number '{k}'")))
325}
326pub(crate) fn pf_or(p: &Map<String, Value>, k: &str, d: f64) -> f64 {
327 pv(p, k).and_then(Value::as_f64).unwrap_or(d)
328}
329pub(crate) fn pb(p: &Map<String, Value>, k: &str) -> bool {
330 pv(p, k).and_then(Value::as_bool).unwrap_or(false)
331}
332pub(crate) fn dur(p: &Map<String, Value>, k: &str, default_s: f64) -> Duration {
333 Duration::from_millis((pf_or(p, k, default_s) * 1000.0) as u64)
334}
335fn resolve(ctx: &Ctx, s: &str) -> PathBuf {
336 let p = Path::new(s);
337 if p.is_absolute() {
338 p.to_path_buf()
339 } else {
340 ctx.data_dir.join(p)
341 }
342}
343fn env_pairs(p: &Map<String, Value>) -> Vec<(String, String)> {
344 pv(p, "env")
345 .and_then(Value::as_object)
346 .map(|m| {
347 m.iter()
348 .map(|(k, v)| {
349 (
350 k.clone(),
351 v.as_str()
352 .map(String::from)
353 .unwrap_or_else(|| v.to_string()),
354 )
355 })
356 .collect()
357 })
358 .unwrap_or_default()
359}
360fn cli_opts(ctx: &Ctx, p: &Map<String, Value>) -> CliOptions {
361 CliOptions {
362 data_dir: Some(ctx.data_dir.clone()),
363 env: env_pairs(p),
364 input: pv(p, "input").cloned(),
365 timeout: Some(dur(p, "timeout_s", 120.0)),
366 }
367}
368
369#[derive(Debug, Clone)]
372pub struct StepRecord {
373 pub index: usize,
374 pub kind: String,
375 pub name: String,
376 pub ok: bool,
377 pub ms: u128,
378 pub error: Option<String>,
379}
380
381pub fn run_step(ctx: &mut Ctx, step: &Step) -> Result<Value> {
383 ctx.step_no += 1;
384 let started = Instant::now();
385 let until = step.until_timeout_s.map(Duration::from_secs);
386 let interval = Duration::from_millis(step.interval_ms.unwrap_or(500));
387 loop {
388 let attempt: Result<Value> = (|| {
389 let resolved = interpolate(
390 &Value::Object(step.params.clone().into_iter().collect()),
391 &ctx.vars,
392 )?;
393 let params = resolved.as_object().cloned().unwrap_or_default();
394 let result = exec(ctx, &step.kind, ¶ms)?;
395 let expects: Vec<Expect> = step
396 .expect
397 .iter()
398 .map(|e| resolve_expect(e, &ctx.vars))
399 .collect::<Result<_>>()?;
400 check_all(&result, &expects)?;
401 Ok(result)
402 })();
403 match attempt {
404 Ok(v) => {
405 if let Some(name) = &step.save {
406 ctx.vars.insert(name.clone(), v.clone());
407 }
408 return Ok(v);
409 }
410 Err(e) => match until {
411 Some(limit) if started.elapsed() < limit => std::thread::sleep(interval),
412 _ => {
413 return Err(Error(format!(
414 "step {} ({}): {}",
415 ctx.step_no, step.kind, e.0
416 )))
417 }
418 },
419 }
420 }
421}
422
423fn resolve_expect(e: &Expect, vars: &BTreeMap<String, Value>) -> Result<Expect> {
424 let mut out = e.clone();
425 if let Some(v) = &e.eq {
426 out.eq = Some(interpolate(v, vars)?);
427 }
428 if let Some(v) = &e.ne {
429 out.ne = Some(interpolate(v, vars)?);
430 }
431 if let Some(v) = &e.contains {
432 out.contains = Some(interpolate(v, vars)?);
433 }
434 Ok(out)
435}
436
437fn exec(ctx: &mut Ctx, kind: &str, p: &Map<String, Value>) -> Result<Value> {
438 match kind {
439 "cli" => {
441 let args: Vec<String> = match (pv(p, "args"), ps_opt(p, "command")) {
442 (Some(Value::Array(a)), _) => a
443 .iter()
444 .map(|v| {
445 v.as_str()
446 .map(String::from)
447 .unwrap_or_else(|| v.to_string())
448 })
449 .collect(),
450 (_, Some(c)) => c.split_whitespace().map(String::from).collect(),
451 _ => return err("cli step needs 'command' or 'args'"),
452 };
453 let r = ctx.engine()?.cli(&args, &cli_opts(ctx, p), ctx.tracker)?;
454 let log = ctx.log_dir.join(format!("{:02}-cli.log", ctx.step_no));
455 std::fs::write(
456 &log,
457 format!(
458 "$ {}\nexit {:?}\n--- stdout\n{}\n--- stderr\n{}\n",
459 args.join(" "),
460 r.code,
461 r.stdout,
462 r.stderr
463 ),
464 )?;
465 ctx.artifact(&format!("{:02}-cli.log", ctx.step_no), log);
466 let ok = r.code == Some(0) && r.json.get("error").map(Value::is_null).unwrap_or(true);
467 if !ok && !pb(p, "allow_error") {
468 return err(format!(
469 "engine `{}` failed (exit {:?}{}): stdout {} stderr tail {}",
470 args.join(" "),
471 r.code,
472 if r.timed_out { ", timed out" } else { "" },
473 crate::util::tail(&r.stdout, 600),
474 crate::util::tail(&r.stderr, 600)
475 ));
476 }
477 Ok(r.to_value())
478 }
479 "sidecar_start" => {
480 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
481 let s = ctx
482 .engine()?
483 .start_sidecar(&cli_opts(ctx, p), ctx.tracker)?;
484 let hello = s.hello.clone();
485 ctx.sidecars.insert(id, s);
486 Ok(hello)
487 }
488 "sidecar_command" => {
489 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
490 let name = ps(p, "name")?;
491 let input = pv(p, "input").cloned().unwrap_or_else(|| json!({}));
492 let timeout = dur(p, "timeout_s", 120.0);
493 let s = ctx
494 .sidecars
495 .get_mut(&id)
496 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?;
497 match s.command(&name, input, timeout) {
498 Ok(v) => Ok(v),
499 Err(e) if pb(p, "allow_error") => Ok(json!({"error": e.0})),
500 Err(e) => Err(e),
501 }
502 }
503 "sidecar_raw" => {
504 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
505 let frame = pv(p, "frame")
506 .cloned()
507 .ok_or_else(|| Error("sidecar_raw needs 'frame'".into()))?;
508 let timeout = dur(p, "timeout_s", 60.0);
509 ctx.sidecars
510 .get_mut(&id)
511 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?
512 .raw(frame, timeout)
513 }
514 "sidecar_close" => {
515 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
516 let mut s = ctx
517 .sidecars
518 .remove(&id)
519 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?;
520 let code = s.close();
521 Ok(json!({"exit_code": code, "stderr_tail": s.stderr_tail()}))
522 }
523 "mock_start" => {
525 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
526 let routes = pv(p, "routes")
527 .and_then(Value::as_array)
528 .map(|a| {
529 a.iter()
530 .map(|r| Route {
531 method: r["method"].as_str().map(String::from),
532 path: r["path"].as_str().unwrap_or("/").to_string(),
533 status: r["status"].as_u64().unwrap_or(200) as u16,
534 body: r
535 .get("json")
536 .or_else(|| r.get("body"))
537 .cloned()
538 .unwrap_or(Value::Null),
539 content_type: r["content_type"].as_str().map(String::from),
540 })
541 .collect()
542 })
543 .unwrap_or_default();
544 let m = MockServer::start(routes)?;
545 let base = m.base.clone();
546 ctx.mocks.insert(id, m);
547 Ok(json!({"base": base}))
548 }
549 "mock_requests" => {
550 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
551 let m = ctx
552 .mocks
553 .get(&id)
554 .ok_or_else(|| Error(format!("no mock '{id}'")))?;
555 Ok(json!({"requests": m.seen().iter().map(|s| s.to_value()).collect::<Vec<_>>()}))
556 }
557 "mock_stop" => {
558 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
559 ctx.mocks.remove(&id);
560 Ok(json!({"stopped": true}))
561 }
562 "write_wav" => {
564 let path = resolve(ctx, &ps(p, "path")?);
565 media::write_reference_wav(&path, pf_or(p, "seconds", 6.0))?;
566 Ok(json!({"path": path.to_string_lossy()}))
567 }
568 "write_file" => {
569 let path = resolve(ctx, &ps(p, "path")?);
570 if let Some(d) = path.parent() {
571 std::fs::create_dir_all(d)?;
572 }
573 std::fs::write(&path, ps_opt(p, "content").unwrap_or_default())?;
574 Ok(json!({"path": path.to_string_lossy()}))
575 }
576 "file_stat" => Ok(media::file_stat(&resolve(ctx, &ps(p, "path")?))),
577 "wav_stats" => media::wav_stats(&resolve(ctx, &ps(p, "path")?)),
578 "png_size" => media::png_size(&resolve(ctx, &ps(p, "path")?)),
579 "ffprobe" => match media::ffprobe(&resolve(ctx, &ps(p, "path")?), ctx.tracker) {
580 Ok(v) => Ok(v),
581 Err(e) if pb(p, "optional") => Ok(json!({"skipped": e.0})),
582 Err(e) => Err(e),
583 },
584 "set" => Ok(pv(p, "value").cloned().unwrap_or(Value::Null)),
586 "sleep" => {
587 sleep_ms(pf_or(p, "ms", 100.0) as u64);
588 Ok(json!({"slept_ms": pf_or(p, "ms", 100.0)}))
589 }
590 "assert_no_orphans" => {
591 ctx.tracker.assert_no_orphans()?;
592 Ok(json!({"alive": 0}))
593 }
594 "ui_launch" => {
596 if ctx.ui.is_some() {
597 return err("a UI app is already running in this scenario");
598 }
599 let binary = match ps_opt(p, "binary") {
600 Some(b) => ctx.cfg.app_root.join(b),
601 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()))?,
602 };
603 let mode = Mode::parse(&ps_opt(p, "mode").unwrap_or_else(|| ctx.cfg.ui.mode.clone()))?;
604 let mut env: Vec<(String, String)> = ctx
605 .cfg
606 .ui
607 .env
608 .iter()
609 .map(|(k, v)| (k.clone(), v.clone()))
610 .collect();
611 env.extend(ctx.cfg.env.iter().map(|(k, v)| (k.clone(), v.clone())));
612 if let Some(var) = &ctx.cfg.engine.data_dir_env {
613 env.push((var.clone(), ctx.data_dir.to_string_lossy().into()));
614 }
615 env.extend(env_pairs(p));
616 let spec = LaunchSpec {
617 binary,
618 mode,
619 env,
620 startup_timeout: Duration::from_secs(ctx.cfg.ui.startup_timeout_s.unwrap_or(90)),
621 label: format!("{} app", ctx.cfg.app),
622 };
623 let c = control::launch(&spec, ctx.ws, ctx.tracker)?;
624 let out = json!({"pid": c.pid, "service": c.service, "mode": format!("{mode:?}").to_lowercase(), "health": c.health()?, "log": c.log_path.to_string_lossy()});
625 let log = c.log_path.clone();
626 ctx.ui = Some((c, mode));
627 ctx.artifact("app.log", log);
628 Ok(out)
629 }
630 "ui_stop" => {
631 let (mut c, mode) = ctx
632 .ui
633 .take()
634 .ok_or_else(|| Error("no UI app running".into()))?;
635 let r = c.stop()?;
636 if !r.process_gone || !r.endpoint_closed {
637 return err(format!(
638 "app pid {} not fully stopped (process_gone={}, endpoint_closed={})",
639 r.pid, r.process_gone, r.endpoint_closed
640 ));
641 }
642 if mode != Mode::Visible && !r.owned_never_frontmost {
643 return err(format!(
644 "owned UI process reached foreground: {:?}",
645 r.owned_frontmost_pids
646 ));
647 }
648 Ok(
649 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}),
650 )
651 }
652 "ui_auth_probe" => {
653 let c = ctx.ui()?;
654 Ok(json!({"unlisted_app_error": c.unlisted_app_probe()?}))
655 }
656 "ui_click" => {
657 let c = ctx.ui()?;
658 let button = ps_opt(p, "button").unwrap_or_else(|| "left".into());
659 let count = pf_or(p, "count", 1.0) as u32;
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.click(x, y, &button, count)?;
665 Ok(json!({"x": x, "y": y}))
666 }
667 "ui_move" => {
668 let c = ctx.ui()?;
669 let (x, y) = match ps_opt(p, "selector") {
670 Some(sel) => c.center_of(&sel)?,
671 None => (pf(p, "x")?, pf(p, "y")?),
672 };
673 c.move_to(x, y)?;
674 Ok(json!({"x": x, "y": y}))
675 }
676 "ui_drag" => {
677 let c = ctx.ui()?;
678 let from = match ps_opt(p, "from_selector") {
679 Some(s) => c.center_of(&s)?,
680 None => (pf(p, "x1")?, pf(p, "y1")?),
681 };
682 let to = match ps_opt(p, "to_selector") {
683 Some(s) => c.center_of(&s)?,
684 None => (pf(p, "x2")?, pf(p, "y2")?),
685 };
686 c.drag(from, to, pf_or(p, "steps", 12.0) as u32)?;
687 Ok(json!({"from": [from.0, from.1], "to": [to.0, to.1]}))
688 }
689 "ui_wheel" => {
690 let c = ctx.ui()?;
691 let (x, y) = match ps_opt(p, "selector") {
692 Some(sel) => c.center_of(&sel)?,
693 None => (pf_or(p, "x", 100.0), pf_or(p, "y", 100.0)),
694 };
695 c.wheel(x, y, pf_or(p, "dx", 0.0), pf_or(p, "dy", 0.0))?;
696 Ok(json!({"x": x, "y": y}))
697 }
698 "ui_key" => {
699 ctx.ui()?.key(&ps(p, "key")?)?;
700 Ok(json!({"ok": true}))
701 }
702 "ui_type" => {
703 ctx.ui()?.type_text(&ps(p, "text")?)?;
704 Ok(json!({"ok": true}))
705 }
706 "ui_eval" => ctx.ui()?.eval(&ps(p, "js")?),
707 "ui_wait_eval" => ctx
708 .ui()?
709 .wait_eval(&ps(p, "js")?, dur(p, "timeout_s", 20.0)),
710 "ui_dom" => Ok(json!({"elements": ctx.ui()?.dom(&ps(p, "selector")?)?})),
711 "ui_ax" => Ok(json!({"nodes": ctx.ui()?.ax()?})),
712 "ui_command" => ctx
713 .ui()?
714 .command(&ps(p, "name")?, pv(p, "args").unwrap_or(&Value::Null)),
715 "ui_screenshot" => {
716 let name = ps_opt(p, "name").unwrap_or_else(|| format!("shot-{:02}", ctx.step_no));
717 let path = ctx.log_dir.join(format!("{name}.png"));
718 ctx.ui()?.screenshot_to(&path)?;
719 let size = media::png_size(&path)?;
720 ctx.artifact(&format!("{name}.png"), path.clone());
721 Ok(json!({"path": path.to_string_lossy(), "size": size}))
722 }
723 "ui_measure_paint" => {
724 let c = ctx.ui()?;
725 let trigger_sel = ps(p, "click_selector")?;
726 let id = format!("paint-{}", util::new_id());
727 let m = page::measure_paint(
728 c,
729 &mut || c.click_selector(&trigger_sel),
730 &id,
731 ps_opt(p, "selector").as_deref(),
732 pf_or(p, "settle_ms", page::DEFAULT_SETTLE_MS as f64) as u64,
733 pf_or(p, "timeout_ms", page::DEFAULT_PAINT_TIMEOUT_MS as f64) as u64,
734 )?;
735 if let Some(budget) = pv(p, "budget_ms").and_then(Value::as_f64) {
736 page::assert_paint_within(&m, budget, &trigger_sel)?;
737 }
738 Ok(
739 json!({"duration_ms": m.duration_ms, "settle_ms": m.settle_ms, "mutations": m.mutations, "animation_frames": m.animation_frames}),
740 )
741 }
742 "ui_assert_pane" => {
743 let g = page::read_pane_geometry(ctx.ui()?, &ps(p, "selector")?)?;
744 page::assert_active_pane(&g)?;
745 Ok(
746 json!({"x": g.x, "y": g.y, "width": g.width, "height": g.height, "matches": g.matches}),
747 )
748 }
749 "ui_resolve_frame" => {
750 let path = page::resolve_unique_frame(
751 ctx.ui()?,
752 &ps(p, "selector")?,
753 pf_or(p, "max_depth", 4.0) as usize,
754 )?;
755 Ok(json!({"frame": path, "described": page::describe_frame(&path)}))
756 }
757 "ui_eval_in_frame" => {
758 let frame: Vec<usize> = pv(p, "frame")
759 .and_then(Value::as_array)
760 .map(|a| {
761 a.iter()
762 .filter_map(|i| i.as_u64().map(|i| i as usize))
763 .collect()
764 })
765 .unwrap_or_default();
766 page::eval_in_frame(ctx.ui()?, &frame, &ps(p, "js")?)
767 }
768 #[cfg(feature = "browser")]
769 k if k.starts_with("browser_") => crate::browser::exec(ctx, k, p),
770 other => err(format!("unknown step kind '{other}'")),
771 }
772}
773
774#[cfg(feature = "browser")]
775pub(crate) mod params {
776 pub(crate) use super::{dur, pb, pf_or, ps, ps_opt, pv};
777}
778
779#[cfg(test)]
780mod tests {
781 use super::*;
782
783 #[test]
784 fn parses_scenario_toml_with_flattened_params_and_expects() {
785 let text = r#"
786name = "engine doctor"
787tier = "fast"
788[[step]]
789kind = "cli"
790command = "doctor"
791save = "doc"
792 [[step.expect]]
793 path = "json.contract_version"
794 gte = 2
795[[step]]
796kind = "cli"
797args = ["spend", "status"]
798input = { a = 1 }
799"#;
800 let path = std::env::temp_dir().join(format!("rkqa-{}.scenario.toml", util::new_id()));
801 std::fs::write(&path, text).unwrap();
802 let s = Scenario::load(&path).unwrap();
803 assert_eq!(s.steps.len(), 2);
804 assert_eq!(s.steps[0].params["command"], json!("doctor"));
805 assert_eq!(s.steps[0].expect[0].gte, Some(2.0));
806 assert_eq!(s.steps[1].params["input"], json!({"a": 1}));
807 let _ = std::fs::remove_file(path);
808 }
809
810 #[test]
811 fn tier_selection_and_requirements() {
812 assert!(tier_selected("fast", "fast"));
813 assert!(!tier_selected("paid", "fast"));
814 assert!(tier_selected("paid", "all"));
815 assert!(tier_selected("local-models", "fast,local-models"));
816 assert_eq!(
817 unmet_requirement(&["env:RKQA_SURELY_UNSET_VAR".into()], Path::new("/")).as_deref(),
818 Some("missing env RKQA_SURELY_UNSET_VAR")
819 );
820 assert_eq!(
821 unmet_requirement(&["file:/definitely/not/here".into()], Path::new("/")).as_deref(),
822 Some("missing file /definitely/not/here")
823 );
824 assert!(unmet_requirement(&[], Path::new("/")).is_none());
825 }
826}