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