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