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//! `rightkit-qa-driver`, 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::check::{check_all, interpolate, Expect};
9use crate::config::QaConfig;
10use crate::control::{self, Control, LaunchSpec, Mode};
11use crate::engine::{CliOptions, EngineTarget, Sidecar};
12use crate::mock::{MockServer, Route};
13use crate::page;
14use crate::process::Tracker;
15use crate::util::{err, sleep_ms, Error, Result};
16use crate::workspace::QaWorkspace;
17use crate::{media, util};
18use serde::Deserialize;
19use serde_json::{json, Map, Value};
20use std::collections::BTreeMap;
21use std::path::{Path, PathBuf};
22use std::time::{Duration, Instant};
23
24#[derive(Debug, Clone, Deserialize)]
25pub struct Scenario {
26    pub name: String,
27    #[serde(default = "fast")]
28    pub tier: String,
29    /// `engine`, `ui`, or `browser`: which binary the scenario needs.
30    #[serde(default = "engine")]
31    pub target: String,
32    /// `env:NAME`, `file:path`, `cmd:program`, `platform:darwin|windows`.
33    #[serde(default)]
34    pub requires: Vec<String>,
35    /// A scenario that may legitimately be skipped when `requires` is unmet. Every other
36    /// scenario is a mandatory gate: skipping it fails the run.
37    #[serde(default)]
38    pub optional: bool,
39    pub timeout_s: Option<u64>,
40    #[serde(default, rename = "step")]
41    pub steps: Vec<Step>,
42    #[serde(skip)]
43    pub file: PathBuf,
44}
45
46fn fast() -> String {
47    "fast".into()
48}
49fn engine() -> String {
50    "engine".into()
51}
52
53#[derive(Debug, Clone, Deserialize)]
54pub struct Step {
55    pub kind: String,
56    /// Human label for reports; `name` stays free for step parameters.
57    pub label: Option<String>,
58    pub save: Option<String>,
59    #[serde(default)]
60    pub expect: Vec<Expect>,
61    /// Re-run the step until its expectations hold, for up to this many seconds.
62    pub until_timeout_s: Option<u64>,
63    pub interval_ms: Option<u64>,
64    #[serde(flatten)]
65    pub params: BTreeMap<String, Value>,
66}
67
68impl Scenario {
69    pub fn load(path: &Path) -> Result<Self> {
70        let text = std::fs::read_to_string(path)
71            .map_err(|e| Error(format!("cannot read {}: {e}", path.display())))?;
72        let mut s: Scenario =
73            toml::from_str(&text).map_err(|e| Error(format!("{}: {e}", path.display())))?;
74        s.file = path.to_path_buf();
75        crate::config::validate_tier_list(&s.tier)?;
76        if !["engine", "ui", "browser"].contains(&s.target.as_str()) {
77            return err(format!(
78                "{}: target must be engine|ui|browser",
79                path.display()
80            ));
81        }
82        if s.steps.is_empty() {
83            return err(format!("{}: scenario has no steps", path.display()));
84        }
85        Ok(s)
86    }
87}
88
89pub fn discover(dirs: &[PathBuf]) -> Result<Vec<PathBuf>> {
90    let mut out = vec![];
91    for d in dirs {
92        if d.is_file() {
93            out.push(d.clone());
94            continue;
95        }
96        let Ok(rd) = std::fs::read_dir(d) else {
97            continue;
98        };
99        for e in rd.flatten() {
100            let p = e.path();
101            if p.file_name()
102                .and_then(|n| n.to_str())
103                .map(|n| n.ends_with(".scenario.toml"))
104                .unwrap_or(false)
105            {
106                out.push(p);
107            }
108        }
109    }
110    out.sort();
111    Ok(out)
112}
113
114/// Whether a scenario runs under `selected` ("fast", "fast,paid", "all").
115pub fn tier_selected(scenario_tier: &str, selected: &str) -> bool {
116    let sel: Vec<&str> = selected.split(',').map(str::trim).collect();
117    sel.contains(&"all")
118        || scenario_tier
119            .split(',')
120            .map(str::trim)
121            .any(|t| sel.contains(&t))
122}
123
124/// `Some(reason)` when a requirement is not met (the scenario is skipped). `env:X` reads the
125/// process environment; see [`unmet_requirement_with`] for a settings source.
126pub fn unmet_requirement(requires: &[String], app_root: &Path) -> Option<String> {
127    unmet_requirement_with(requires, app_root, &|k| {
128        std::env::var(k).ok().filter(|v| !v.is_empty())
129    })
130}
131
132/// Like [`unmet_requirement`], with `env:X` answered by `lookup` (for example
133/// `|k| settings.get(k)` over a [`crate::settings::Settings`], which survives managed cargo).
134/// Kinds: `env:NAME`, `file:path` (relative to `app_root` or absolute), `cmd:program` (on `PATH`
135/// or a path), `platform:darwin|windows`.
136pub fn unmet_requirement_with(
137    requires: &[String],
138    app_root: &Path,
139    lookup: &dyn Fn(&str) -> Option<String>,
140) -> Option<String> {
141    for r in requires {
142        let (kind, value) = r.split_once(':').unwrap_or((r.as_str(), ""));
143        let missing = match kind {
144            "env" => lookup(value)
145                .is_none()
146                .then(|| format!("missing env {value}")),
147            "file" => (!app_root.join(value).exists() && !Path::new(value).exists())
148                .then(|| format!("missing file {value}")),
149            "cmd" => (!command_exists(value)).then(|| format!("missing command {value}")),
150            "platform" => (crate::util::platform().ok() != Some(value))
151                .then(|| format!("platform is not {value}")),
152            other => Some(format!("unknown requirement kind '{other}'")),
153        };
154        if missing.is_some() {
155            return missing;
156        }
157    }
158    None
159}
160
161fn command_exists(name: &str) -> bool {
162    if name.contains(std::path::MAIN_SEPARATOR) {
163        return Path::new(name).exists();
164    }
165    let exts: Vec<String> = if cfg!(windows) {
166        vec![".exe".into(), ".cmd".into(), ".bat".into(), String::new()]
167    } else {
168        vec![String::new()]
169    };
170    std::env::var_os("PATH")
171        .map(|p| {
172            std::env::split_paths(&p)
173                .any(|d| exts.iter().any(|e| d.join(format!("{name}{e}")).is_file()))
174        })
175        .unwrap_or(false)
176}
177
178// ------------------------------------------------------------------- context
179
180pub struct Ctx<'a> {
181    pub cfg: &'a QaConfig,
182    pub engine: Option<EngineTarget>,
183    pub ui_binary: Option<PathBuf>,
184    pub ws: &'a QaWorkspace,
185    pub tracker: &'a Tracker,
186    pub scenario: String,
187    pub data_dir: PathBuf,
188    pub log_dir: PathBuf,
189    pub vars: BTreeMap<String, Value>,
190    pub sidecars: BTreeMap<String, Sidecar>,
191    pub mocks: BTreeMap<String, MockServer>,
192    pub ui: Option<(Control, Mode)>,
193    #[cfg(feature = "browser")]
194    pub browser: Option<crate::browser::BrowserTarget>,
195    /// Files this scenario produced; the runner hashes them into the evidence bundle.
196    pub artifacts: Vec<(String, PathBuf)>,
197    step_no: usize,
198}
199
200impl<'a> Ctx<'a> {
201    pub fn new(
202        cfg: &'a QaConfig,
203        engine: Option<EngineTarget>,
204        ui_binary: Option<PathBuf>,
205        ws: &'a QaWorkspace,
206        tracker: &'a Tracker,
207        scenario: &str,
208    ) -> Result<Self> {
209        let data_dir = ws.scenario_dir(scenario)?;
210        let log_dir = ws
211            .evidence_dir
212            .join(data_dir.file_name().unwrap_or_default());
213        std::fs::create_dir_all(&log_dir)?;
214        // Templates passed through ui_command/ui_eval must never name managed storage.
215        let app_evidence_dir = ws
216            .home
217            .join("evidence")
218            .join(data_dir.file_name().unwrap_or_default());
219        std::fs::create_dir_all(&app_evidence_dir)?;
220        let mut vars = BTreeMap::new();
221        // Every path a scenario can expand goes through `scenario_path_text`, so text such as
222        // `${data_dir}/proj/README.txt` is a path Windows accepts.
223        vars.insert("data_dir".into(), json!(scenario_path_text(&data_dir)));
224        vars.insert(
225            "evidence_dir".into(),
226            json!(scenario_path_text(&app_evidence_dir)),
227        );
228        vars.insert("app_root".into(), json!(scenario_path_text(&cfg.app_root)));
229        vars.insert(
230            "engine".into(),
231            json!(engine.as_ref().map(|e| scenario_path_text(&e.binary))),
232        );
233        Ok(Self {
234            cfg,
235            engine,
236            ui_binary,
237            ws,
238            tracker,
239            scenario: scenario.into(),
240            data_dir,
241            log_dir,
242            vars,
243            sidecars: BTreeMap::new(),
244            mocks: BTreeMap::new(),
245            ui: None,
246            #[cfg(feature = "browser")]
247            browser: None,
248            artifacts: vec![],
249            step_no: 0,
250        })
251    }
252
253    fn engine(&self) -> Result<&EngineTarget> {
254        self.engine.as_ref().ok_or_else(|| Error("no engine binary configured (set [engine].binary, RIGHTKIT_QA_ENGINE, or pass --build)".into()))
255    }
256
257    fn ui(&self) -> Result<&Control> {
258        self.ui
259            .as_ref()
260            .map(|(c, _)| c)
261            .ok_or_else(|| Error("no UI app launched; add a ui_launch step first".into()))
262    }
263
264    fn artifact(&mut self, name: &str, path: PathBuf) {
265        self.artifacts
266            .push((format!("{}/{}", self.scenario, name), path));
267    }
268
269    /// Stop everything this scenario started, in reverse dependency order.
270    /// Returns problems found while cleaning up (a failed stop is a failed scenario).
271    pub fn teardown(&mut self) -> Vec<String> {
272        let mut problems = vec![];
273        #[cfg(feature = "browser")]
274        if let Some(mut b) = self.browser.take() {
275            b.stop();
276        }
277        if let Some((mut c, mode)) = self.ui.take() {
278            match c.stop() {
279                Ok(r) => {
280                    if !r.process_gone || !r.endpoint_closed {
281                        problems.push(format!(
282                            "UI app pid {} not fully stopped (process_gone={}, endpoint_closed={})",
283                            r.pid, r.process_gone, r.endpoint_closed
284                        ));
285                    }
286                    if mode != Mode::Visible && !r.owned_never_frontmost {
287                        problems.push(format!(
288                            "owned UI process reached foreground: {:?}",
289                            r.owned_frontmost_pids
290                        ));
291                    }
292                }
293                Err(e) => problems.push(e.0),
294            }
295        }
296        for (_, mut s) in std::mem::take(&mut self.sidecars) {
297            s.close();
298        }
299        self.mocks.clear();
300        if let Err(e) = self.tracker.assert_no_orphans() {
301            problems.push(e.0);
302        }
303        problems
304    }
305}
306
307// --------------------------------------------------------------------- params
308
309pub(crate) fn pv<'p>(p: &'p Map<String, Value>, k: &str) -> Option<&'p Value> {
310    p.get(k).filter(|v| !v.is_null())
311}
312pub(crate) fn ps(p: &Map<String, Value>, k: &str) -> Result<String> {
313    pv(p, k)
314        .and_then(Value::as_str)
315        .map(String::from)
316        .ok_or_else(|| Error(format!("step needs string '{k}'")))
317}
318pub(crate) fn ps_opt(p: &Map<String, Value>, k: &str) -> Option<String> {
319    pv(p, k).and_then(Value::as_str).map(String::from)
320}
321pub(crate) fn pf(p: &Map<String, Value>, k: &str) -> Result<f64> {
322    pv(p, k)
323        .and_then(Value::as_f64)
324        .ok_or_else(|| Error(format!("step needs number '{k}'")))
325}
326pub(crate) fn pf_or(p: &Map<String, Value>, k: &str, d: f64) -> f64 {
327    pv(p, k).and_then(Value::as_f64).unwrap_or(d)
328}
329pub(crate) fn pb(p: &Map<String, Value>, k: &str) -> bool {
330    pv(p, k).and_then(Value::as_bool).unwrap_or(false)
331}
332pub(crate) fn dur(p: &Map<String, Value>, k: &str, default_s: f64) -> Duration {
333    Duration::from_millis((pf_or(p, k, default_s) * 1000.0) as u64)
334}
335/// A Windows verbatim path (`\\?\C:\dir`, `\\?\UNC\server\share`) in its ordinary form
336/// (`C:\dir`, `\\server\share`), when it fits the ordinary length limit. Anything else is
337/// returned unchanged, including a verbatim path too long to be written without the prefix.
338///
339/// A canonicalised path on Windows is verbatim, and under that prefix Windows does no
340/// normalisation: `/` is a file-name character, not a separator. Scenario text joins with `/`.
341pub(crate) fn strip_verbatim(text: &str) -> String {
342    // Below MAX_PATH (260) with room for a file name appended by the scenario.
343    const ORDINARY_LIMIT: usize = 200;
344    let plain = if let Some(unc) = text.strip_prefix(r"\\?\UNC\") {
345        format!(r"\\{unc}")
346    } else if let Some(rest) = text.strip_prefix(r"\\?\") {
347        let bytes = rest.as_bytes();
348        let drive = bytes.len() >= 3
349            && bytes[0].is_ascii_alphabetic()
350            && bytes[1] == b':'
351            && bytes[2] == b'\\';
352        if !drive {
353            return text.to_owned();
354        }
355        rest.to_owned()
356    } else {
357        return text.to_owned();
358    };
359    if plain.chars().count() > ORDINARY_LIMIT {
360        text.to_owned()
361    } else {
362        plain
363    }
364}
365
366/// The text a scenario variable holds for `path`: the path itself, in ordinary (non-verbatim)
367/// form on Windows where it fits.
368fn scenario_path_text(path: &Path) -> String {
369    let text = path.to_string_lossy();
370    if cfg!(windows) {
371        strip_verbatim(&text)
372    } else {
373        text.into_owned()
374    }
375}
376
377/// `s` as a path of this host. On Windows every `/` becomes `\`, which is the same path for an
378/// ordinary one and the only correct reading for a verbatim one.
379fn host_path(s: &str) -> PathBuf {
380    if cfg!(windows) {
381        PathBuf::from(s.replace('/', "\\"))
382    } else {
383        PathBuf::from(s)
384    }
385}
386
387/// A scenario path: absolute as it is, relative inside `data_dir`. Separators are normalised
388/// after the join, so a relative `a/b` under a verbatim `data_dir` is still two components.
389fn resolve_in(data_dir: &Path, s: &str) -> PathBuf {
390    let p = host_path(s);
391    if p.is_absolute() {
392        p
393    } else {
394        data_dir.join(p)
395    }
396}
397
398fn resolve(ctx: &Ctx, s: &str) -> PathBuf {
399    resolve_in(&ctx.data_dir, s)
400}
401fn env_pairs(p: &Map<String, Value>) -> Vec<(String, String)> {
402    pv(p, "env")
403        .and_then(Value::as_object)
404        .map(|m| {
405            m.iter()
406                .map(|(k, v)| {
407                    (
408                        k.clone(),
409                        v.as_str()
410                            .map(String::from)
411                            .unwrap_or_else(|| v.to_string()),
412                    )
413                })
414                .collect()
415        })
416        .unwrap_or_default()
417}
418fn cli_opts(ctx: &Ctx, p: &Map<String, Value>) -> CliOptions {
419    CliOptions {
420        data_dir: Some(ctx.data_dir.clone()),
421        env: env_pairs(p),
422        input: pv(p, "input").cloned(),
423        timeout: Some(dur(p, "timeout_s", 120.0)),
424    }
425}
426
427// ------------------------------------------------------------------- execution
428
429#[derive(Debug, Clone)]
430pub struct StepRecord {
431    pub index: usize,
432    pub kind: String,
433    pub name: String,
434    pub ok: bool,
435    pub ms: u128,
436    pub error: Option<String>,
437}
438
439/// Run one step including retry-until-expectations-hold.
440pub fn run_step(ctx: &mut Ctx, step: &Step) -> Result<Value> {
441    ctx.step_no += 1;
442    let started = Instant::now();
443    let until = step.until_timeout_s.map(Duration::from_secs);
444    let interval = Duration::from_millis(step.interval_ms.unwrap_or(500));
445    loop {
446        let attempt: Result<Value> = (|| {
447            let resolved = interpolate(
448                &Value::Object(step.params.clone().into_iter().collect()),
449                &ctx.vars,
450            )?;
451            let params = resolved.as_object().cloned().unwrap_or_default();
452            let result = exec(ctx, &step.kind, &params)?;
453            let expects: Vec<Expect> = step
454                .expect
455                .iter()
456                .map(|e| resolve_expect(e, &ctx.vars))
457                .collect::<Result<_>>()?;
458            check_all(&result, &expects)?;
459            Ok(result)
460        })();
461        match attempt {
462            Ok(v) => {
463                if let Some(name) = &step.save {
464                    ctx.vars.insert(name.clone(), v.clone());
465                }
466                return Ok(v);
467            }
468            Err(e) => match until {
469                Some(limit) if started.elapsed() < limit => std::thread::sleep(interval),
470                _ => {
471                    return Err(Error(format!(
472                        "step {} ({}): {}",
473                        ctx.step_no, step.kind, e.0
474                    )))
475                }
476            },
477        }
478    }
479}
480
481fn resolve_expect(e: &Expect, vars: &BTreeMap<String, Value>) -> Result<Expect> {
482    let mut out = e.clone();
483    if let Some(v) = &e.eq {
484        out.eq = Some(interpolate(v, vars)?);
485    }
486    if let Some(v) = &e.ne {
487        out.ne = Some(interpolate(v, vars)?);
488    }
489    if let Some(v) = &e.contains {
490        out.contains = Some(interpolate(v, vars)?);
491    }
492    Ok(out)
493}
494
495fn exec(ctx: &mut Ctx, kind: &str, p: &Map<String, Value>) -> Result<Value> {
496    match kind {
497        // ------------------------------------------------------------ engine
498        "cli" => {
499            let args: Vec<String> = match (pv(p, "args"), ps_opt(p, "command")) {
500                (Some(Value::Array(a)), _) => a
501                    .iter()
502                    .map(|v| {
503                        v.as_str()
504                            .map(String::from)
505                            .unwrap_or_else(|| v.to_string())
506                    })
507                    .collect(),
508                (_, Some(c)) => c.split_whitespace().map(String::from).collect(),
509                _ => return err("cli step needs 'command' or 'args'"),
510            };
511            let r = ctx.engine()?.cli(&args, &cli_opts(ctx, p), ctx.tracker)?;
512            let log = ctx.log_dir.join(format!("{:02}-cli.log", ctx.step_no));
513            std::fs::write(
514                &log,
515                format!(
516                    "$ {}\nexit {:?}\n--- stdout\n{}\n--- stderr\n{}\n",
517                    args.join(" "),
518                    r.code,
519                    r.stdout,
520                    r.stderr
521                ),
522            )?;
523            ctx.artifact(&format!("{:02}-cli.log", ctx.step_no), log);
524            let ok = r.code == Some(0) && r.json.get("error").map(Value::is_null).unwrap_or(true);
525            if !ok && !pb(p, "allow_error") {
526                return err(format!(
527                    "engine `{}` failed (exit {:?}{}): stdout {} stderr tail {}",
528                    args.join(" "),
529                    r.code,
530                    if r.timed_out { ", timed out" } else { "" },
531                    crate::util::tail(&r.stdout, 600),
532                    crate::util::tail(&r.stderr, 600)
533                ));
534            }
535            Ok(r.to_value())
536        }
537        "sidecar_start" => {
538            let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
539            let s = ctx
540                .engine()?
541                .start_sidecar(&cli_opts(ctx, p), ctx.tracker)?;
542            let mut s = s;
543            s.set_frame_shape(pv(p, "legacy_status_frames").and_then(Value::as_bool));
544            let hello = s.hello.clone();
545            ctx.sidecars.insert(id, s);
546            Ok(hello)
547        }
548        "sidecar_command" => {
549            let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
550            let name = ps(p, "name")?;
551            let input = pv(p, "input").cloned().unwrap_or_else(|| json!({}));
552            let timeout = dur(p, "timeout_s", 120.0);
553            let s = ctx
554                .sidecars
555                .get_mut(&id)
556                .ok_or_else(|| Error(format!("no sidecar '{id}'")))?;
557            let legacy = pv(p, "legacy_status_frames").and_then(Value::as_bool);
558            match s.command_with(&name, input, timeout, legacy) {
559                Ok(v) => Ok(v),
560                Err(e) if pb(p, "allow_error") => Ok(json!({"error": e.0})),
561                Err(e) => Err(e),
562            }
563        }
564        "sidecar_raw" => {
565            let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
566            let frame = pv(p, "frame")
567                .cloned()
568                .ok_or_else(|| Error("sidecar_raw needs 'frame'".into()))?;
569            let timeout = dur(p, "timeout_s", 60.0);
570            ctx.sidecars
571                .get_mut(&id)
572                .ok_or_else(|| Error(format!("no sidecar '{id}'")))?
573                .raw(frame, timeout)
574        }
575        "sidecar_close" => {
576            let id = ps_opt(p, "id").unwrap_or_else(|| "sidecar".into());
577            let mut s = ctx
578                .sidecars
579                .remove(&id)
580                .ok_or_else(|| Error(format!("no sidecar '{id}'")))?;
581            let code = s.close();
582            Ok(json!({"exit_code": code, "stderr_tail": s.stderr_tail()}))
583        }
584        // -------------------------------------------------------------- mocks
585        "mock_start" => {
586            let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
587            let routes = pv(p, "routes")
588                .and_then(Value::as_array)
589                .map(|a| {
590                    a.iter()
591                        .map(|r| Route {
592                            method: r["method"].as_str().map(String::from),
593                            path: r["path"].as_str().unwrap_or("/").to_string(),
594                            status: r["status"].as_u64().unwrap_or(200) as u16,
595                            body: r
596                                .get("json")
597                                .or_else(|| r.get("body"))
598                                .cloned()
599                                .unwrap_or(Value::Null),
600                            content_type: r["content_type"].as_str().map(String::from),
601                        })
602                        .collect()
603                })
604                .unwrap_or_default();
605            let m = MockServer::start(routes)?;
606            let base = m.base.clone();
607            ctx.mocks.insert(id, m);
608            Ok(json!({"base": base}))
609        }
610        "mock_requests" => {
611            let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
612            let m = ctx
613                .mocks
614                .get(&id)
615                .ok_or_else(|| Error(format!("no mock '{id}'")))?;
616            Ok(json!({"requests": m.seen().iter().map(|s| s.to_value()).collect::<Vec<_>>()}))
617        }
618        "mock_stop" => {
619            let id = ps_opt(p, "id").unwrap_or_else(|| "mock".into());
620            ctx.mocks.remove(&id);
621            Ok(json!({"stopped": true}))
622        }
623        // -------------------------------------------------------------- files
624        "write_wav" => {
625            let path = resolve(ctx, &ps(p, "path")?);
626            media::write_reference_wav(&path, pf_or(p, "seconds", 6.0))?;
627            Ok(json!({"path": path.to_string_lossy()}))
628        }
629        "write_file" => {
630            let path = resolve(ctx, &ps(p, "path")?);
631            if let Some(d) = path.parent() {
632                std::fs::create_dir_all(d)?;
633            }
634            std::fs::write(&path, ps_opt(p, "content").unwrap_or_default())?;
635            Ok(json!({"path": path.to_string_lossy()}))
636        }
637        "file_stat" => Ok(media::file_stat(&resolve(ctx, &ps(p, "path")?))),
638        "wav_stats" => media::wav_stats(&resolve(ctx, &ps(p, "path")?)),
639        "png_size" => media::png_size(&resolve(ctx, &ps(p, "path")?)),
640        "ffprobe" => match media::ffprobe(&resolve(ctx, &ps(p, "path")?), ctx.tracker) {
641            Ok(v) => Ok(v),
642            Err(e) if pb(p, "optional") => Ok(json!({"skipped": e.0})),
643            Err(e) => Err(e),
644        },
645        // --------------------------------------------------------------- misc
646        "set" => Ok(pv(p, "value").cloned().unwrap_or(Value::Null)),
647        "sleep" => {
648            sleep_ms(pf_or(p, "ms", 100.0) as u64);
649            Ok(json!({"slept_ms": pf_or(p, "ms", 100.0)}))
650        }
651        "assert_no_orphans" => {
652            ctx.tracker.assert_no_orphans()?;
653            Ok(json!({"alive": 0}))
654        }
655        // ------------------------------------------------------------- UI app
656        "ui_launch" => {
657            if ctx.ui.is_some() {
658                return err("a UI app is already running in this scenario");
659            }
660            let binary = match ps_opt(p, "binary") {
661                Some(b) => ctx.cfg.app_root.join(b),
662                None => ctx.ui_binary.clone().ok_or_else(|| Error("no UI binary configured: set [ui].binaries, pass --ui-binary, or `binary` on the step".into()))?,
663            };
664            let mode = Mode::parse(&ps_opt(p, "mode").unwrap_or_else(|| ctx.cfg.ui.mode.clone()))?;
665            let mut env: Vec<(String, String)> = ctx
666                .cfg
667                .ui
668                .env
669                .iter()
670                .map(|(k, v)| (k.clone(), v.clone()))
671                .collect();
672            env.extend(ctx.cfg.env.iter().map(|(k, v)| (k.clone(), v.clone())));
673            if let Some(var) = &ctx.cfg.engine.data_dir_env {
674                env.push((var.clone(), ctx.data_dir.to_string_lossy().into()));
675            }
676            env.extend(env_pairs(p));
677            let spec = LaunchSpec {
678                binary,
679                mode,
680                env,
681                startup_timeout: Duration::from_secs(ctx.cfg.ui.startup_timeout_s.unwrap_or(90)),
682                label: format!("{} app", ctx.cfg.app),
683            };
684            let c = control::launch(&spec, ctx.ws, ctx.tracker)?;
685            let out = json!({"pid": c.pid, "service": c.service, "mode": format!("{mode:?}").to_lowercase(), "health": c.health()?, "log": c.log_path.to_string_lossy()});
686            let log = c.log_path.clone();
687            ctx.ui = Some((c, mode));
688            ctx.artifact("app.log", log);
689            Ok(out)
690        }
691        "ui_stop" => {
692            let (mut c, mode) = ctx
693                .ui
694                .take()
695                .ok_or_else(|| Error("no UI app running".into()))?;
696            let r = c.stop()?;
697            if !r.process_gone || !r.endpoint_closed {
698                return err(format!(
699                    "app pid {} not fully stopped (process_gone={}, endpoint_closed={})",
700                    r.pid, r.process_gone, r.endpoint_closed
701                ));
702            }
703            if mode != Mode::Visible && !r.owned_never_frontmost {
704                return err(format!(
705                    "owned UI process reached foreground: {:?}",
706                    r.owned_frontmost_pids
707                ));
708            }
709            Ok(
710                json!({"pid": r.pid, "endpoint_closed": r.endpoint_closed, "process_gone": r.process_gone, "frontmost_unchanged": r.frontmost_unchanged, "owned_never_frontmost": r.owned_never_frontmost, "owned_frontmost_pids": r.owned_frontmost_pids}),
711            )
712        }
713        "ui_auth_probe" => {
714            let c = ctx.ui()?;
715            Ok(json!({"unlisted_app_error": c.unlisted_app_probe()?}))
716        }
717        "ui_click" => {
718            let c = ctx.ui()?;
719            let button = ps_opt(p, "button").unwrap_or_else(|| "left".into());
720            let count = pf_or(p, "count", 1.0) as u32;
721            let (x, y) = match ps_opt(p, "selector") {
722                Some(sel) => c.center_of(&sel)?,
723                None => (pf(p, "x")?, pf(p, "y")?),
724            };
725            c.click(x, y, &button, count)?;
726            Ok(json!({"x": x, "y": y}))
727        }
728        "ui_move" => {
729            let c = ctx.ui()?;
730            let (x, y) = match ps_opt(p, "selector") {
731                Some(sel) => c.center_of(&sel)?,
732                None => (pf(p, "x")?, pf(p, "y")?),
733            };
734            c.move_to(x, y)?;
735            Ok(json!({"x": x, "y": y}))
736        }
737        "ui_drag" => {
738            let c = ctx.ui()?;
739            let from = match ps_opt(p, "from_selector") {
740                Some(s) => c.center_of(&s)?,
741                None => (pf(p, "x1")?, pf(p, "y1")?),
742            };
743            let to = match ps_opt(p, "to_selector") {
744                Some(s) => c.center_of(&s)?,
745                None => (pf(p, "x2")?, pf(p, "y2")?),
746            };
747            c.drag(from, to, pf_or(p, "steps", 12.0) as u32)?;
748            Ok(json!({"from": [from.0, from.1], "to": [to.0, to.1]}))
749        }
750        "ui_wheel" => {
751            let c = ctx.ui()?;
752            let (x, y) = match ps_opt(p, "selector") {
753                Some(sel) => c.center_of(&sel)?,
754                None => (pf_or(p, "x", 100.0), pf_or(p, "y", 100.0)),
755            };
756            c.wheel(x, y, pf_or(p, "dx", 0.0), pf_or(p, "dy", 0.0))?;
757            Ok(json!({"x": x, "y": y}))
758        }
759        "ui_key" => {
760            ctx.ui()?.key(&ps(p, "key")?)?;
761            Ok(json!({"ok": true}))
762        }
763        "ui_type" => {
764            ctx.ui()?.type_text(&ps(p, "text")?)?;
765            Ok(json!({"ok": true}))
766        }
767        "ui_eval" => ctx.ui()?.eval(&ps(p, "js")?),
768        "ui_wait_eval" => ctx
769            .ui()?
770            .wait_eval(&ps(p, "js")?, dur(p, "timeout_s", 20.0)),
771        "ui_dom" => Ok(json!({"elements": ctx.ui()?.dom(&ps(p, "selector")?)?})),
772        "ui_ax" => Ok(json!({"nodes": ctx.ui()?.ax()?})),
773        "ui_command" => ctx
774            .ui()?
775            .command(&ps(p, "name")?, pv(p, "args").unwrap_or(&Value::Null)),
776        "ui_screenshot" => {
777            let name = ps_opt(p, "name").unwrap_or_else(|| format!("shot-{:02}", ctx.step_no));
778            let path = ctx.log_dir.join(format!("{name}.png"));
779            ctx.ui()?.screenshot_to(&path)?;
780            let size = media::png_size(&path)?;
781            ctx.artifact(&format!("{name}.png"), path.clone());
782            Ok(json!({"path": path.to_string_lossy(), "size": size}))
783        }
784        "ui_measure_paint" => {
785            let c = ctx.ui()?;
786            let trigger_sel = ps(p, "click_selector")?;
787            let id = format!("paint-{}", util::new_id());
788            let m = page::measure_paint(
789                c,
790                &mut || c.click_selector(&trigger_sel),
791                &id,
792                ps_opt(p, "selector").as_deref(),
793                pf_or(p, "settle_ms", page::DEFAULT_SETTLE_MS as f64) as u64,
794                pf_or(p, "timeout_ms", page::DEFAULT_PAINT_TIMEOUT_MS as f64) as u64,
795            )?;
796            if let Some(budget) = pv(p, "budget_ms").and_then(Value::as_f64) {
797                page::assert_paint_within(&m, budget, &trigger_sel)?;
798            }
799            Ok(
800                json!({"duration_ms": m.duration_ms, "settle_ms": m.settle_ms, "mutations": m.mutations, "animation_frames": m.animation_frames}),
801            )
802        }
803        "ui_assert_pane" => {
804            let g = page::read_pane_geometry(ctx.ui()?, &ps(p, "selector")?)?;
805            page::assert_active_pane(&g)?;
806            Ok(
807                json!({"x": g.x, "y": g.y, "width": g.width, "height": g.height, "matches": g.matches}),
808            )
809        }
810        "ui_resolve_frame" => {
811            let path = page::resolve_unique_frame(
812                ctx.ui()?,
813                &ps(p, "selector")?,
814                pf_or(p, "max_depth", 4.0) as usize,
815            )?;
816            Ok(json!({"frame": path, "described": page::describe_frame(&path)}))
817        }
818        "ui_eval_in_frame" => {
819            let frame: Vec<usize> = pv(p, "frame")
820                .and_then(Value::as_array)
821                .map(|a| {
822                    a.iter()
823                        .filter_map(|i| i.as_u64().map(|i| i as usize))
824                        .collect()
825                })
826                .unwrap_or_default();
827            page::eval_in_frame(ctx.ui()?, &frame, &ps(p, "js")?)
828        }
829        #[cfg(feature = "browser")]
830        k if k.starts_with("browser_") => crate::browser::exec(ctx, k, p),
831        other => err(format!("unknown step kind '{other}'")),
832    }
833}
834
835#[cfg(feature = "browser")]
836pub(crate) mod params {
837    pub(crate) use super::{dur, pb, pf_or, ps, ps_opt, pv};
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    #[test]
845    fn parses_scenario_toml_with_flattened_params_and_expects() {
846        let text = r#"
847name = "engine doctor"
848tier = "fast"
849[[step]]
850kind = "cli"
851command = "doctor"
852save = "doc"
853  [[step.expect]]
854  path = "json.contract_version"
855  gte = 2
856[[step]]
857kind = "cli"
858args = ["spend", "status"]
859input = { a = 1 }
860"#;
861        let path = std::env::temp_dir().join(format!("rkqa-{}.scenario.toml", util::new_id()));
862        std::fs::write(&path, text).unwrap();
863        let s = Scenario::load(&path).unwrap();
864        assert_eq!(s.steps.len(), 2);
865        assert_eq!(s.steps[0].params["command"], json!("doctor"));
866        assert_eq!(s.steps[0].expect[0].gte, Some(2.0));
867        assert_eq!(s.steps[1].params["input"], json!({"a": 1}));
868        let _ = std::fs::remove_file(path);
869    }
870
871    #[test]
872    fn tier_selection_and_requirements() {
873        assert!(tier_selected("fast", "fast"));
874        assert!(!tier_selected("paid", "fast"));
875        assert!(tier_selected("paid", "all"));
876        assert!(tier_selected("local-models", "fast,local-models"));
877        assert_eq!(
878            unmet_requirement(&["env:RKQA_SURELY_UNSET_VAR".into()], Path::new("/")).as_deref(),
879            Some("missing env RKQA_SURELY_UNSET_VAR")
880        );
881        assert_eq!(
882            unmet_requirement(&["file:/definitely/not/here".into()], Path::new("/")).as_deref(),
883            Some("missing file /definitely/not/here")
884        );
885        assert!(unmet_requirement(&[], Path::new("/")).is_none());
886    }
887
888    #[test]
889    fn a_verbatim_path_is_given_to_scenarios_in_its_ordinary_form() {
890        assert_eq!(strip_verbatim(r"\\?\C:\qa\run-1"), r"C:\qa\run-1");
891        assert_eq!(
892            strip_verbatim(r"\\?\UNC\server\share\qa"),
893            r"\\server\share\qa"
894        );
895        // Already ordinary, not Windows at all, or a verbatim form with no ordinary spelling.
896        for unchanged in [
897            r"C:\qa\run-1",
898            "/tmp/qa/run-1",
899            r"\\?\Volume{4c1b02c1-d990-11dc-99ae-806e6f6e6963}\qa",
900            r"\\?\GLOBALROOT\Device\x",
901        ] {
902            assert_eq!(strip_verbatim(unchanged), unchanged);
903        }
904        // Too long to be written without the prefix: left verbatim.
905        let long = format!(r"\\?\C:\{}", "d".repeat(260));
906        assert_eq!(strip_verbatim(&long), long);
907    }
908
909    #[cfg(not(windows))]
910    #[test]
911    fn scenario_paths_are_untouched_off_windows() {
912        assert_eq!(
913            resolve_in(Path::new("/data"), "proj/a\\b.txt"),
914            Path::new("/data/proj/a\\b.txt")
915        );
916        assert_eq!(
917            resolve_in(Path::new("/data"), "/abs/x"),
918            Path::new("/abs/x")
919        );
920        assert_eq!(scenario_path_text(Path::new("/data/run")), "/data/run");
921    }
922
923    /// The defect: `${data_dir}/proj/README.txt` under a canonicalised (verbatim) data
924    /// directory reached the file system with a `/` Windows does not split on (os error 123).
925    #[cfg(windows)]
926    #[test]
927    fn a_scenario_path_mixing_separators_reaches_the_file_system_on_windows() {
928        let root = std::env::temp_dir().join(format!("rkqa-paths-{}", util::new_id()));
929        std::fs::create_dir_all(&root).unwrap();
930        let data_dir = std::fs::canonicalize(&root).unwrap();
931        assert!(
932            data_dir.to_string_lossy().starts_with(r"\\?\"),
933            "{data_dir:?}"
934        );
935
936        // The variable a scenario expands is ordinary, so text built on it is a valid path.
937        let var = scenario_path_text(&data_dir);
938        assert!(!var.starts_with(r"\\?\"), "{var}");
939        let expanded = resolve_in(&data_dir, &format!("{var}/proj/README.txt"));
940        std::fs::create_dir_all(expanded.parent().unwrap()).unwrap();
941        std::fs::write(&expanded, "one").unwrap();
942
943        // A relative path with `/`, joined to the verbatim directory itself.
944        let relative = resolve_in(&data_dir, "proj/nested/b.txt");
945        std::fs::create_dir_all(relative.parent().unwrap()).unwrap();
946        std::fs::write(&relative, "two").unwrap();
947
948        // Text built on the verbatim form, as a scenario written before this fix would hold.
949        let verbatim = resolve_in(
950            &data_dir,
951            &format!("{}/proj/c.txt", data_dir.to_string_lossy()),
952        );
953        std::fs::write(&verbatim, "three").unwrap();
954
955        // All three are real files in the same directory tree.
956        assert_eq!(
957            std::fs::read_to_string(root.join("proj").join("README.txt")).unwrap(),
958            "one"
959        );
960        assert_eq!(
961            std::fs::read_to_string(root.join("proj").join("nested").join("b.txt")).unwrap(),
962            "two"
963        );
964        assert_eq!(
965            std::fs::read_to_string(root.join("proj").join("c.txt")).unwrap(),
966            "three"
967        );
968        assert!(std::fs::metadata(&expanded).unwrap().is_file());
969        std::fs::remove_dir_all(root).unwrap();
970    }
971}