Skip to main content

rightkit_qa/
scenario.rs

1//! Declarative scenarios (`*.scenario.toml`) and the step executor.
2//!
3//! A scenario is a tier, optional requirements, and an ordered list of steps.
4//! Steps drive the engine (CLI and framed sidecar), the real app through
5//! the in-app control server, paid-provider mocks, media validators, and (feature
6//! `browser`) Chrome. Each step may `save` its JSON result under a name for
7//! later `${name.path}` interpolation and carries `[[step.expect]]` assertions.
8use 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    /// `engine`, `ui`, or `browser`: which binary the scenario needs.
33    #[serde(default = "engine")]
34    pub target: String,
35    /// `env:NAME`, `file:path`, `cmd:program`, `platform:darwin|windows`.
36    #[serde(default)]
37    pub requires: Vec<String>,
38    /// A scenario that may legitimately be skipped when `requires` is unmet. Every other
39    /// scenario is a mandatory gate: skipping it fails the run.
40    #[serde(default)]
41    pub optional: bool,
42    /// Kill any tracked process still alive after this scenario's teardown, without failing the
43    /// scenario (see `SuiteOptions::kill_orphans`).
44    #[serde(default)]
45    pub kill_orphans: bool,
46    pub timeout_s: Option<u64>,
47    /// `functional` or `visual`. Absent: the scenario takes the run's kind (see
48    /// [`resolve_run_kind`]). A `functional` scenario takes no screenshots and no recordings; a
49    /// `visual` scenario may, and makes an otherwise unconstrained run `visual`.
50    #[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    /// Human label for reports; `name` stays free for step parameters.
69    pub label: Option<String>,
70    pub save: Option<String>,
71    #[serde(default)]
72    pub expect: Vec<Expect>,
73    /// Re-run the step until its expectations hold, for up to this many seconds.
74    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
101/// The kind of a suite run. An explicit request (`rightkit-qa run --kind`, [`crate::suite::SuiteOptions::kind`])
102/// wins. Otherwise the run is `visual` when any selected scenario declares `kind = "visual"`, and
103/// `functional` (the default, no screenshots) when none does.
104pub 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
114/// The kind in force for one scenario inside a run of kind `run`. A scenario that declares
115/// nothing takes the run's kind; one that declares `functional` stays functional even in a visual
116/// run (it takes no captures); one that declares `visual` in a `functional` run is a conflict
117/// with the run that was asked for, and is refused so evidence never changes silently.
118pub 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
128/// Refuse a capture (screenshot or screen recording) unless the scenario's kind is `visual`.
129/// `what` names the step or method. The error says how to change the kind.
130pub 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
139/// Driver methods that capture the screen. A `ui_batch` may not smuggle one into a functional run.
140pub 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
172/// Whether a scenario runs under `selected` ("fast", "fast,paid", "all").
173pub 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
182/// `Some(reason)` when a `platform:<os>` requirement names another OS than the host. The suite
183/// skips such a scenario even when it is mandatory: it can never run here.
184pub 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
192/// `Some(reason)` when a requirement is not met (the scenario is skipped). `env:X` reads the
193/// process environment; see [`unmet_requirement_with`] for a settings source.
194pub 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
200/// Like [`unmet_requirement`], with `env:X` answered by `lookup` (for example
201/// `|k| settings.get(k)` over a [`crate::settings::Settings`], which survives managed cargo).
202/// Kinds: `env:NAME`, `file:path` (relative to `app_root` or absolute), `cmd:program` (on `PATH`
203/// or a path), `platform:darwin|windows`.
204pub 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
246// ------------------------------------------------------------------- context
247
248/// A handler for an app-defined step kind: gets the live [`Ctx`] (variables, the `save` map is
249/// `ctx.vars`, data and log dirs, tracker, mocks) and the step's parameters with `${...}` already
250/// interpolated. The value it returns is what the step's `[[step.expect]]` checks run on and what
251/// `save` stores, exactly like a built-in kind.
252pub type StepHandler =
253    Arc<dyn for<'a> Fn(&mut Ctx<'a>, &Map<String, Value>) -> Result<Value> + Send + Sync>;
254
255/// The app-defined step kinds of a run, by kind name. A registered kind takes precedence over a
256/// built-in of the same name.
257#[derive(Clone, Default)]
258pub struct StepHandlers(BTreeMap<String, StepHandler>);
259
260impl StepHandlers {
261    pub fn new() -> Self {
262        Self::default()
263    }
264    /// Register `handler` for `kind` (replacing an earlier one).
265    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    /// The scenario's run kind. [`Ctx::new`] starts `Functional`; the suite sets the kind in
309    /// force ([`scenario_kind`]). Only `Visual` allows screenshots and recordings.
310    pub kind: RunKind,
311    /// Files this scenario produced; the runner hashes them into the evidence bundle.
312    pub artifacts: Vec<(String, PathBuf)>,
313    /// App-defined step kinds; the suite fills it from `SuiteOptions::hooks`. Empty by default.
314    pub handlers: StepHandlers,
315    /// When set, [`Ctx::teardown`] kills tracked survivors instead of reporting them as orphans.
316    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        // Templates passed through ui_command/ui_eval must never name managed storage.
335        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        // Every path a scenario can expand goes through `scenario_path_text`, so text such as
342        // `${data_dir}/proj/README.txt` is a path Windows accepts.
343        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    /// Record a file this scenario produced as evidence (hashed into the bundle).
388    pub fn artifact(&mut self, name: &str, path: PathBuf) {
389        self.artifacts
390            .push((format!("{}/{}", self.scenario, name), path));
391    }
392
393    /// Stop everything this scenario started, in reverse dependency order.
394    /// Returns problems found while cleaning up (a failed stop is a failed scenario).
395    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
437// --------------------------------------------------------------------- params
438
439pub(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}
465/// A Windows verbatim path (`\\?\C:\dir`, `\\?\UNC\server\share`) in its ordinary form
466/// (`C:\dir`, `\\server\share`), when it fits the ordinary length limit. Anything else is
467/// returned unchanged, including a verbatim path too long to be written without the prefix.
468///
469/// A canonicalised path on Windows is verbatim, and under that prefix Windows does no
470/// normalisation: `/` is a file-name character, not a separator. Scenario text joins with `/`.
471pub fn strip_verbatim(text: &str) -> String {
472    // Also keeps the path verbatim for a reserved device name or a component Windows would
473    // rewrite (see `util::plain_path_text`).
474    crate::util::plain_path_text(text)
475}
476
477/// The text a scenario variable holds for `path`: the path itself, in ordinary (non-verbatim)
478/// form on Windows where it fits.
479fn 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
488/// `s` as a path of this host. On Windows every `/` becomes `\`, which is the same path for an
489/// ordinary one and the only correct reading for a verbatim one.
490fn host_path(s: &str) -> PathBuf {
491    if cfg!(windows) {
492        PathBuf::from(s.replace('/', "\\"))
493    } else {
494        PathBuf::from(s)
495    }
496}
497
498/// A scenario path: absolute as it is, relative inside `data_dir`. Separators are normalised
499/// after the join, so a relative `a/b` under a verbatim `data_dir` is still two components.
500fn 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// ------------------------------------------------------------------- execution
539
540#[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
550/// Default gap between `until` retries. The retried check is local to the harness (a UI read or an
551/// engine call), so a long gap is dead time; override per step with `interval_ms`.
552pub const DEFAULT_UNTIL_INTERVAL_MS: u64 = 25;
553
554/// Run one step including retry-until-expectations-hold.
555pub 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, &params)?;
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
610/// The `commands` of an `engine_seed` step: a non-empty array of tables, each a `cli` step's parameters.
611pub(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
636/// One engine CLI call: the body of the `cli` step and of each `engine_seed` command. The call's
637/// transcript is written to `log_name` in the scenario's evidence dir.
638fn 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        // ------------------------------------------------------------ engine
684        "cli" => {
685            let log = format!("{:02}-cli.log", ctx.step_no);
686            run_cli(ctx, p, &log)
687        }
688        // Seed the engine's state before the UI starts: each entry of `commands` is a `cli` step's
689        // parameters (`command` or `args`, `input`, `env`, `allow_error`, `timeout_s`), run in order
690        // against the scenario's data dir, the one `ui_launch` then hands the app. A `ui` scenario
691        // needs the engine binary only when it uses this step.
692        "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        // -------------------------------------------------------------- mocks
755        "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        // -------------------------------------------------------------- files
794        "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        // --------------------------------------------------------------- misc
816        "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 app
826        "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        // Role/name assertion over the accessibility nodes: `role`, `name`, `name_contains`, `named`,
952        // `disabled`, `min_count` (default 1), `max_count`. Fails listing the roles and names found.
953        "ui_assert_ax" => {
954            let q = a11y::AxQuery::from_params(p)?;
955            a11y::ax_assert(&ctx.ui()?.ax()?, &q)
956        }
957        // Text contrast of `selector` against its effective background (WCAG 2.x). Fails below the
958        // minimum: `min`, else `level` (`AA` default, `AAA`) for the text size (`large` overrides the
959        // size read from the page). `report_only = true` reports the ratio and never fails.
960        "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        // Keyboard-focus evidence: whether the focused element shows a visible indicator (computed
972        // outline or box-shadow), its identity, and `:focus-visible`. Reports `visible`; assert it
973        // with `[[step.expect]]`. `selector` adds `matches` (is the focused element that one).
974        // `screenshot = true` also saves a capture as evidence (a visual scenario only).
975        "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            // Only evidence is hashed into the bundle; `evidence = false` keeps a working capture out of it.
1005            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            // `steps = [{method = "click", params = {x = 1, y = 2}}, ...]`: one round trip.
1012            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    /// `focus` on `ui_launch` is a plain boolean step parameter; absent means the default (off).
1129    #[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        // Already ordinary, not Windows at all, or a verbatim form with no ordinary spelling.
1184        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        // Too long to be written without the prefix: left verbatim.
1193        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        // An explicit request wins over what the scenarios declare, both ways.
1271        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    /// The step-level rule, through the real executor: a functional scenario's screenshot step
1337    /// is refused before the app is touched, and a visual one gets past the gate (and then stops
1338    /// for want of a launched app, which proves the refusal was the kind and nothing else).
1339    #[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        // Parameters arrive interpolated; the result is what `expect` checks and `save` stores.
1358        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        // A handler error is the step's error.
1370        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    /// `engine_seed` through the executor: it needs the engine (a `ui` scenario with no engine
1506    /// binary gets a clear error, not a skip) and it refuses to run once the app is up.
1507    #[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        // The accessibility steps need a launched app.
1526        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    /// The defect: `${data_dir}/proj/README.txt` under a canonicalised (verbatim) data
1537    /// directory reached the file system with a `/` Windows does not split on (os error 123).
1538    #[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        // The variable a scenario expands is ordinary, so text built on it is a valid path.
1550        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        // A relative path with `/`, joined to the verbatim directory itself.
1557        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        // Text built on the verbatim form, as a scenario written before this fix would hold.
1562        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        // All three are real files in the same directory tree.
1569        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}