1use crate::a11y;
9use crate::check::{check_all, interpolate, Expect};
10use crate::config::QaConfig;
11use crate::control::{self, Control, LaunchSpec, Mode};
12use crate::engine::{CliOptions, EngineTarget, Sidecar};
13use crate::mock::{MockServer, Route};
14use crate::page;
15use crate::process::Tracker;
16use crate::storage::RunKind;
17use crate::util::{err, sleep_ms, Error, Result};
18use crate::workspace::QaWorkspace;
19use crate::{media, util};
20use serde::Deserialize;
21use serde_json::{json, Map, Value};
22use std::collections::BTreeMap;
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25use std::time::{Duration, Instant};
26
27#[derive(Debug, Clone, Deserialize)]
28pub struct Scenario {
29 pub name: String,
30 #[serde(default = "fast")]
31 pub tier: String,
32 #[serde(default = "engine")]
34 pub target: String,
35 #[serde(default)]
37 pub requires: Vec<String>,
38 #[serde(default)]
41 pub optional: bool,
42 #[serde(default)]
45 pub kill_orphans: bool,
46 pub timeout_s: Option<u64>,
47 #[serde(default)]
51 pub kind: Option<RunKind>,
52 #[serde(default, rename = "step")]
53 pub steps: Vec<Step>,
54 #[serde(skip)]
55 pub file: PathBuf,
56}
57
58fn fast() -> String {
59 "fast".into()
60}
61fn engine() -> String {
62 "engine".into()
63}
64
65#[derive(Debug, Clone, Deserialize)]
66pub struct Step {
67 pub kind: String,
68 pub label: Option<String>,
70 pub save: Option<String>,
71 #[serde(default)]
72 pub expect: Vec<Expect>,
73 pub until_timeout_s: Option<u64>,
75 pub interval_ms: Option<u64>,
76 #[serde(flatten)]
77 pub params: BTreeMap<String, Value>,
78}
79
80impl Scenario {
81 pub fn load(path: &Path) -> Result<Self> {
82 let text = std::fs::read_to_string(path)
83 .map_err(|e| Error(format!("cannot read {}: {e}", path.display())))?;
84 let mut s: Scenario =
85 toml::from_str(&text).map_err(|e| Error(format!("{}: {e}", path.display())))?;
86 s.file = path.to_path_buf();
87 crate::config::validate_tier_list(&s.tier)?;
88 if !["engine", "ui", "browser"].contains(&s.target.as_str()) {
89 return err(format!(
90 "{}: target must be engine|ui|browser",
91 path.display()
92 ));
93 }
94 if s.steps.is_empty() {
95 return err(format!("{}: scenario has no steps", path.display()));
96 }
97 Ok(s)
98 }
99}
100
101pub fn resolve_run_kind(requested: Option<RunKind>, scenarios: &[Scenario]) -> RunKind {
105 requested.unwrap_or_else(|| {
106 if scenarios.iter().any(|s| s.kind == Some(RunKind::Visual)) {
107 RunKind::Visual
108 } else {
109 RunKind::Functional
110 }
111 })
112}
113
114pub fn scenario_kind(run: RunKind, declared: Option<RunKind>, scenario: &str) -> Result<RunKind> {
119 match (run, declared) {
120 (run, None) => Ok(run),
121 (RunKind::Functional, Some(RunKind::Visual)) => err(format!(
122 "scenario '{scenario}' is `visual` but this is a functional run, which takes no screenshots or screen recordings; run with --kind visual (or drop the explicit functional kind)"
123 )),
124 (_, Some(declared)) => Ok(declared),
125 }
126}
127
128pub fn ensure_capture_allowed(kind: RunKind, scenario: &str, what: &str) -> Result<()> {
131 if kind == RunKind::Visual {
132 return Ok(());
133 }
134 err(format!(
135 "{what} is refused in functional scenario '{scenario}': a functional run takes no screenshots or screen recordings; mark the scenario `kind = \"visual\"` or run with --kind visual"
136 ))
137}
138
139pub fn is_capture_method(method: &str) -> bool {
141 method == "screenshot"
142 || method.starts_with("record")
143 || method.starts_with("capture")
144 || method.starts_with("video")
145}
146
147pub fn discover(dirs: &[PathBuf]) -> Result<Vec<PathBuf>> {
148 let mut out = vec![];
149 for d in dirs {
150 if d.is_file() {
151 out.push(d.clone());
152 continue;
153 }
154 let Ok(rd) = std::fs::read_dir(d) else {
155 continue;
156 };
157 for e in rd.flatten() {
158 let p = e.path();
159 if p.file_name()
160 .and_then(|n| n.to_str())
161 .map(|n| n.ends_with(".scenario.toml"))
162 .unwrap_or(false)
163 {
164 out.push(p);
165 }
166 }
167 }
168 out.sort();
169 Ok(out)
170}
171
172pub fn tier_selected(scenario_tier: &str, selected: &str) -> bool {
174 let sel: Vec<&str> = selected.split(',').map(str::trim).collect();
175 sel.contains(&"all")
176 || scenario_tier
177 .split(',')
178 .map(str::trim)
179 .any(|t| sel.contains(&t))
180}
181
182pub fn platform_mismatch(requires: &[String]) -> Option<String> {
185 let host = crate::util::platform().ok();
186 requires.iter().find_map(|r| {
187 let (kind, value) = r.split_once(':')?;
188 (kind == "platform" && host != Some(value)).then(|| format!("platform is not {value}"))
189 })
190}
191
192pub fn unmet_requirement(requires: &[String], app_root: &Path) -> Option<String> {
195 unmet_requirement_with(requires, app_root, &|k| {
196 std::env::var(k).ok().filter(|v| !v.is_empty())
197 })
198}
199
200pub fn unmet_requirement_with(
205 requires: &[String],
206 app_root: &Path,
207 lookup: &dyn Fn(&str) -> Option<String>,
208) -> Option<String> {
209 for r in requires {
210 let (kind, value) = r.split_once(':').unwrap_or((r.as_str(), ""));
211 let missing = match kind {
212 "env" => lookup(value)
213 .is_none()
214 .then(|| format!("missing env {value}")),
215 "file" => (!app_root.join(value).exists() && !Path::new(value).exists())
216 .then(|| format!("missing file {value}")),
217 "cmd" => (!command_exists(value)).then(|| format!("missing command {value}")),
218 "platform" => (crate::util::platform().ok() != Some(value))
219 .then(|| format!("platform is not {value}")),
220 other => Some(format!("unknown requirement kind '{other}'")),
221 };
222 if missing.is_some() {
223 return missing;
224 }
225 }
226 None
227}
228
229fn command_exists(name: &str) -> bool {
230 if name.contains(std::path::MAIN_SEPARATOR) {
231 return Path::new(name).exists();
232 }
233 let exts: Vec<String> = if cfg!(windows) {
234 vec![".exe".into(), ".cmd".into(), ".bat".into(), String::new()]
235 } else {
236 vec![String::new()]
237 };
238 std::env::var_os("PATH")
239 .map(|p| {
240 std::env::split_paths(&p)
241 .any(|d| exts.iter().any(|e| d.join(format!("{name}{e}")).is_file()))
242 })
243 .unwrap_or(false)
244}
245
246pub type StepHandler =
253 Arc<dyn for<'a> Fn(&mut Ctx<'a>, &Map<String, Value>) -> Result<Value> + Send + Sync>;
254
255#[derive(Clone, Default)]
258pub struct StepHandlers(BTreeMap<String, StepHandler>);
259
260impl StepHandlers {
261 pub fn new() -> Self {
262 Self::default()
263 }
264 pub fn register(
266 &mut self,
267 kind: &str,
268 handler: impl for<'a> Fn(&mut Ctx<'a>, &Map<String, Value>) -> Result<Value>
269 + Send
270 + Sync
271 + 'static,
272 ) -> &mut Self {
273 self.0.insert(kind.to_string(), Arc::new(handler));
274 self
275 }
276 pub fn get(&self, kind: &str) -> Option<StepHandler> {
277 self.0.get(kind).cloned()
278 }
279 pub fn kinds(&self) -> Vec<&str> {
280 self.0.keys().map(String::as_str).collect()
281 }
282 pub fn is_empty(&self) -> bool {
283 self.0.is_empty()
284 }
285}
286
287impl std::fmt::Debug for StepHandlers {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 f.debug_list().entries(self.0.keys()).finish()
290 }
291}
292
293pub struct Ctx<'a> {
294 pub cfg: &'a QaConfig,
295 pub engine: Option<EngineTarget>,
296 pub ui_binary: Option<PathBuf>,
297 pub ws: &'a QaWorkspace,
298 pub tracker: &'a Tracker,
299 pub scenario: String,
300 pub data_dir: PathBuf,
301 pub log_dir: PathBuf,
302 pub vars: BTreeMap<String, Value>,
303 pub sidecars: BTreeMap<String, Sidecar>,
304 pub mocks: BTreeMap<String, MockServer>,
305 pub ui: Option<(Control, Mode)>,
306 #[cfg(feature = "browser")]
307 pub browser: Option<crate::browser::BrowserTarget>,
308 pub kind: RunKind,
311 pub artifacts: Vec<(String, PathBuf)>,
313 pub handlers: StepHandlers,
315 pub kill_orphans: bool,
317 step_no: usize,
318}
319
320impl<'a> Ctx<'a> {
321 pub fn new(
322 cfg: &'a QaConfig,
323 engine: Option<EngineTarget>,
324 ui_binary: Option<PathBuf>,
325 ws: &'a QaWorkspace,
326 tracker: &'a Tracker,
327 scenario: &str,
328 ) -> Result<Self> {
329 let data_dir = ws.scenario_dir(scenario)?;
330 let log_dir = ws
331 .evidence_dir
332 .join(data_dir.file_name().unwrap_or_default());
333 std::fs::create_dir_all(&log_dir)?;
334 let app_evidence_dir = ws
336 .home
337 .join("evidence")
338 .join(data_dir.file_name().unwrap_or_default());
339 std::fs::create_dir_all(&app_evidence_dir)?;
340 let mut vars = BTreeMap::new();
341 vars.insert("data_dir".into(), json!(scenario_path_text(&data_dir)));
344 vars.insert(
345 "evidence_dir".into(),
346 json!(scenario_path_text(&app_evidence_dir)),
347 );
348 vars.insert("app_root".into(), json!(scenario_path_text(&cfg.app_root)));
349 vars.insert(
350 "engine".into(),
351 json!(engine.as_ref().map(|e| scenario_path_text(&e.binary))),
352 );
353 Ok(Self {
354 cfg,
355 engine,
356 ui_binary,
357 ws,
358 tracker,
359 scenario: scenario.into(),
360 data_dir,
361 log_dir,
362 vars,
363 sidecars: BTreeMap::new(),
364 mocks: BTreeMap::new(),
365 ui: None,
366 #[cfg(feature = "browser")]
367 browser: None,
368 kind: RunKind::Functional,
369 artifacts: vec![],
370 handlers: StepHandlers::default(),
371 kill_orphans: false,
372 step_no: 0,
373 })
374 }
375
376 fn engine(&self) -> Result<&EngineTarget> {
377 self.engine.as_ref().ok_or_else(|| Error("no engine binary configured (set [engine].binary, RIGHTKIT_QA_ENGINE, or pass --build)".into()))
378 }
379
380 fn ui(&self) -> Result<&Control> {
381 self.ui
382 .as_ref()
383 .map(|(c, _)| c)
384 .ok_or_else(|| Error("no UI app launched; add a ui_launch step first".into()))
385 }
386
387 pub fn artifact(&mut self, name: &str, path: PathBuf) {
389 self.artifacts
390 .push((format!("{}/{}", self.scenario, name), path));
391 }
392
393 pub fn teardown(&mut self) -> Vec<String> {
396 let mut problems = vec![];
397 #[cfg(feature = "browser")]
398 if let Some(mut b) = self.browser.take() {
399 b.stop();
400 }
401 if let Some((mut c, mode)) = self.ui.take() {
402 let focused = c.focus_requested();
403 match c.stop() {
404 Ok(r) => {
405 if !r.process_gone || !r.endpoint_closed {
406 problems.push(format!(
407 "UI app pid {} not fully stopped (process_gone={}, endpoint_closed={})",
408 r.pid, r.process_gone, r.endpoint_closed
409 ));
410 }
411 if mode != Mode::Visible && !focused && !r.owned_never_frontmost {
412 problems.push(format!(
413 "background proof failed: {} (states {:?}, owned frontmost pids {:?}, pid unreadable in {} sample(s))",
414 r.owned_never_frontmost_failure.as_deref().unwrap_or("unknown"),
415 r.frontmost_states,
416 r.owned_frontmost_pids,
417 r.frontmost_pid_unavailable_samples
418 ));
419 }
420 }
421 Err(e) => problems.push(e.0),
422 }
423 }
424 for (_, mut s) in std::mem::take(&mut self.sidecars) {
425 s.close();
426 }
427 self.mocks.clear();
428 if self.kill_orphans {
429 self.tracker.kill_all();
430 } else if let Err(e) = self.tracker.assert_no_orphans() {
431 problems.push(e.0);
432 }
433 problems
434 }
435}
436
437pub(crate) fn pv<'p>(p: &'p Map<String, Value>, k: &str) -> Option<&'p Value> {
440 p.get(k).filter(|v| !v.is_null())
441}
442pub(crate) fn ps(p: &Map<String, Value>, k: &str) -> Result<String> {
443 pv(p, k)
444 .and_then(Value::as_str)
445 .map(String::from)
446 .ok_or_else(|| Error(format!("step needs string '{k}'")))
447}
448pub(crate) fn ps_opt(p: &Map<String, Value>, k: &str) -> Option<String> {
449 pv(p, k).and_then(Value::as_str).map(String::from)
450}
451pub(crate) fn pf(p: &Map<String, Value>, k: &str) -> Result<f64> {
452 pv(p, k)
453 .and_then(Value::as_f64)
454 .ok_or_else(|| Error(format!("step needs number '{k}'")))
455}
456pub(crate) fn pf_or(p: &Map<String, Value>, k: &str, d: f64) -> f64 {
457 pv(p, k).and_then(Value::as_f64).unwrap_or(d)
458}
459pub(crate) fn pb(p: &Map<String, Value>, k: &str) -> bool {
460 pv(p, k).and_then(Value::as_bool).unwrap_or(false)
461}
462pub(crate) fn dur(p: &Map<String, Value>, k: &str, default_s: f64) -> Duration {
463 Duration::from_millis((pf_or(p, k, default_s) * 1000.0) as u64)
464}
465pub fn strip_verbatim(text: &str) -> String {
472 crate::util::plain_path_text(text)
475}
476
477fn scenario_path_text(path: &Path) -> String {
480 let text = path.to_string_lossy();
481 if cfg!(windows) {
482 strip_verbatim(&text)
483 } else {
484 text.into_owned()
485 }
486}
487
488fn host_path(s: &str) -> PathBuf {
491 if cfg!(windows) {
492 PathBuf::from(s.replace('/', "\\"))
493 } else {
494 PathBuf::from(s)
495 }
496}
497
498fn resolve_in(data_dir: &Path, s: &str) -> PathBuf {
501 let p = host_path(s);
502 if p.is_absolute() {
503 p
504 } else {
505 data_dir.join(p)
506 }
507}
508
509fn resolve(ctx: &Ctx, s: &str) -> PathBuf {
510 resolve_in(&ctx.data_dir, s)
511}
512fn env_pairs(p: &Map<String, Value>) -> Vec<(String, String)> {
513 pv(p, "env")
514 .and_then(Value::as_object)
515 .map(|m| {
516 m.iter()
517 .map(|(k, v)| {
518 (
519 k.clone(),
520 v.as_str()
521 .map(String::from)
522 .unwrap_or_else(|| v.to_string()),
523 )
524 })
525 .collect()
526 })
527 .unwrap_or_default()
528}
529fn cli_opts(ctx: &Ctx, p: &Map<String, Value>) -> CliOptions {
530 CliOptions {
531 data_dir: Some(ctx.data_dir.clone()),
532 env: env_pairs(p),
533 input: pv(p, "input").cloned(),
534 timeout: Some(dur(p, "timeout_s", 120.0)),
535 }
536}
537
538#[derive(Debug, Clone)]
541pub struct StepRecord {
542 pub index: usize,
543 pub kind: String,
544 pub name: String,
545 pub ok: bool,
546 pub ms: u128,
547 pub error: Option<String>,
548}
549
550pub const DEFAULT_UNTIL_INTERVAL_MS: u64 = 25;
553
554pub fn run_step(ctx: &mut Ctx, step: &Step) -> Result<Value> {
556 ctx.step_no += 1;
557 let started = Instant::now();
558 let until = step.until_timeout_s.map(Duration::from_secs);
559 let interval = Duration::from_millis(step.interval_ms.unwrap_or(DEFAULT_UNTIL_INTERVAL_MS));
560 loop {
561 let attempt: Result<Value> = (|| {
562 let resolved = interpolate(
563 &Value::Object(step.params.clone().into_iter().collect()),
564 &ctx.vars,
565 )?;
566 let params = resolved.as_object().cloned().unwrap_or_default();
567 let result = exec(ctx, &step.kind, ¶ms)?;
568 let expects: Vec<Expect> = step
569 .expect
570 .iter()
571 .map(|e| resolve_expect(e, &ctx.vars))
572 .collect::<Result<_>>()?;
573 check_all(&result, &expects)?;
574 Ok(result)
575 })();
576 match attempt {
577 Ok(v) => {
578 if let Some(name) = &step.save {
579 ctx.vars.insert(name.clone(), v.clone());
580 }
581 return Ok(v);
582 }
583 Err(e) => match until {
584 Some(limit) if started.elapsed() < limit => std::thread::sleep(interval),
585 _ => {
586 return Err(Error(format!(
587 "step {} ({}): {}",
588 ctx.step_no, step.kind, e.0
589 )))
590 }
591 },
592 }
593 }
594}
595
596fn resolve_expect(e: &Expect, vars: &BTreeMap<String, Value>) -> Result<Expect> {
597 let mut out = e.clone();
598 if let Some(v) = &e.eq {
599 out.eq = Some(interpolate(v, vars)?);
600 }
601 if let Some(v) = &e.ne {
602 out.ne = Some(interpolate(v, vars)?);
603 }
604 if let Some(v) = &e.contains {
605 out.contains = Some(interpolate(v, vars)?);
606 }
607 Ok(out)
608}
609
610pub(crate) fn seed_commands(p: &Map<String, Value>) -> Result<Vec<Map<String, Value>>> {
612 let list = pv(p, "commands").and_then(Value::as_array).ok_or_else(|| {
613 Error("engine_seed needs a `commands` array of cli parameter tables".into())
614 })?;
615 if list.is_empty() {
616 return err("engine_seed `commands` is empty");
617 }
618 list.iter()
619 .enumerate()
620 .map(|(i, c)| {
621 let m = c
622 .as_object()
623 .cloned()
624 .ok_or_else(|| Error(format!("engine_seed command {} is not a table", i + 1)))?;
625 if pv(&m, "command").is_none() && pv(&m, "args").is_none() {
626 return err(format!(
627 "engine_seed command {} needs 'command' or 'args'",
628 i + 1
629 ));
630 }
631 Ok(m)
632 })
633 .collect()
634}
635
636fn run_cli(ctx: &mut Ctx, p: &Map<String, Value>, log_name: &str) -> Result<Value> {
639 let args: Vec<String> = match (pv(p, "args"), ps_opt(p, "command")) {
640 (Some(Value::Array(a)), _) => a
641 .iter()
642 .map(|v| {
643 v.as_str()
644 .map(String::from)
645 .unwrap_or_else(|| v.to_string())
646 })
647 .collect(),
648 (_, Some(c)) => c.split_whitespace().map(String::from).collect(),
649 _ => return err("cli step needs 'command' or 'args'"),
650 };
651 let r = ctx.engine()?.cli(&args, &cli_opts(ctx, p), ctx.tracker)?;
652 let log = ctx.log_dir.join(log_name);
653 std::fs::write(
654 &log,
655 format!(
656 "$ {}\nexit {:?}\n--- stdout\n{}\n--- stderr\n{}\n",
657 args.join(" "),
658 r.code,
659 r.stdout,
660 r.stderr
661 ),
662 )?;
663 ctx.artifact(log_name, log);
664 let ok = r.code == Some(0) && r.json.get("error").map(Value::is_null).unwrap_or(true);
665 if !ok && !pb(p, "allow_error") {
666 return err(format!(
667 "engine `{}` failed (exit {:?}{}): stdout {} stderr tail {}",
668 args.join(" "),
669 r.code,
670 if r.timed_out { ", timed out" } else { "" },
671 crate::util::tail(&r.stdout, 600),
672 crate::util::tail(&r.stderr, 600)
673 ));
674 }
675 Ok(r.to_value())
676}
677
678fn exec(ctx: &mut Ctx, kind: &str, p: &Map<String, Value>) -> Result<Value> {
679 if let Some(handler) = ctx.handlers.get(kind) {
680 return handler(ctx, p);
681 }
682 match kind {
683 "cli" => {
685 let log = format!("{:02}-cli.log", ctx.step_no);
686 run_cli(ctx, p, &log)
687 }
688 "engine_seed" => {
693 if ctx.ui.is_some() {
694 return err("engine_seed must run before ui_launch: the app is already running and may hold the state it would seed");
695 }
696 let commands = seed_commands(p)?;
697 let mut results = Vec::new();
698 for (i, one) in commands.iter().enumerate() {
699 let log = format!("{:02}-seed-{:02}.log", ctx.step_no, i + 1);
700 results.push(
701 run_cli(ctx, one, &log)
702 .map_err(|e| Error(format!("engine_seed command {}: {}", i + 1, e.0)))?,
703 );
704 }
705 Ok(json!({"seeded": results.len(), "results": results}))
706 }
707 "sidecar_start" => {
708 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
709 let s = ctx
710 .engine()?
711 .start_sidecar(&cli_opts(ctx, p), ctx.tracker)?;
712 let mut s = s;
713 s.set_frame_shape(pv(p, "legacy_status_frames").and_then(Value::as_bool));
714 let hello = s.hello.clone();
715 ctx.sidecars.insert(id, s);
716 Ok(hello)
717 }
718 "sidecar_command" => {
719 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
720 let name = ps(p, "name")?;
721 let input = pv(p, "input").cloned().unwrap_or_else(|| json!({}));
722 let timeout = dur(p, "timeout_s", 120.0);
723 let s = ctx
724 .sidecars
725 .get_mut(&id)
726 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?;
727 let legacy = pv(p, "legacy_status_frames").and_then(Value::as_bool);
728 match s.command_with(&name, input, timeout, legacy) {
729 Ok(v) => Ok(v),
730 Err(e) if pb(p, "allow_error") => Ok(json!({"error": e.0})),
731 Err(e) => Err(e),
732 }
733 }
734 "sidecar_raw" => {
735 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
736 let frame = pv(p, "frame")
737 .cloned()
738 .ok_or_else(|| Error("sidecar_raw needs 'frame'".into()))?;
739 let timeout = dur(p, "timeout_s", 60.0);
740 ctx.sidecars
741 .get_mut(&id)
742 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?
743 .raw(frame, timeout)
744 }
745 "sidecar_close" => {
746 let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
747 let mut s = ctx
748 .sidecars
749 .remove(&id)
750 .ok_or_else(|| Error(format!("no sidecar '{id}'")))?;
751 let code = s.close();
752 Ok(json!({"exit_code": code, "stderr_tail": s.stderr_tail()}))
753 }
754 "mock_start" => {
756 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
757 let routes = pv(p, "routes")
758 .and_then(Value::as_array)
759 .map(|a| {
760 a.iter()
761 .map(|r| Route {
762 method: r["method"].as_str().map(String::from),
763 path: r["path"].as_str().unwrap_or("/").to_string(),
764 status: r["status"].as_u64().unwrap_or(200) as u16,
765 body: r
766 .get("json")
767 .or_else(|| r.get("body"))
768 .cloned()
769 .unwrap_or(Value::Null),
770 content_type: r["content_type"].as_str().map(String::from),
771 })
772 .collect()
773 })
774 .unwrap_or_default();
775 let m = MockServer::start(routes)?;
776 let base = m.base.clone();
777 ctx.mocks.insert(id, m);
778 Ok(json!({"base": base}))
779 }
780 "mock_requests" => {
781 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
782 let m = ctx
783 .mocks
784 .get(&id)
785 .ok_or_else(|| Error(format!("no mock '{id}'")))?;
786 Ok(json!({"requests": m.seen().iter().map(|s| s.to_value()).collect::<Vec<_>>()}))
787 }
788 "mock_stop" => {
789 let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
790 ctx.mocks.remove(&id);
791 Ok(json!({"stopped": true}))
792 }
793 "write_wav" => {
795 let path = resolve(ctx, &ps(p, "path")?);
796 media::write_reference_wav(&path, pf_or(p, "seconds", 6.0))?;
797 Ok(json!({"path": path.to_string_lossy()}))
798 }
799 "write_file" => {
800 let path = resolve(ctx, &ps(p, "path")?);
801 if let Some(d) = path.parent() {
802 std::fs::create_dir_all(d)?;
803 }
804 std::fs::write(&path, ps_opt(p, "content").unwrap_or_default())?;
805 Ok(json!({"path": path.to_string_lossy()}))
806 }
807 "file_stat" => Ok(media::file_stat(&resolve(ctx, &ps(p, "path")?))),
808 "wav_stats" => media::wav_stats(&resolve(ctx, &ps(p, "path")?)),
809 "png_size" => media::png_size(&resolve(ctx, &ps(p, "path")?)),
810 "ffprobe" => match media::ffprobe(&resolve(ctx, &ps(p, "path")?), ctx.tracker) {
811 Ok(v) => Ok(v),
812 Err(e) if pb(p, "optional") => Ok(json!({"skipped": e.0})),
813 Err(e) => Err(e),
814 },
815 "set" => Ok(pv(p, "value").cloned().unwrap_or(Value::Null)),
817 "sleep" => {
818 sleep_ms(pf_or(p, "ms", 100.0) as u64);
819 Ok(json!({"slept_ms": pf_or(p, "ms", 100.0)}))
820 }
821 "assert_no_orphans" => {
822 ctx.tracker.assert_no_orphans()?;
823 Ok(json!({"alive": 0}))
824 }
825 "ui_launch" => {
827 if ctx.ui.is_some() {
828 return err("a UI app is already running in this scenario");
829 }
830 let binary = match ps_opt(p, "binary") {
831 Some(b) => ctx.cfg.app_root.join(b),
832 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()))?,
833 };
834 let mode = Mode::parse(&ps_opt(p, "mode").unwrap_or_else(|| ctx.cfg.ui.mode.clone()))?;
835 let mut env: Vec<(String, String)> = ctx
836 .cfg
837 .ui
838 .env
839 .iter()
840 .map(|(k, v)| (k.clone(), v.clone()))
841 .collect();
842 env.extend(ctx.cfg.env.iter().map(|(k, v)| (k.clone(), v.clone())));
843 if let Some(var) = &ctx.cfg.engine.data_dir_env {
844 env.push((var.clone(), ctx.data_dir.to_string_lossy().into()));
845 }
846 env.extend(env_pairs(p));
847 let focus = pv(p, "focus")
848 .and_then(Value::as_bool)
849 .unwrap_or(ctx.cfg.ui.focus);
850 let spec = LaunchSpec {
851 binary,
852 mode,
853 env,
854 startup_timeout: Duration::from_secs(ctx.cfg.ui.startup_timeout_s.unwrap_or(90)),
855 label: format!("{} app", ctx.cfg.app),
856 focus,
857 };
858 let c = control::launch(&spec, ctx.ws, ctx.tracker)?;
859 let out = json!({"pid": c.pid, "service": c.service, "focus": focus, "mode": format!("{mode:?}").to_lowercase(), "health": c.health()?, "log": c.log_path.to_string_lossy()});
860 let log = c.log_path.clone();
861 ctx.ui = Some((c, mode));
862 ctx.artifact("app.log", log);
863 Ok(out)
864 }
865 "ui_stop" => {
866 let (mut c, mode) = ctx
867 .ui
868 .take()
869 .ok_or_else(|| Error("no UI app running".into()))?;
870 let focused = c.focus_requested();
871 let r = c.stop()?;
872 if !r.process_gone || !r.endpoint_closed {
873 return err(format!(
874 "app pid {} not fully stopped (process_gone={}, endpoint_closed={})",
875 r.pid, r.process_gone, r.endpoint_closed
876 ));
877 }
878 if mode != Mode::Visible && !focused && !r.owned_never_frontmost {
879 return err(format!(
880 "background proof failed: {} (states {:?}, owned frontmost pids {:?}, pid unreadable in {} sample(s))",
881 r.owned_never_frontmost_failure.as_deref().unwrap_or("unknown"),
882 r.frontmost_states,
883 r.owned_frontmost_pids,
884 r.frontmost_pid_unavailable_samples
885 ));
886 }
887 Ok(
888 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, "frontmost_pid_unavailable_samples": r.frontmost_pid_unavailable_samples, "owned_never_frontmost_failure": r.owned_never_frontmost_failure}),
889 )
890 }
891 "ui_auth_probe" => {
892 let c = ctx.ui()?;
893 Ok(json!({"unlisted_app_error": c.unlisted_app_probe()?}))
894 }
895 "ui_click" => {
896 let c = ctx.ui()?;
897 let button = ps_opt(p, "button").unwrap_or_else(|| "left".into());
898 let count = pf_or(p, "count", 1.0) as u32;
899 let (x, y) = match ps_opt(p, "selector") {
900 Some(sel) => c.center_of(&sel)?,
901 None => (pf(p, "x")?, pf(p, "y")?),
902 };
903 c.click(x, y, &button, count)?;
904 Ok(json!({"x": x, "y": y}))
905 }
906 "ui_move" => {
907 let c = ctx.ui()?;
908 let (x, y) = match ps_opt(p, "selector") {
909 Some(sel) => c.center_of(&sel)?,
910 None => (pf(p, "x")?, pf(p, "y")?),
911 };
912 c.move_to(x, y)?;
913 Ok(json!({"x": x, "y": y}))
914 }
915 "ui_drag" => {
916 let c = ctx.ui()?;
917 let from = match ps_opt(p, "from_selector") {
918 Some(s) => c.center_of(&s)?,
919 None => (pf(p, "x1")?, pf(p, "y1")?),
920 };
921 let to = match ps_opt(p, "to_selector") {
922 Some(s) => c.center_of(&s)?,
923 None => (pf(p, "x2")?, pf(p, "y2")?),
924 };
925 c.drag(from, to, pf_or(p, "steps", 12.0) as u32)?;
926 Ok(json!({"from": [from.0, from.1], "to": [to.0, to.1]}))
927 }
928 "ui_wheel" => {
929 let c = ctx.ui()?;
930 let (x, y) = match ps_opt(p, "selector") {
931 Some(sel) => c.center_of(&sel)?,
932 None => (pf_or(p, "x", 100.0), pf_or(p, "y", 100.0)),
933 };
934 c.wheel(x, y, pf_or(p, "dx", 0.0), pf_or(p, "dy", 0.0))?;
935 Ok(json!({"x": x, "y": y}))
936 }
937 "ui_key" => {
938 ctx.ui()?.key(&ps(p, "key")?)?;
939 Ok(json!({"ok": true}))
940 }
941 "ui_type" => {
942 ctx.ui()?.type_text(&ps(p, "text")?)?;
943 Ok(json!({"ok": true}))
944 }
945 "ui_eval" => ctx.ui()?.eval(&ps(p, "js")?),
946 "ui_wait_eval" => ctx
947 .ui()?
948 .wait_eval(&ps(p, "js")?, dur(p, "timeout_s", 20.0)),
949 "ui_dom" => Ok(json!({"elements": ctx.ui()?.dom(&ps(p, "selector")?)?})),
950 "ui_ax" => Ok(json!({"nodes": ctx.ui()?.ax()?})),
951 "ui_assert_ax" => {
954 let q = a11y::AxQuery::from_params(p)?;
955 a11y::ax_assert(&ctx.ui()?.ax()?, &q)
956 }
957 "ui_contrast" => {
961 let selector = ps(p, "selector")?;
962 let probe = ctx.ui()?.eval(&a11y::contrast_probe_js(&selector))?;
963 let opts = a11y::ContrastOptions {
964 level: ps_opt(p, "level"),
965 min: pv(p, "min").and_then(Value::as_f64),
966 large: pv(p, "large").and_then(Value::as_bool),
967 report_only: pb(p, "report_only"),
968 };
969 a11y::contrast_report(&selector, &probe, &opts)
970 }
971 "ui_focus_ring" => {
976 let selector = ps_opt(p, "selector");
977 let probe = ctx.ui()?.eval(&a11y::focus_probe_js(selector.as_deref()))?;
978 let mut out = a11y::focus_ring_verdict(&probe);
979 if pb(p, "screenshot") {
980 ensure_capture_allowed(ctx.kind, &ctx.scenario, "ui_focus_ring screenshot")?;
981 let name = ps_opt(p, "name").unwrap_or_else(|| format!("focus-{:02}", ctx.step_no));
982 let path = ctx.log_dir.join(format!("{name}.png"));
983 let png = ctx.ui()?.screenshot_png()?;
984 media::png_size_bytes(&png, &path)?;
985 std::fs::write(&path, &png)?;
986 ctx.artifact(&format!("{name}.png"), path.clone());
987 out["screenshot"] = json!(path.to_string_lossy());
988 }
989 Ok(out)
990 }
991 "ui_command" => ctx
992 .ui()?
993 .command(&ps(p, "name")?, pv(p, "args").unwrap_or(&Value::Null)),
994 "ui_screenshot" => {
995 ensure_capture_allowed(ctx.kind, &ctx.scenario, "ui_screenshot")?;
996 let name = ps_opt(p, "name").unwrap_or_else(|| format!("shot-{:02}", ctx.step_no));
997 let path = ctx.log_dir.join(format!("{name}.png"));
998 let png = ctx.ui()?.screenshot_png()?;
999 let size = media::png_size_bytes(&png, &path)?;
1000 if let Some(dir) = path.parent() {
1001 std::fs::create_dir_all(dir)?;
1002 }
1003 std::fs::write(&path, &png)?;
1004 if pv(p, "evidence").and_then(Value::as_bool) != Some(false) {
1006 ctx.artifact(&format!("{name}.png"), path.clone());
1007 }
1008 Ok(json!({"path": path.to_string_lossy(), "size": size}))
1009 }
1010 "ui_batch" => {
1011 let steps: Vec<(String, Value)> = pv(p, "steps")
1013 .and_then(Value::as_array)
1014 .ok_or_else(|| Error("ui_batch needs a `steps` array".into()))?
1015 .iter()
1016 .map(|s| {
1017 let m = s
1018 .get("method")
1019 .and_then(Value::as_str)
1020 .ok_or_else(|| Error("ui_batch step needs a `method`".into()))?;
1021 Ok((
1022 m.to_string(),
1023 s.get("params").cloned().unwrap_or_else(|| json!({})),
1024 ))
1025 })
1026 .collect::<Result<_>>()?;
1027 for (m, _) in &steps {
1028 if is_capture_method(m) {
1029 ensure_capture_allowed(
1030 ctx.kind,
1031 &ctx.scenario,
1032 &format!("ui_batch method '{m}'"),
1033 )?;
1034 }
1035 }
1036 let refs: Vec<(&str, Value)> =
1037 steps.iter().map(|(m, p)| (m.as_str(), p.clone())).collect();
1038 let results = ctx.ui()?.batch(&refs)?;
1039 Ok(json!({"results": results}))
1040 }
1041 "ui_measure_paint" => {
1042 let c = ctx.ui()?;
1043 let trigger_sel = ps(p, "click_selector")?;
1044 let id = format!("paint-{}", util::new_id());
1045 let m = page::measure_paint(
1046 c,
1047 &mut || c.click_selector(&trigger_sel),
1048 &id,
1049 ps_opt(p, "selector").as_deref(),
1050 pf_or(p, "settle_ms", page::DEFAULT_SETTLE_MS as f64) as u64,
1051 pf_or(p, "timeout_ms", page::DEFAULT_PAINT_TIMEOUT_MS as f64) as u64,
1052 )?;
1053 if let Some(budget) = pv(p, "budget_ms").and_then(Value::as_f64) {
1054 page::assert_paint_within(&m, budget, &trigger_sel)?;
1055 }
1056 Ok(
1057 json!({"duration_ms": m.duration_ms, "settle_ms": m.settle_ms, "mutations": m.mutations, "animation_frames": m.animation_frames}),
1058 )
1059 }
1060 "ui_assert_pane" => {
1061 let g = page::read_pane_geometry(ctx.ui()?, &ps(p, "selector")?)?;
1062 page::assert_active_pane(&g)?;
1063 Ok(
1064 json!({"x": g.x, "y": g.y, "width": g.width, "height": g.height, "matches": g.matches}),
1065 )
1066 }
1067 "ui_resolve_frame" => {
1068 let path = page::resolve_unique_frame(
1069 ctx.ui()?,
1070 &ps(p, "selector")?,
1071 pf_or(p, "max_depth", 4.0) as usize,
1072 )?;
1073 Ok(json!({"frame": path, "described": page::describe_frame(&path)}))
1074 }
1075 "ui_eval_in_frame" => {
1076 let frame: Vec<usize> = pv(p, "frame")
1077 .and_then(Value::as_array)
1078 .map(|a| {
1079 a.iter()
1080 .filter_map(|i| i.as_u64().map(|i| i as usize))
1081 .collect()
1082 })
1083 .unwrap_or_default();
1084 page::eval_in_frame(ctx.ui()?, &frame, &ps(p, "js")?)
1085 }
1086 #[cfg(feature = "browser")]
1087 k if k.starts_with("browser_") => crate::browser::exec(ctx, k, p),
1088 other => err(format!("unknown step kind '{other}'")),
1089 }
1090}
1091
1092#[cfg(feature = "browser")]
1093pub(crate) mod params {
1094 pub(crate) use super::{dur, pb, pf_or, ps, ps_opt, pv};
1095}
1096
1097#[cfg(test)]
1098mod tests {
1099 use super::*;
1100
1101 #[test]
1102 fn parses_scenario_toml_with_flattened_params_and_expects() {
1103 let text = r#"
1104name = "engine doctor"
1105tier = "fast"
1106[[step]]
1107kind = "cli"
1108command = "doctor"
1109save = "doc"
1110 [[step.expect]]
1111 path = "json.contract_version"
1112 gte = 2
1113[[step]]
1114kind = "cli"
1115args = ["spend", "status"]
1116input = { a = 1 }
1117"#;
1118 let path = std::env::temp_dir().join(format!("rkqa-{}.scenario.toml", util::new_id()));
1119 std::fs::write(&path, text).unwrap();
1120 let s = Scenario::load(&path).unwrap();
1121 assert_eq!(s.steps.len(), 2);
1122 assert_eq!(s.steps[0].params["command"], json!("doctor"));
1123 assert_eq!(s.steps[0].expect[0].gte, Some(2.0));
1124 assert_eq!(s.steps[1].params["input"], json!({"a": 1}));
1125 let _ = std::fs::remove_file(path);
1126 }
1127
1128 #[test]
1130 fn ui_launch_focus_param_parses() {
1131 let text = r#"
1132name = "focus ring"
1133target = "ui"
1134[[step]]
1135kind = "ui_launch"
1136focus = true
1137[[step]]
1138kind = "ui_key"
1139key = "Tab"
1140[[step]]
1141kind = "ui_stop"
1142"#;
1143 let path =
1144 std::env::temp_dir().join(format!("rkqa-focus-{}.scenario.toml", util::new_id()));
1145 std::fs::write(&path, text).unwrap();
1146 let s = Scenario::load(&path);
1147 let _ = std::fs::remove_file(&path);
1148 let s = s.unwrap();
1149 assert_eq!(
1150 pv(&s.steps[0].params.clone().into_iter().collect(), "focus"),
1151 Some(&json!(true))
1152 );
1153 assert!(!pb(
1154 &s.steps[2].params.clone().into_iter().collect(),
1155 "focus"
1156 ));
1157 }
1158
1159 #[test]
1160 fn tier_selection_and_requirements() {
1161 assert!(tier_selected("fast", "fast"));
1162 assert!(!tier_selected("paid", "fast"));
1163 assert!(tier_selected("paid", "all"));
1164 assert!(tier_selected("local-models", "fast,local-models"));
1165 assert_eq!(
1166 unmet_requirement(&["env:RKQA_SURELY_UNSET_VAR".into()], Path::new("/")).as_deref(),
1167 Some("missing env RKQA_SURELY_UNSET_VAR")
1168 );
1169 assert_eq!(
1170 unmet_requirement(&["file:/definitely/not/here".into()], Path::new("/")).as_deref(),
1171 Some("missing file /definitely/not/here")
1172 );
1173 assert!(unmet_requirement(&[], Path::new("/")).is_none());
1174 }
1175
1176 #[test]
1177 fn a_verbatim_path_is_given_to_scenarios_in_its_ordinary_form() {
1178 assert_eq!(strip_verbatim(r"\\?\C:\qa\run-1"), r"C:\qa\run-1");
1179 assert_eq!(
1180 strip_verbatim(r"\\?\UNC\server\share\qa"),
1181 r"\\server\share\qa"
1182 );
1183 for unchanged in [
1185 r"C:\qa\run-1",
1186 "/tmp/qa/run-1",
1187 r"\\?\Volume{4c1b02c1-d990-11dc-99ae-806e6f6e6963}\qa",
1188 r"\\?\GLOBALROOT\Device\x",
1189 ] {
1190 assert_eq!(strip_verbatim(unchanged), unchanged);
1191 }
1192 let long = format!(r"\\?\C:\{}", "d".repeat(260));
1194 assert_eq!(strip_verbatim(&long), long);
1195 }
1196
1197 #[cfg(not(windows))]
1198 #[test]
1199 fn scenario_paths_are_untouched_off_windows() {
1200 assert_eq!(
1201 resolve_in(Path::new("/data"), "proj/a\\b.txt"),
1202 Path::new("/data/proj/a\\b.txt")
1203 );
1204 assert_eq!(
1205 resolve_in(Path::new("/data"), "/abs/x"),
1206 Path::new("/abs/x")
1207 );
1208 assert_eq!(scenario_path_text(Path::new("/data/run")), "/data/run");
1209 }
1210
1211 fn scenario_with_kind(kind: Option<&str>) -> Scenario {
1212 let header = kind
1213 .map(|k| {
1214 format!(
1215 "kind = \"{k}\"
1216"
1217 )
1218 })
1219 .unwrap_or_default();
1220 let text = format!(
1221 "name = \"k\"
1222{header}[[step]]
1223kind = \"cli\"
1224command = \"x\"
1225"
1226 );
1227 let path = std::env::temp_dir().join(format!("rkqa-kind-{}.scenario.toml", util::new_id()));
1228 std::fs::write(&path, text).unwrap();
1229 let s = Scenario::load(&path);
1230 let _ = std::fs::remove_file(&path);
1231 s.unwrap()
1232 }
1233
1234 #[test]
1235 fn a_scenario_declares_its_kind_or_leaves_it_to_the_run() {
1236 assert_eq!(scenario_with_kind(None).kind, None);
1237 assert_eq!(
1238 scenario_with_kind(Some("visual")).kind,
1239 Some(RunKind::Visual)
1240 );
1241 assert_eq!(
1242 scenario_with_kind(Some("functional")).kind,
1243 Some(RunKind::Functional)
1244 );
1245 let path = std::env::temp_dir().join(format!("rkqa-kind-{}.scenario.toml", util::new_id()));
1246 std::fs::write(
1247 &path,
1248 "name = \"k\"
1249kind = \"cosmetic\"
1250[[step]]
1251kind = \"cli\"
1252command = \"x\"
1253",
1254 )
1255 .unwrap();
1256 let e = Scenario::load(&path).unwrap_err();
1257 let _ = std::fs::remove_file(&path);
1258 assert!(e.0.contains("functional") && e.0.contains("visual"), "{e}");
1259 }
1260
1261 #[test]
1262 fn run_kind_defaults_to_functional_and_follows_scenarios_unless_requested() {
1263 let none = [
1264 scenario_with_kind(None),
1265 scenario_with_kind(Some("functional")),
1266 ];
1267 assert_eq!(resolve_run_kind(None, &none), RunKind::Functional);
1268 let some = [scenario_with_kind(None), scenario_with_kind(Some("visual"))];
1269 assert_eq!(resolve_run_kind(None, &some), RunKind::Visual);
1270 assert_eq!(
1272 resolve_run_kind(Some(RunKind::Functional), &some),
1273 RunKind::Functional
1274 );
1275 assert_eq!(
1276 resolve_run_kind(Some(RunKind::Visual), &none),
1277 RunKind::Visual
1278 );
1279 assert_eq!(resolve_run_kind(None, &[]), RunKind::Functional);
1280 assert_eq!(RunKind::parse("visual").unwrap(), RunKind::Visual);
1281 assert_eq!(RunKind::parse(" functional ").unwrap(), RunKind::Functional);
1282 assert!(RunKind::parse("both").is_err());
1283 }
1284
1285 #[test]
1286 fn a_scenario_kind_may_narrow_a_run_but_never_widen_a_functional_one() {
1287 let f = RunKind::Functional;
1288 let v = RunKind::Visual;
1289 assert_eq!(scenario_kind(f, None, "s").unwrap(), f);
1290 assert_eq!(scenario_kind(v, None, "s").unwrap(), v);
1291 assert_eq!(scenario_kind(v, Some(f), "s").unwrap(), f);
1292 assert_eq!(scenario_kind(v, Some(v), "s").unwrap(), v);
1293 assert_eq!(scenario_kind(f, Some(f), "s").unwrap(), f);
1294 let e = scenario_kind(f, Some(v), "login").unwrap_err();
1295 assert!(
1296 e.0.contains("login") && e.0.contains("--kind visual"),
1297 "{e}"
1298 );
1299 }
1300
1301 #[test]
1302 fn captures_are_refused_in_a_functional_scenario_and_allowed_in_a_visual_one() {
1303 let e = ensure_capture_allowed(RunKind::Functional, "login", "ui_screenshot").unwrap_err();
1304 assert!(
1305 e.0.contains("ui_screenshot")
1306 && e.0.contains("login")
1307 && e.0.contains("functional")
1308 && e.0.contains("kind = \"visual\""),
1309 "{e}"
1310 );
1311 ensure_capture_allowed(RunKind::Visual, "login", "ui_screenshot").unwrap();
1312 assert!(is_capture_method("screenshot"));
1313 assert!(is_capture_method("record_start"));
1314 assert!(is_capture_method("capture"));
1315 assert!(!is_capture_method("click"));
1316 assert!(!is_capture_method("batch"));
1317 }
1318
1319 fn bare_step(kind: &str, params: Value) -> Step {
1320 Step {
1321 kind: kind.into(),
1322 label: None,
1323 save: None,
1324 expect: vec![],
1325 until_timeout_s: None,
1326 interval_ms: None,
1327 params: params
1328 .as_object()
1329 .cloned()
1330 .unwrap_or_default()
1331 .into_iter()
1332 .collect(),
1333 }
1334 }
1335
1336 #[test]
1340 fn a_custom_step_kind_runs_with_ctx_and_params_and_feeds_expect_and_save() {
1341 let root = std::env::temp_dir().join(format!("rkqa-custom-ws-{}", util::new_id()));
1342 std::fs::create_dir_all(&root).unwrap();
1343 let cfg =
1344 QaConfig::parse("app = \"customtest\"\n", &root.join("rightkit-qa.toml")).unwrap();
1345 let ws = crate::workspace::create(&root.join("managed"), None, "customtest").unwrap();
1346 let tracker = Tracker::new();
1347 let mut ctx = Ctx::new(&cfg, None, None, &ws, &tracker, "s").unwrap();
1348 ctx.vars.insert("mock_url".into(), json!("http://m"));
1349 let e = run_step(&mut ctx, &bare_step("mock_admin", json!({}))).unwrap_err();
1350 assert!(e.0.contains("unknown step kind 'mock_admin'"), "{e}");
1351 ctx.handlers.register("mock_admin", |ctx, p| {
1352 let url = ctx.vars["mock_url"].as_str().unwrap().to_string();
1353 ctx.vars.insert("touched".into(), json!(true));
1354 Ok(json!({"url": url, "echo": p.get("what").cloned()}))
1355 });
1356 assert_eq!(ctx.handlers.kinds(), vec!["mock_admin"]);
1357 let mut step = bare_step("mock_admin", json!({"what": "${mock_url}/x"}));
1359 step.save = Some("admin".into());
1360 step.expect =
1361 vec![serde_json::from_value(json!({"path": "echo", "eq": "http://m/x"})).unwrap()];
1362 let v = run_step(&mut ctx, &step).unwrap();
1363 assert_eq!(v["url"], "http://m");
1364 assert_eq!(ctx.vars["admin"]["echo"], "http://m/x");
1365 assert_eq!(ctx.vars["touched"], json!(true));
1366 step.expect = vec![serde_json::from_value(json!({"path": "echo", "eq": "nope"})).unwrap()];
1367 let e = run_step(&mut ctx, &step).unwrap_err();
1368 assert!(e.0.contains("mock_admin"), "{e}");
1369 ctx.handlers.register("boom", |_, _| err("bad thing"));
1371 assert!(run_step(&mut ctx, &bare_step("boom", json!({})))
1372 .unwrap_err()
1373 .0
1374 .contains("bad thing"));
1375 drop(ctx);
1376 let _ = crate::workspace::dispose(&ws, false, 3, 10);
1377 let _ = std::fs::remove_dir_all(&root);
1378 }
1379
1380 #[test]
1381 fn a_functional_scenario_refuses_screenshot_steps_and_a_visual_one_does_not() {
1382 let root = std::env::temp_dir().join(format!("rkqa-kind-ws-{}", util::new_id()));
1383 std::fs::create_dir_all(&root).unwrap();
1384 let cfg = QaConfig::parse(
1385 "app = \"kindtest\"
1386",
1387 &root.join("rightkit-qa.toml"),
1388 )
1389 .unwrap();
1390 let ws = crate::workspace::create(&root.join("managed"), None, "kindtest").unwrap();
1391 let tracker = Tracker::new();
1392 let mut ctx = Ctx::new(&cfg, None, None, &ws, &tracker, "s").unwrap();
1393 assert_eq!(
1394 ctx.kind,
1395 RunKind::Functional,
1396 "a scenario is functional unless told otherwise"
1397 );
1398
1399 let shot = bare_step("ui_screenshot", json!({}));
1400 let batch = bare_step(
1401 "ui_batch",
1402 json!({"steps": [{"method": "click", "params": {"x": 1, "y": 2}}, {"method": "screenshot"}]}),
1403 );
1404 let e = run_step(&mut ctx, &shot).unwrap_err();
1405 assert!(e.0.contains("refused in functional scenario"), "{e}");
1406 let e = run_step(&mut ctx, &batch).unwrap_err();
1407 assert!(
1408 e.0.contains("ui_batch method 'screenshot'") && e.0.contains("functional"),
1409 "{e}"
1410 );
1411
1412 ctx.kind = RunKind::Visual;
1413 let e = run_step(&mut ctx, &shot).unwrap_err();
1414 assert!(e.0.contains("no UI app launched"), "{e}");
1415 let e = run_step(&mut ctx, &batch).unwrap_err();
1416 assert!(e.0.contains("no UI app launched"), "{e}");
1417
1418 drop(ctx);
1419 let _ = crate::workspace::dispose(&ws, false, 3, 10);
1420 let _ = std::fs::remove_dir_all(&root);
1421 }
1422
1423 #[test]
1424 fn parses_the_seed_and_accessibility_steps() {
1425 let text = r##"
1426name = "settings a11y"
1427target = "ui"
1428kind = "visual"
1429[[step]]
1430kind = "engine_seed"
1431commands = [
1432 { command = "project create demo" },
1433 { args = ["settings", "set", "theme", "dark"], allow_error = true },
1434]
1435[[step]]
1436kind = "ui_launch"
1437[[step]]
1438kind = "ui_assert_ax"
1439role = "button"
1440name = "Save"
1441min_count = 1
1442[[step]]
1443kind = "ui_contrast"
1444selector = "#status"
1445level = "AA"
1446 [[step.expect]]
1447 path = "passes"
1448 eq = true
1449[[step]]
1450kind = "ui_focus_ring"
1451selector = "#btn"
1452screenshot = true
1453 [[step.expect]]
1454 path = "visible"
1455 eq = true
1456"##;
1457 let path = std::env::temp_dir().join(format!("rkqa-a11y-{}.scenario.toml", util::new_id()));
1458 std::fs::write(&path, text).unwrap();
1459 let s = Scenario::load(&path);
1460 let _ = std::fs::remove_file(&path);
1461 let s = s.unwrap();
1462 assert_eq!(s.target, "ui");
1463 let kinds: Vec<&str> = s.steps.iter().map(|s| s.kind.as_str()).collect();
1464 assert_eq!(
1465 kinds,
1466 [
1467 "engine_seed",
1468 "ui_launch",
1469 "ui_assert_ax",
1470 "ui_contrast",
1471 "ui_focus_ring"
1472 ]
1473 );
1474 let seeds = seed_commands(&s.steps[0].params.clone().into_iter().collect()).unwrap();
1475 assert_eq!(seeds.len(), 2);
1476 assert_eq!(
1477 seeds[1]["args"],
1478 json!(["settings", "set", "theme", "dark"])
1479 );
1480 assert_eq!(s.steps[3].params["selector"], json!("#status"));
1481 assert_eq!(s.steps[4].expect[0].eq, Some(json!(true)));
1482 let q =
1483 a11y::AxQuery::from_params(&s.steps[2].params.clone().into_iter().collect()).unwrap();
1484 assert_eq!(
1485 (q.role.as_deref(), q.name.as_deref(), q.min_count),
1486 (Some("button"), Some("Save"), Some(1))
1487 );
1488 }
1489
1490 #[test]
1491 fn seed_commands_are_validated() {
1492 let get = |v: Value| seed_commands(v.as_object().unwrap());
1493 assert!(get(json!({})).is_err());
1494 assert!(get(json!({"commands": []})).is_err());
1495 assert!(get(json!({"commands": ["doctor"]})).is_err());
1496 assert!(get(json!({"commands": [{"input": {"a": 1}}]})).is_err());
1497 assert_eq!(
1498 get(json!({"commands": [{"command": "doctor"}]}))
1499 .unwrap()
1500 .len(),
1501 1
1502 );
1503 }
1504
1505 #[test]
1508 fn engine_seed_needs_the_engine_and_runs_before_the_app() {
1509 let root = std::env::temp_dir().join(format!("rkqa-seed-ws-{}", util::new_id()));
1510 std::fs::create_dir_all(&root).unwrap();
1511 let cfg = QaConfig::parse(
1512 "app = \"seedtest\"
1513",
1514 &root.join("rightkit-qa.toml"),
1515 )
1516 .unwrap();
1517 let ws = crate::workspace::create(&root.join("managed"), None, "seedtest").unwrap();
1518 let tracker = Tracker::new();
1519 let mut ctx = Ctx::new(&cfg, None, None, &ws, &tracker, "s").unwrap();
1520 let seed = bare_step("engine_seed", json!({"commands": [{"command": "doctor"}]}));
1521 let e = run_step(&mut ctx, &seed).unwrap_err();
1522 assert!(e.0.contains("no engine binary configured"), "{e}");
1523 let bad = bare_step("engine_seed", json!({"commands": []}));
1524 assert!(run_step(&mut ctx, &bad).unwrap_err().0.contains("empty"));
1525 for kind in ["ui_assert_ax", "ui_contrast", "ui_focus_ring"] {
1527 let step = bare_step(kind, json!({"role": "button", "selector": "#a"}));
1528 let e = run_step(&mut ctx, &step).unwrap_err();
1529 assert!(e.0.contains("no UI app launched"), "{kind}: {e}");
1530 }
1531 drop(ctx);
1532 let _ = crate::workspace::dispose(&ws, false, 3, 10);
1533 let _ = std::fs::remove_dir_all(&root);
1534 }
1535
1536 #[cfg(windows)]
1539 #[test]
1540 fn a_scenario_path_mixing_separators_reaches_the_file_system_on_windows() {
1541 let root = std::env::temp_dir().join(format!("rkqa-paths-{}", util::new_id()));
1542 std::fs::create_dir_all(&root).unwrap();
1543 let data_dir = std::fs::canonicalize(&root).unwrap();
1544 assert!(
1545 data_dir.to_string_lossy().starts_with(r"\\?\"),
1546 "{data_dir:?}"
1547 );
1548
1549 let var = scenario_path_text(&data_dir);
1551 assert!(!var.starts_with(r"\\?\"), "{var}");
1552 let expanded = resolve_in(&data_dir, &format!("{var}/proj/README.txt"));
1553 std::fs::create_dir_all(expanded.parent().unwrap()).unwrap();
1554 std::fs::write(&expanded, "one").unwrap();
1555
1556 let relative = resolve_in(&data_dir, "proj/nested/b.txt");
1558 std::fs::create_dir_all(relative.parent().unwrap()).unwrap();
1559 std::fs::write(&relative, "two").unwrap();
1560
1561 let verbatim = resolve_in(
1563 &data_dir,
1564 &format!("{}/proj/c.txt", data_dir.to_string_lossy()),
1565 );
1566 std::fs::write(&verbatim, "three").unwrap();
1567
1568 assert_eq!(
1570 std::fs::read_to_string(root.join("proj").join("README.txt")).unwrap(),
1571 "one"
1572 );
1573 assert_eq!(
1574 std::fs::read_to_string(root.join("proj").join("nested").join("b.txt")).unwrap(),
1575 "two"
1576 );
1577 assert_eq!(
1578 std::fs::read_to_string(root.join("proj").join("c.txt")).unwrap(),
1579 "three"
1580 );
1581 assert!(std::fs::metadata(&expanded).unwrap().is_file());
1582 std::fs::remove_dir_all(root).unwrap();
1583 }
1584}