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!(scenario_path_text(&data_dir)));
224 vars.insert(
225 "evidence_dir".into(),
226 json!(scenario_path_text(&app_evidence_dir)),
227 );
228 vars.insert("app_root".into(), json!(scenario_path_text(&cfg.app_root)));
229 vars.insert(
230 "engine".into(),
231 json!(engine.as_ref().map(|e| scenario_path_text(&e.binary))),
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}
335pub(crate) fn strip_verbatim(text: &str) -> String {
342 const ORDINARY_LIMIT: usize = 200;
344 let plain = if let Some(unc) = text.strip_prefix(r"\\?\UNC\") {
345 format!(r"\\{unc}")
346 } else if let Some(rest) = text.strip_prefix(r"\\?\") {
347 let bytes = rest.as_bytes();
348 let drive = bytes.len() >= 3
349 && bytes[0].is_ascii_alphabetic()
350 && bytes[1] == b':'
351 && bytes[2] == b'\\';
352 if !drive {
353 return text.to_owned();
354 }
355 rest.to_owned()
356 } else {
357 return text.to_owned();
358 };
359 if plain.chars().count() > ORDINARY_LIMIT {
360 text.to_owned()
361 } else {
362 plain
363 }
364}
365
366fn scenario_path_text(path: &Path) -> String {
369 let text = path.to_string_lossy();
370 if cfg!(windows) {
371 strip_verbatim(&text)
372 } else {
373 text.into_owned()
374 }
375}
376
377fn host_path(s: &str) -> PathBuf {
380 if cfg!(windows) {
381 PathBuf::from(s.replace('/', "\\"))
382 } else {
383 PathBuf::from(s)
384 }
385}
386
387fn resolve_in(data_dir: &Path, s: &str) -> PathBuf {
390 let p = host_path(s);
391 if p.is_absolute() {
392 p
393 } else {
394 data_dir.join(p)
395 }
396}
397
398fn resolve(ctx: &Ctx, s: &str) -> PathBuf {
399 resolve_in(&ctx.data_dir, s)
400}
401fn env_pairs(p: &Map<String, Value>) -> Vec<(String, String)> {
402 pv(p, "env")
403 .and_then(Value::as_object)
404 .map(|m| {
405 m.iter()
406 .map(|(k, v)| {
407 (
408 k.clone(),
409 v.as_str()
410 .map(String::from)
411 .unwrap_or_else(|| v.to_string()),
412 )
413 })
414 .collect()
415 })
416 .unwrap_or_default()
417}
418fn cli_opts(ctx: &Ctx, p: &Map<String, Value>) -> CliOptions {
419 CliOptions {
420 data_dir: Some(ctx.data_dir.clone()),
421 env: env_pairs(p),
422 input: pv(p, "input").cloned(),
423 timeout: Some(dur(p, "timeout_s", 120.0)),
424 }
425}
426
427#[derive(Debug, Clone)]
430pub struct StepRecord {
431 pub index: usize,
432 pub kind: String,
433 pub name: String,
434 pub ok: bool,
435 pub ms: u128,
436 pub error: Option<String>,
437}
438
439pub fn run_step(ctx: &mut Ctx, step: &Step) -> Result<Value> {
441 ctx.step_no += 1;
442 let started = Instant::now();
443 let until = step.until_timeout_s.map(Duration::from_secs);
444 let interval = Duration::from_millis(step.interval_ms.unwrap_or(500));
445 loop {
446 let attempt: Result<Value> = (|| {
447 let resolved = interpolate(
448 &Value::Object(step.params.clone().into_iter().collect()),
449 &ctx.vars,
450 )?;
451 let params = resolved.as_object().cloned().unwrap_or_default();
452 let result = exec(ctx, &step.kind, ¶ms)?;
453 let expects: Vec<Expect> = step
454 .expect
455 .iter()
456 .map(|e| resolve_expect(e, &ctx.vars))
457 .collect::<Result<_>>()?;
458 check_all(&result, &expects)?;
459 Ok(result)
460 })();
461 match attempt {
462 Ok(v) => {
463 if let Some(name) = &step.save {
464 ctx.vars.insert(name.clone(), v.clone());
465 }
466 return Ok(v);
467 }
468 Err(e) => match until {
469 Some(limit) if started.elapsed() < limit => std::thread::sleep(interval),
470 _ => {
471 return Err(Error(format!(
472 "step {} ({}): {}",
473 ctx.step_no, step.kind, e.0
474 )))
475 }
476 },
477 }
478 }
479}
480
481fn resolve_expect(e: &Expect, vars: &BTreeMap<String, Value>) -> Result<Expect> {
482 let mut out = e.clone();
483 if let Some(v) = &e.eq {
484 out.eq = Some(interpolate(v, vars)?);
485 }
486 if let Some(v) = &e.ne {
487 out.ne = Some(interpolate(v, vars)?);
488 }
489 if let Some(v) = &e.contains {
490 out.contains = Some(interpolate(v, vars)?);
491 }
492 Ok(out)
493}
494
495fn exec(ctx: &mut Ctx, kind: &str, p: &Map<String, Value>) -> Result<Value> {
496 match kind {
497 "cli" => {
499 let args: Vec<String> = match (pv(p, "args"), ps_opt(p, "command")) {
500 (Some(Value::Array(a)), _) => a
501 .iter()
502 .map(|v| {
503 v.as_str()
504 .map(String::from)
505 .unwrap_or_else(|| v.to_string())
506 })
507 .collect(),
508 (_, Some(c)) => c.split_whitespace().map(String::from).collect(),
509 _ => return err("cli step needs 'command' or 'args'"),
510 };
511 let r = ctx.engine()?.cli(&args, &cli_opts(ctx, p), ctx.tracker)?;
512 let log = ctx.log_dir.join(format!("{:02}-cli.log", ctx.step_no));
513 std::fs::write(
514 &log,
515 format!(
516 "$ {}\nexit {:?}\n--- stdout\n{}\n--- stderr\n{}\n",
517 args.join(" "),
518 r.code,
519 r.stdout,
520 r.stderr
521 ),
522 )?;
523 ctx.artifact(&format!("{:02}-cli.log", ctx.step_no), log);
524 let ok = r.code == Some(0) && r.json.get("error").map(Value::is_null).unwrap_or(true);
525 if !ok && !pb(p, "allow_error") {
526 return err(format!(
527 "engine `{}` failed (exit {:?}{}): stdout {} stderr tail {}",
528 args.join(" "),
529 r.code,
530 if r.timed_out { ", timed out" } else { "" },
531 crate::util::tail(&r.stdout, 600),
532 crate::util::tail(&r.stderr, 600)
533 ));
534 }
535 Ok(r.to_value())
536 }
537 "sidecar_start" => {
538 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
539 let s = ctx
540 .engine()?
541 .start_sidecar(&cli_opts(ctx, p), ctx.tracker)?;
542 let mut s = s;
543 s.set_frame_shape(pv(p, "legacy_status_frames").and_then(Value::as_bool));
544 let hello = s.hello.clone();
545 ctx.sidecars.insert(id, s);
546 Ok(hello)
547 }
548 "sidecar_command" => {
549 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
550 let name = ps(p, "name")?;
551 let input = pv(p, "input").cloned().unwrap_or_else(|| json!({}));
552 let timeout = dur(p, "timeout_s", 120.0);
553 let s = ctx
554 .sidecars
555 .get_mut(&id)
556 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?;
557 let legacy = pv(p, "legacy_status_frames").and_then(Value::as_bool);
558 match s.command_with(&name, input, timeout, legacy) {
559 Ok(v) => Ok(v),
560 Err(e) if pb(p, "allow_error") => Ok(json!({"error": e.0})),
561 Err(e) => Err(e),
562 }
563 }
564 "sidecar_raw" => {
565 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
566 let frame = pv(p, "frame")
567 .cloned()
568 .ok_or_else(|| Error("sidecar_raw needs 'frame'".into()))?;
569 let timeout = dur(p, "timeout_s", 60.0);
570 ctx.sidecars
571 .get_mut(&id)
572 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?
573 .raw(frame, timeout)
574 }
575 "sidecar_close" => {
576 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
577 let mut s = ctx
578 .sidecars
579 .remove(&id)
580 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?;
581 let code = s.close();
582 Ok(json!({"exit_code": code, "stderr_tail": s.stderr_tail()}))
583 }
584 "mock_start" => {
586 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
587 let routes = pv(p, "routes")
588 .and_then(Value::as_array)
589 .map(|a| {
590 a.iter()
591 .map(|r| Route {
592 method: r["method"].as_str().map(String::from),
593 path: r["path"].as_str().unwrap_or("/").to_string(),
594 status: r["status"].as_u64().unwrap_or(200) as u16,
595 body: r
596 .get("json")
597 .or_else(|| r.get("body"))
598 .cloned()
599 .unwrap_or(Value::Null),
600 content_type: r["content_type"].as_str().map(String::from),
601 })
602 .collect()
603 })
604 .unwrap_or_default();
605 let m = MockServer::start(routes)?;
606 let base = m.base.clone();
607 ctx.mocks.insert(id, m);
608 Ok(json!({"base": base}))
609 }
610 "mock_requests" => {
611 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
612 let m = ctx
613 .mocks
614 .get(&id)
615 .ok_or_else(|| Error(format!("no mock '{id}'")))?;
616 Ok(json!({"requests": m.seen().iter().map(|s| s.to_value()).collect::<Vec<_>>()}))
617 }
618 "mock_stop" => {
619 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
620 ctx.mocks.remove(&id);
621 Ok(json!({"stopped": true}))
622 }
623 "write_wav" => {
625 let path = resolve(ctx, &ps(p, "path")?);
626 media::write_reference_wav(&path, pf_or(p, "seconds", 6.0))?;
627 Ok(json!({"path": path.to_string_lossy()}))
628 }
629 "write_file" => {
630 let path = resolve(ctx, &ps(p, "path")?);
631 if let Some(d) = path.parent() {
632 std::fs::create_dir_all(d)?;
633 }
634 std::fs::write(&path, ps_opt(p, "content").unwrap_or_default())?;
635 Ok(json!({"path": path.to_string_lossy()}))
636 }
637 "file_stat" => Ok(media::file_stat(&resolve(ctx, &ps(p, "path")?))),
638 "wav_stats" => media::wav_stats(&resolve(ctx, &ps(p, "path")?)),
639 "png_size" => media::png_size(&resolve(ctx, &ps(p, "path")?)),
640 "ffprobe" => match media::ffprobe(&resolve(ctx, &ps(p, "path")?), ctx.tracker) {
641 Ok(v) => Ok(v),
642 Err(e) if pb(p, "optional") => Ok(json!({"skipped": e.0})),
643 Err(e) => Err(e),
644 },
645 "set" => Ok(pv(p, "value").cloned().unwrap_or(Value::Null)),
647 "sleep" => {
648 sleep_ms(pf_or(p, "ms", 100.0) as u64);
649 Ok(json!({"slept_ms": pf_or(p, "ms", 100.0)}))
650 }
651 "assert_no_orphans" => {
652 ctx.tracker.assert_no_orphans()?;
653 Ok(json!({"alive": 0}))
654 }
655 "ui_launch" => {
657 if ctx.ui.is_some() {
658 return err("a UI app is already running in this scenario");
659 }
660 let binary = match ps_opt(p, "binary") {
661 Some(b) => ctx.cfg.app_root.join(b),
662 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()))?,
663 };
664 let mode = Mode::parse(&ps_opt(p, "mode").unwrap_or_else(|| ctx.cfg.ui.mode.clone()))?;
665 let mut env: Vec<(String, String)> = ctx
666 .cfg
667 .ui
668 .env
669 .iter()
670 .map(|(k, v)| (k.clone(), v.clone()))
671 .collect();
672 env.extend(ctx.cfg.env.iter().map(|(k, v)| (k.clone(), v.clone())));
673 if let Some(var) = &ctx.cfg.engine.data_dir_env {
674 env.push((var.clone(), ctx.data_dir.to_string_lossy().into()));
675 }
676 env.extend(env_pairs(p));
677 let spec = LaunchSpec {
678 binary,
679 mode,
680 env,
681 startup_timeout: Duration::from_secs(ctx.cfg.ui.startup_timeout_s.unwrap_or(90)),
682 label: format!("{} app", ctx.cfg.app),
683 };
684 let c = control::launch(&spec, ctx.ws, ctx.tracker)?;
685 let out = json!({"pid": c.pid, "service": c.service, "mode": format!("{mode:?}").to_lowercase(), "health": c.health()?, "log": c.log_path.to_string_lossy()});
686 let log = c.log_path.clone();
687 ctx.ui = Some((c, mode));
688 ctx.artifact("app.log", log);
689 Ok(out)
690 }
691 "ui_stop" => {
692 let (mut c, mode) = ctx
693 .ui
694 .take()
695 .ok_or_else(|| Error("no UI app running".into()))?;
696 let r = c.stop()?;
697 if !r.process_gone || !r.endpoint_closed {
698 return err(format!(
699 "app pid {} not fully stopped (process_gone={}, endpoint_closed={})",
700 r.pid, r.process_gone, r.endpoint_closed
701 ));
702 }
703 if mode != Mode::Visible && !r.owned_never_frontmost {
704 return err(format!(
705 "owned UI process reached foreground: {:?}",
706 r.owned_frontmost_pids
707 ));
708 }
709 Ok(
710 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}),
711 )
712 }
713 "ui_auth_probe" => {
714 let c = ctx.ui()?;
715 Ok(json!({"unlisted_app_error": c.unlisted_app_probe()?}))
716 }
717 "ui_click" => {
718 let c = ctx.ui()?;
719 let button = ps_opt(p, "button").unwrap_or_else(|| "left".into());
720 let count = pf_or(p, "count", 1.0) as u32;
721 let (x, y) = match ps_opt(p, "selector") {
722 Some(sel) => c.center_of(&sel)?,
723 None => (pf(p, "x")?, pf(p, "y")?),
724 };
725 c.click(x, y, &button, count)?;
726 Ok(json!({"x": x, "y": y}))
727 }
728 "ui_move" => {
729 let c = ctx.ui()?;
730 let (x, y) = match ps_opt(p, "selector") {
731 Some(sel) => c.center_of(&sel)?,
732 None => (pf(p, "x")?, pf(p, "y")?),
733 };
734 c.move_to(x, y)?;
735 Ok(json!({"x": x, "y": y}))
736 }
737 "ui_drag" => {
738 let c = ctx.ui()?;
739 let from = match ps_opt(p, "from_selector") {
740 Some(s) => c.center_of(&s)?,
741 None => (pf(p, "x1")?, pf(p, "y1")?),
742 };
743 let to = match ps_opt(p, "to_selector") {
744 Some(s) => c.center_of(&s)?,
745 None => (pf(p, "x2")?, pf(p, "y2")?),
746 };
747 c.drag(from, to, pf_or(p, "steps", 12.0) as u32)?;
748 Ok(json!({"from": [from.0, from.1], "to": [to.0, to.1]}))
749 }
750 "ui_wheel" => {
751 let c = ctx.ui()?;
752 let (x, y) = match ps_opt(p, "selector") {
753 Some(sel) => c.center_of(&sel)?,
754 None => (pf_or(p, "x", 100.0), pf_or(p, "y", 100.0)),
755 };
756 c.wheel(x, y, pf_or(p, "dx", 0.0), pf_or(p, "dy", 0.0))?;
757 Ok(json!({"x": x, "y": y}))
758 }
759 "ui_key" => {
760 ctx.ui()?.key(&ps(p, "key")?)?;
761 Ok(json!({"ok": true}))
762 }
763 "ui_type" => {
764 ctx.ui()?.type_text(&ps(p, "text")?)?;
765 Ok(json!({"ok": true}))
766 }
767 "ui_eval" => ctx.ui()?.eval(&ps(p, "js")?),
768 "ui_wait_eval" => ctx
769 .ui()?
770 .wait_eval(&ps(p, "js")?, dur(p, "timeout_s", 20.0)),
771 "ui_dom" => Ok(json!({"elements": ctx.ui()?.dom(&ps(p, "selector")?)?})),
772 "ui_ax" => Ok(json!({"nodes": ctx.ui()?.ax()?})),
773 "ui_command" => ctx
774 .ui()?
775 .command(&ps(p, "name")?, pv(p, "args").unwrap_or(&Value::Null)),
776 "ui_screenshot" => {
777 let name = ps_opt(p, "name").unwrap_or_else(|| format!("shot-{:02}", ctx.step_no));
778 let path = ctx.log_dir.join(format!("{name}.png"));
779 ctx.ui()?.screenshot_to(&path)?;
780 let size = media::png_size(&path)?;
781 ctx.artifact(&format!("{name}.png"), path.clone());
782 Ok(json!({"path": path.to_string_lossy(), "size": size}))
783 }
784 "ui_measure_paint" => {
785 let c = ctx.ui()?;
786 let trigger_sel = ps(p, "click_selector")?;
787 let id = format!("paint-{}", util::new_id());
788 let m = page::measure_paint(
789 c,
790 &mut || c.click_selector(&trigger_sel),
791 &id,
792 ps_opt(p, "selector").as_deref(),
793 pf_or(p, "settle_ms", page::DEFAULT_SETTLE_MS as f64) as u64,
794 pf_or(p, "timeout_ms", page::DEFAULT_PAINT_TIMEOUT_MS as f64) as u64,
795 )?;
796 if let Some(budget) = pv(p, "budget_ms").and_then(Value::as_f64) {
797 page::assert_paint_within(&m, budget, &trigger_sel)?;
798 }
799 Ok(
800 json!({"duration_ms": m.duration_ms, "settle_ms": m.settle_ms, "mutations": m.mutations, "animation_frames": m.animation_frames}),
801 )
802 }
803 "ui_assert_pane" => {
804 let g = page::read_pane_geometry(ctx.ui()?, &ps(p, "selector")?)?;
805 page::assert_active_pane(&g)?;
806 Ok(
807 json!({"x": g.x, "y": g.y, "width": g.width, "height": g.height, "matches": g.matches}),
808 )
809 }
810 "ui_resolve_frame" => {
811 let path = page::resolve_unique_frame(
812 ctx.ui()?,
813 &ps(p, "selector")?,
814 pf_or(p, "max_depth", 4.0) as usize,
815 )?;
816 Ok(json!({"frame": path, "described": page::describe_frame(&path)}))
817 }
818 "ui_eval_in_frame" => {
819 let frame: Vec<usize> = pv(p, "frame")
820 .and_then(Value::as_array)
821 .map(|a| {
822 a.iter()
823 .filter_map(|i| i.as_u64().map(|i| i as usize))
824 .collect()
825 })
826 .unwrap_or_default();
827 page::eval_in_frame(ctx.ui()?, &frame, &ps(p, "js")?)
828 }
829 #[cfg(feature = "browser")]
830 k if k.starts_with("browser_") => crate::browser::exec(ctx, k, p),
831 other => err(format!("unknown step kind '{other}'")),
832 }
833}
834
835#[cfg(feature = "browser")]
836pub(crate) mod params {
837 pub(crate) use super::{dur, pb, pf_or, ps, ps_opt, pv};
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843
844 #[test]
845 fn parses_scenario_toml_with_flattened_params_and_expects() {
846 let text = r#"
847name = "engine doctor"
848tier = "fast"
849[[step]]
850kind = "cli"
851command = "doctor"
852save = "doc"
853 [[step.expect]]
854 path = "json.contract_version"
855 gte = 2
856[[step]]
857kind = "cli"
858args = ["spend", "status"]
859input = { a = 1 }
860"#;
861 let path = std::env::temp_dir().join(format!("rkqa-{}.scenario.toml", util::new_id()));
862 std::fs::write(&path, text).unwrap();
863 let s = Scenario::load(&path).unwrap();
864 assert_eq!(s.steps.len(), 2);
865 assert_eq!(s.steps[0].params["command"], json!("doctor"));
866 assert_eq!(s.steps[0].expect[0].gte, Some(2.0));
867 assert_eq!(s.steps[1].params["input"], json!({"a": 1}));
868 let _ = std::fs::remove_file(path);
869 }
870
871 #[test]
872 fn tier_selection_and_requirements() {
873 assert!(tier_selected("fast", "fast"));
874 assert!(!tier_selected("paid", "fast"));
875 assert!(tier_selected("paid", "all"));
876 assert!(tier_selected("local-models", "fast,local-models"));
877 assert_eq!(
878 unmet_requirement(&["env:RKQA_SURELY_UNSET_VAR".into()], Path::new("/")).as_deref(),
879 Some("missing env RKQA_SURELY_UNSET_VAR")
880 );
881 assert_eq!(
882 unmet_requirement(&["file:/definitely/not/here".into()], Path::new("/")).as_deref(),
883 Some("missing file /definitely/not/here")
884 );
885 assert!(unmet_requirement(&[], Path::new("/")).is_none());
886 }
887
888 #[test]
889 fn a_verbatim_path_is_given_to_scenarios_in_its_ordinary_form() {
890 assert_eq!(strip_verbatim(r"\\?\C:\qa\run-1"), r"C:\qa\run-1");
891 assert_eq!(
892 strip_verbatim(r"\\?\UNC\server\share\qa"),
893 r"\\server\share\qa"
894 );
895 for unchanged in [
897 r"C:\qa\run-1",
898 "/tmp/qa/run-1",
899 r"\\?\Volume{4c1b02c1-d990-11dc-99ae-806e6f6e6963}\qa",
900 r"\\?\GLOBALROOT\Device\x",
901 ] {
902 assert_eq!(strip_verbatim(unchanged), unchanged);
903 }
904 let long = format!(r"\\?\C:\{}", "d".repeat(260));
906 assert_eq!(strip_verbatim(&long), long);
907 }
908
909 #[cfg(not(windows))]
910 #[test]
911 fn scenario_paths_are_untouched_off_windows() {
912 assert_eq!(
913 resolve_in(Path::new("/data"), "proj/a\\b.txt"),
914 Path::new("/data/proj/a\\b.txt")
915 );
916 assert_eq!(
917 resolve_in(Path::new("/data"), "/abs/x"),
918 Path::new("/abs/x")
919 );
920 assert_eq!(scenario_path_text(Path::new("/data/run")), "/data/run");
921 }
922
923 #[cfg(windows)]
926 #[test]
927 fn a_scenario_path_mixing_separators_reaches_the_file_system_on_windows() {
928 let root = std::env::temp_dir().join(format!("rkqa-paths-{}", util::new_id()));
929 std::fs::create_dir_all(&root).unwrap();
930 let data_dir = std::fs::canonicalize(&root).unwrap();
931 assert!(
932 data_dir.to_string_lossy().starts_with(r"\\?\"),
933 "{data_dir:?}"
934 );
935
936 let var = scenario_path_text(&data_dir);
938 assert!(!var.starts_with(r"\\?\"), "{var}");
939 let expanded = resolve_in(&data_dir, &format!("{var}/proj/README.txt"));
940 std::fs::create_dir_all(expanded.parent().unwrap()).unwrap();
941 std::fs::write(&expanded, "one").unwrap();
942
943 let relative = resolve_in(&data_dir, "proj/nested/b.txt");
945 std::fs::create_dir_all(relative.parent().unwrap()).unwrap();
946 std::fs::write(&relative, "two").unwrap();
947
948 let verbatim = resolve_in(
950 &data_dir,
951 &format!("{}/proj/c.txt", data_dir.to_string_lossy()),
952 );
953 std::fs::write(&verbatim, "three").unwrap();
954
955 assert_eq!(
957 std::fs::read_to_string(root.join("proj").join("README.txt")).unwrap(),
958 "one"
959 );
960 assert_eq!(
961 std::fs::read_to_string(root.join("proj").join("nested").join("b.txt")).unwrap(),
962 "two"
963 );
964 assert_eq!(
965 std::fs::read_to_string(root.join("proj").join("c.txt")).unwrap(),
966 "three"
967 );
968 assert!(std::fs::metadata(&expanded).unwrap().is_file());
969 std::fs::remove_dir_all(root).unwrap();
970 }
971}