Skip to main content

rightkit_qa/
suite.rs

1//! A QA run: select scenarios by tier, take the run lock, create the isolated
2//! workspace, run scenarios sequentially, sweep orphans, and write hashed evidence.
3use crate::audio::SystemMute;
4use crate::config::{validate_tier_list, QaConfig};
5use crate::engine::{build_engine, EngineTarget};
6use crate::evidence::{Bundle, CheckStatus, RunStatus};
7use crate::lock::{self, LockOptions};
8use crate::process::{sweep_registries, Tracker};
9use crate::scenario::{
10    discover, platform_mismatch, resolve_run_kind, run_step, scenario_kind, tier_selected,
11    unmet_requirement, Ctx, Scenario, StepHandlers, StepRecord,
12};
13use crate::storage::{self, RunKind, RunOutcome};
14use crate::util::{err, now_iso, platform, Result};
15use crate::workspace;
16use serde_json::{json, Value};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21/// Per-scenario callback: gets the scenario's [`Ctx`] (already built, run kind set) and the scenario.
22pub type ScenarioHook = Arc<dyn for<'a> Fn(&mut Ctx<'a>, &Scenario) -> Result<()> + Send + Sync>;
23
24/// App-defined behaviour for a suite run (rightkit#456). `SuiteHooks::default()` changes nothing.
25#[derive(Clone, Default)]
26pub struct SuiteHooks {
27    /// App-defined step kinds, see [`StepHandlers`].
28    pub steps: StepHandlers,
29    /// Runs once per scenario, after its [`Ctx`] exists and before its first step: start the app's
30    /// mock, set `ctx.vars` entries (`mock_url`, ...), register cleanup. An `Err` fails the scenario
31    /// without running its steps; [`SuiteHooks::teardown`] still runs.
32    pub setup: Option<ScenarioHook>,
33    /// Runs once per scenario that reached setup, after its steps (pass or fail) and before the
34    /// built-in teardown (UI, sidecars, mocks) and the run's orphan sweep, while `ctx` is still
35    /// live. An `Err` is a teardown problem and fails an otherwise passing scenario.
36    pub teardown: Option<ScenarioHook>,
37}
38
39impl SuiteHooks {
40    /// Register a custom step kind (builder style).
41    pub fn step(
42        mut self,
43        kind: &str,
44        handler: impl for<'a> Fn(&mut Ctx<'a>, &serde_json::Map<String, Value>) -> Result<Value>
45            + Send
46            + Sync
47            + 'static,
48    ) -> Self {
49        self.steps.register(kind, handler);
50        self
51    }
52    pub fn setup(
53        mut self,
54        f: impl for<'a> Fn(&mut Ctx<'a>, &Scenario) -> Result<()> + Send + Sync + 'static,
55    ) -> Self {
56        self.setup = Some(Arc::new(f));
57        self
58    }
59    pub fn teardown(
60        mut self,
61        f: impl for<'a> Fn(&mut Ctx<'a>, &Scenario) -> Result<()> + Send + Sync + 'static,
62    ) -> Self {
63        self.teardown = Some(Arc::new(f));
64        self
65    }
66}
67
68impl std::fmt::Debug for SuiteHooks {
69    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
70        f.debug_struct("SuiteHooks")
71            .field("steps", &self.steps)
72            .field("setup", &self.setup.is_some())
73            .field("teardown", &self.teardown.is_some())
74            .finish()
75    }
76}
77
78#[derive(Debug, Clone, Default)]
79pub struct SuiteOptions {
80    pub tier: Option<String>,
81    pub only: Option<String>,
82    pub engine: Option<PathBuf>,
83    /// An explicit UI executable. Skips the resolver's validation; still refused when it is an
84    /// installed app (see [`explicit_ui_binary`]).
85    pub ui_binary: Option<PathBuf>,
86    pub build_engine: bool,
87    pub build_ui: bool,
88    pub keep_data: bool,
89    /// Run kind: `functional` (clicks, state, text; no screenshots or recordings) or `visual`.
90    /// `None` lets the scenarios decide ([`resolve_run_kind`]); with none declaring `visual` the
91    /// run is functional.
92    pub kind: Option<RunKind>,
93    /// Explicit scenario files (override discovery).
94    pub files: Vec<PathBuf>,
95    /// App-defined step kinds and per-scenario setup/teardown.
96    pub hooks: SuiteHooks,
97    /// Kill tracked processes still alive after each scenario's teardown, without failing the
98    /// scenario, so one scenario's stray process cannot fail the ones after it. A scenario can
99    /// also set `kill_orphans = true`. Off by default: survivors fail the scenario.
100    pub kill_orphans: bool,
101}
102
103#[derive(Debug, Clone)]
104pub struct Outcome {
105    pub name: String,
106    pub file: PathBuf,
107    pub tier: String,
108    pub status: CheckStatus,
109    pub reason: Option<String>,
110    pub ms: u128,
111    pub steps: Vec<StepRecord>,
112}
113
114impl Outcome {
115    fn to_value(&self) -> Value {
116        json!({
117            "name": self.name, "file": self.file.to_string_lossy(), "tier": self.tier,
118            "status": format!("{:?}", self.status).to_lowercase(), "reason": self.reason, "ms": self.ms,
119            "steps": self.steps.iter().map(|s| json!({"index": s.index, "kind": s.kind, "name": s.name, "ok": s.ok, "ms": s.ms, "error": s.error})).collect::<Vec<_>>(),
120        })
121    }
122}
123
124#[derive(Debug)]
125pub struct SuiteReport {
126    pub exit_code: i32,
127    pub run_id: String,
128    pub run_dir: PathBuf,
129    pub evidence_path: PathBuf,
130    pub results_path: PathBuf,
131    pub outcomes: Vec<Outcome>,
132}
133
134/// Whether `only` selects the scenario: `only` is one name or a comma-separated list; a name
135/// matches when it is a substring of the scenario name or file path, or equals the file stem (the
136/// file name without `.scenario.toml`) exactly.
137pub fn only_matches(only: &str, name: &str, file: &Path) -> bool {
138    let fname = file
139        .file_name()
140        .map(|n| n.to_string_lossy().to_string())
141        .unwrap_or_default();
142    let stem = fname
143        .strip_suffix(".scenario.toml")
144        .map(str::to_string)
145        .unwrap_or_else(|| {
146            file.file_stem()
147                .map(|s| s.to_string_lossy().to_string())
148                .unwrap_or_default()
149        });
150    let path = file.to_string_lossy();
151    only.split(',')
152        .map(str::trim)
153        .filter(|o| !o.is_empty())
154        .any(|o| name.contains(o) || path.contains(o) || stem == o)
155}
156
157pub fn select(cfg: &QaConfig, opts: &SuiteOptions) -> Result<Vec<Scenario>> {
158    let tier = opts
159        .tier
160        .clone()
161        .unwrap_or_else(|| cfg.default_tier.clone());
162    validate_tier_list(&tier)?;
163    let files = if opts.files.is_empty() {
164        discover(&cfg.scenario_dirs)?
165    } else {
166        opts.files.clone()
167    };
168    let mut out = vec![];
169    for f in files {
170        let s = Scenario::load(&f)?;
171        let matches_only = opts
172            .only
173            .as_ref()
174            .map(|o| only_matches(o, &s.name, &f))
175            .unwrap_or(true);
176        if matches_only && (!opts.files.is_empty() || tier_selected(&s.tier, &tier)) {
177            out.push(s);
178        }
179    }
180    Ok(out)
181}
182
183fn engine_target(
184    cfg: &QaConfig,
185    opts: &SuiteOptions,
186    tracker: &Tracker,
187    tier: &str,
188) -> Result<Option<EngineTarget>> {
189    let mut env: Vec<(String, String)> = cfg
190        .env
191        .iter()
192        .chain(cfg.engine.env.iter())
193        .map(|(k, v)| (k.clone(), v.clone()))
194        .collect();
195    env.push(("RIGHTKIT_QA_TIER".into(), tier.into()));
196    env.push(("RIGHTKIT_QA_MUTE".into(), "1".into()));
197    let binary = if let Some(p) = &opts.engine {
198        Some(p.clone())
199    } else if let Ok(p) = std::env::var("RIGHTKIT_QA_ENGINE") {
200        Some(PathBuf::from(p))
201    } else if opts.build_engine {
202        if cfg.engine.build.is_empty() {
203            return err("--build needs [engine].build in the config");
204        }
205        let pkg = cfg.engine.package.clone().ok_or_else(|| {
206            crate::util::Error("--build needs [engine].package (the cargo target name)".into())
207        })?;
208        let build_env: Vec<(String, String)> = cfg
209            .env
210            .iter()
211            .map(|(k, v)| (k.clone(), v.clone()))
212            .collect();
213        Some(build_engine(
214            &cfg.app_root,
215            &cfg.engine.build,
216            &pkg,
217            &build_env,
218            tracker,
219        )?)
220    } else {
221        cfg.engine_binary()?
222    };
223    Ok(binary.map(|binary| EngineTarget {
224        env,
225        data_dir_env: cfg.engine.data_dir_env.clone(),
226        data_dir_arg: cfg.engine.data_dir_arg.clone(),
227        forbidden_args: cfg.engine.forbidden_args.clone(),
228        ..EngineTarget::new(binary)
229    }))
230}
231
232/// A UI binary the caller named (`SuiteOptions::ui_binary`, `--ui-binary`, `RIGHTKIT_QA_UI_BINARY`).
233/// It skips the resolver's checks (name, release directory, QA marker: the caller said which file),
234/// but the installed-app refusal stays in force (Orthic-Labs/rightkit#235), so the suite fails before
235/// any scenario starts instead of at the first `ui_launch`.
236pub fn explicit_ui_binary(path: &Path) -> Result<PathBuf> {
237    crate::installed::refuse_installed(path, "UI app binary")?;
238    Ok(path.to_path_buf())
239}
240
241/// An explicitly named UI binary, launched from the fixed stash like every other source (macOS keys privacy
242/// grants to the launch path, so the path must not follow the file the caller happened to name). The
243/// control-marker check stays skipped for it, and an app bundle (`.app`) is staged at its fixed path by
244/// `control::launch`.
245fn stashed_explicit_ui_binary(cfg: &QaConfig, path: &Path) -> Result<PathBuf> {
246    let named = explicit_ui_binary(path)?;
247    if named.is_dir() || crate::binary::is_stashed(&named, &cfg.app_root) {
248        return Ok(named);
249    }
250    crate::binary::stash_binary(&named, &cfg.app_root, None, false)
251}
252
253fn ui_binary(cfg: &QaConfig, opts: &SuiteOptions, tracker: &Tracker) -> Result<Option<PathBuf>> {
254    if let Some(p) = &opts.ui_binary {
255        return stashed_explicit_ui_binary(cfg, p).map(Some);
256    }
257    if let Ok(p) = std::env::var("RIGHTKIT_QA_UI_BINARY") {
258        return stashed_explicit_ui_binary(cfg, Path::new(&p)).map(Some);
259    }
260    if opts.build_ui {
261        if cfg.ui.build.is_empty() {
262            return err("--build-ui needs [ui].build in the config");
263        }
264        let pkg = cfg.ui.package.clone().ok_or_else(|| {
265            crate::util::Error("--build-ui needs [ui].package (the cargo target name)".into())
266        })?;
267        let env: Vec<(String, String)> = cfg
268            .env
269            .iter()
270            .map(|(k, v)| (k.clone(), v.clone()))
271            .collect();
272        let built = build_engine(&cfg.app_root, &cfg.ui.build, &pkg, &env, tracker)?;
273        // Keep a stable QA-only copy so a later plain dev build cannot displace it.
274        let kept =
275            crate::binary::stash_qa_build(&built, &cfg.app_root, cfg.ui.pointer_file.as_deref())?;
276        return Ok(Some(kept));
277    }
278    // No override and no build requested: the dev-build resolver (pointer file, broker target root,
279    // then `ui.binaries`). A config that names no executable keeps the old "none configured" answer.
280    match cfg.resolve_ui_binary() {
281        Ok(found) => {
282            eprintln!(
283                "[qa] ui binary ({:?}): {}",
284                found.source,
285                found.path.display()
286            );
287            Ok(Some(found.path))
288        }
289        // A found-but-not-QA build fails closed with its instructive message.
290        Err(err) if err.0.contains("test-mode/control build") => Err(err),
291        Err(err) => {
292            eprintln!("[qa] dev-build resolver: {err}; falling back to ui.binaries");
293            // Same rule as every other source: launch from the fixed stash, not from `target/`.
294            match cfg.ui_binary()? {
295                Some(p) => stashed_explicit_ui_binary(cfg, &p).map(Some),
296                None => Ok(None),
297            }
298        }
299    }
300}
301
302pub fn run_suite(cfg: &QaConfig, opts: &SuiteOptions) -> Result<SuiteReport> {
303    run_suite_at(cfg, opts, None)
304}
305
306/// Test/embedding entry point with an explicit managed root.
307pub fn run_suite_in_managed_root(
308    cfg: &QaConfig,
309    opts: &SuiteOptions,
310    managed_root: &Path,
311) -> Result<SuiteReport> {
312    run_suite_at(cfg, opts, Some(managed_root))
313}
314
315fn run_suite_at(
316    cfg: &QaConfig,
317    opts: &SuiteOptions,
318    managed_override: Option<&Path>,
319) -> Result<SuiteReport> {
320    let platform = platform()?;
321    let tier = opts
322        .tier
323        .clone()
324        .unwrap_or_else(|| cfg.default_tier.clone());
325    let scenarios = select(cfg, opts)?;
326    if scenarios.is_empty() {
327        return err(format!(
328            "no scenarios matched tier '{tier}'{}",
329            opts.only
330                .as_ref()
331                .map(|o| format!(" --only {o}"))
332                .unwrap_or_default()
333        ));
334    }
335    let run_kind = resolve_run_kind(opts.kind, &scenarios);
336    let managed_root = managed_override
337        .map(Path::to_path_buf)
338        .unwrap_or_else(|| crate::storage::managed_root(None));
339    let managed_app_root = crate::storage::prepare_app_root(&managed_root, &cfg.app)?;
340    let lock_opts = LockOptions {
341        timeout: Duration::from_secs(cfg.lock.timeout_s.unwrap_or(120)),
342        stale: Duration::from_secs(cfg.lock.stale_s.unwrap_or(60)),
343        ..Default::default()
344    };
345    let mut ownership = lock::acquire(
346        &managed_app_root.join("native.lock"),
347        &format!("{}:{tier}", cfg.app),
348        &lock_opts,
349    )?;
350    let run_id = crate::util::new_id();
351    // Earlier crashed runs: kill survivors by recorded pid + command line, then drop their dirs.
352    let swept = sweep_registries(&managed_app_root, &run_id);
353    let ws = workspace::create(&managed_root, Some(&run_id), &cfg.app)?;
354    if let Err(e) = ws.set_kind(run_kind) {
355        ownership.release();
356        return Err(e);
357    }
358    let tracker = Tracker::with_registry(ws.root.join("pids.json"));
359    let mut bundle = Bundle::new(&cfg.app, platform, &run_id)?;
360    let _mute = cfg.audio.system_mute.then(SystemMute::engage);
361
362    let needs_engine = scenarios
363        .iter()
364        .any(|s| s.target == "engine" && unmet_requirement(&s.requires, &cfg.app_root).is_none());
365    let needs_ui = scenarios.iter().any(|s| s.target == "ui");
366    // A `ui` scenario that seeds engine state before launch (`engine_seed`) needs the engine too.
367    let needs_engine = needs_engine
368        || scenarios.iter().any(|s| {
369            s.target == "ui"
370                && s.steps.iter().any(|st| st.kind == "engine_seed")
371                && unmet_requirement(&s.requires, &cfg.app_root).is_none()
372        });
373    let engine = engine_target(cfg, opts, &tracker, &tier)?;
374    let ui = if needs_ui {
375        ui_binary(cfg, opts, &tracker)?
376    } else {
377        None
378    };
379    if needs_engine && engine.is_none() {
380        ownership.release();
381        return err("selected scenarios need the engine but no binary is configured (use --engine, RIGHTKIT_QA_ENGINE, [engine].binary, or --build)");
382    }
383
384    let identity = match run_identity(
385        cfg,
386        &scenarios,
387        engine.as_ref().map(|e| e.binary.as_path()),
388        ui.as_deref(),
389        needs_engine,
390        false,
391    ) {
392        Ok(mut i) => {
393            if let Some(o) = i.as_object_mut() {
394                o.insert("runKind".into(), json!(run_kind.as_str()));
395            }
396            i
397        }
398        Err(e) => {
399            ownership.release();
400            return Err(e);
401        }
402    };
403    bundle.set_identity(identity)?;
404    println!(
405        "[qa] app={} platform={platform} tier={tier} kind={run_kind} scenarios={} run={run_id}",
406        cfg.app,
407        scenarios.len()
408    );
409    if let Some(e) = &engine {
410        println!("[qa] engine={}", e.binary.display());
411    }
412    if !swept.is_empty() {
413        println!("[qa] swept orphan pids from earlier runs: {swept:?}");
414    }
415
416    let mut outcomes = vec![];
417    for sc in &scenarios {
418        let started = Instant::now();
419        let name = sc.name.clone();
420        let unmet = platform_mismatch(&sc.requires)
421            .map(|r| (r, true))
422            .or_else(|| unmet_requirement(&sc.requires, &cfg.app_root).map(|r| (r, false)));
423        if let Some((reason, other_os)) = unmet {
424            if sc.optional || other_os {
425                println!(
426                    "[qa] SKIP  {name}: {reason} ({})",
427                    if sc.optional {
428                        "optional"
429                    } else {
430                        "other platform"
431                    }
432                );
433                bundle.add_check(
434                    &name,
435                    CheckStatus::Skipped,
436                    json!({"reason": reason, "optional": sc.optional, "platform": other_os}),
437                    &[],
438                )?;
439                outcomes.push(Outcome {
440                    name,
441                    file: sc.file.clone(),
442                    tier: sc.tier.clone(),
443                    status: CheckStatus::Skipped,
444                    reason: Some(reason),
445                    ms: 0,
446                    steps: vec![],
447                });
448            } else {
449                let reason = format!("mandatory scenario could not run: {reason} (mark it `optional = true` only if skipping is acceptable)");
450                println!("[qa] FAIL  {name}: {reason}");
451                bundle.add_check(
452                    &name,
453                    CheckStatus::Failed,
454                    json!({"error": reason, "mandatory": true}),
455                    &[],
456                )?;
457                outcomes.push(Outcome {
458                    name,
459                    file: sc.file.clone(),
460                    tier: sc.tier.clone(),
461                    status: CheckStatus::Failed,
462                    reason: Some(reason),
463                    ms: 0,
464                    steps: vec![],
465                });
466            }
467            continue;
468        }
469        if sc.target == "ui" && ui.is_none() {
470            let reason =
471                "no UI binary for this platform ([ui].binaries / --ui-binary / --build-ui)"
472                    .to_string();
473            println!("[qa] FAIL  {name}: {reason}");
474            bundle.add_check(&name, CheckStatus::Failed, json!({"error": reason}), &[])?;
475            outcomes.push(Outcome {
476                name,
477                file: sc.file.clone(),
478                tier: sc.tier.clone(),
479                status: CheckStatus::Failed,
480                reason: Some(reason),
481                ms: 0,
482                steps: vec![],
483            });
484            continue;
485        }
486        let kind = match scenario_kind(run_kind, sc.kind, &name) {
487            Ok(k) => k,
488            Err(e) => {
489                println!("[qa] FAIL  {name}: {e}");
490                bundle.add_check(&name, CheckStatus::Failed, json!({"error": e.0}), &[])?;
491                outcomes.push(Outcome {
492                    name,
493                    file: sc.file.clone(),
494                    tier: sc.tier.clone(),
495                    status: CheckStatus::Failed,
496                    reason: Some(e.0),
497                    ms: 0,
498                    steps: vec![],
499                });
500                continue;
501            }
502        };
503        let mut ctx = Ctx::new(cfg, engine.clone(), ui.clone(), &ws, &tracker, &name)?;
504        ctx.kind = kind;
505        ctx.handlers = opts.hooks.steps.clone();
506        ctx.kill_orphans = opts.kill_orphans || sc.kill_orphans;
507        let mut steps = vec![];
508        let mut failure: Option<String> = None;
509        let limit = Duration::from_secs(sc.timeout_s.unwrap_or(600));
510        let mut run_steps = true;
511        if let Some(setup) = &opts.hooks.setup {
512            if let Err(e) = setup(&mut ctx, sc) {
513                failure = Some(format!("scenario setup: {}", e.0));
514                run_steps = false;
515            }
516        }
517        for (i, step) in sc.steps.iter().enumerate() {
518            if !run_steps {
519                break;
520            }
521            let t = Instant::now();
522            if started.elapsed() > limit {
523                failure = Some(format!(
524                    "scenario exceeded its {}s budget before step {}",
525                    limit.as_secs(),
526                    i + 1
527                ));
528                break;
529            }
530            let label = step.label.clone().unwrap_or_else(|| step.kind.clone());
531            match run_step(&mut ctx, step) {
532                Ok(_) => steps.push(StepRecord {
533                    index: i + 1,
534                    kind: step.kind.clone(),
535                    name: label,
536                    ok: true,
537                    ms: t.elapsed().as_millis(),
538                    error: None,
539                }),
540                Err(e) => {
541                    steps.push(StepRecord {
542                        index: i + 1,
543                        kind: step.kind.clone(),
544                        name: label,
545                        ok: false,
546                        ms: t.elapsed().as_millis(),
547                        error: Some(e.0.clone()),
548                    });
549                    failure = Some(e.0);
550                    break;
551                }
552            }
553        }
554        // The failure capture is a screenshot: only a visual scenario takes one.
555        if failure.is_some() && ctx.kind == RunKind::Visual {
556            if let Some((c, _)) = &ctx.ui {
557                let p = ctx.log_dir.join("failure.png");
558                if c.screenshot_to(&p).is_ok() {
559                    ctx.artifacts.push((format!("{name}/failure.png"), p));
560                }
561            }
562        }
563        let mut problems = vec![];
564        if let Some(td) = &opts.hooks.teardown {
565            if let Err(e) = td(&mut ctx, sc) {
566                problems.push(format!("scenario teardown: {}", e.0));
567            }
568        }
569        problems.extend(ctx.teardown());
570        if failure.is_none() && !problems.is_empty() {
571            failure = Some(problems.join("; "));
572        }
573        // Hash every artifact the scenario produced, then bind the check to them.
574        let mut names = vec![];
575        let mut artifact_errors = vec![];
576        for (n, path) in &ctx.artifacts {
577            if names.contains(n) {
578                continue;
579            }
580            match ws
581                .preserve_artifact(path)
582                .and_then(|kept| bundle.add_artifact(n, &kept))
583            {
584                Ok(_) => names.push(n.clone()),
585                Err(e) => artifact_errors.push(e.0),
586            }
587        }
588        if !artifact_errors.is_empty() {
589            let msg = format!(
590                "required evidence could not be recorded: {}",
591                artifact_errors.join("; ")
592            );
593            failure = Some(match failure.take() {
594                Some(f) => format!("{f}; {msg}"),
595                None => msg,
596            });
597        }
598        let status = if failure.is_none() {
599            CheckStatus::Passed
600        } else {
601            CheckStatus::Failed
602        };
603        let details = json!({"file": sc.file.to_string_lossy(), "tier": sc.tier, "steps": steps.len(), "error": failure});
604        bundle.add_check(&name, status, details, &names)?;
605        println!(
606            "[qa] {}  {name} ({} ms){}",
607            if failure.is_none() { "PASS" } else { "FAIL" },
608            started.elapsed().as_millis(),
609            failure
610                .as_ref()
611                .map(|f| format!("\n       {f}"))
612                .unwrap_or_default()
613        );
614        outcomes.push(Outcome {
615            name,
616            file: sc.file.clone(),
617            tier: sc.tier.clone(),
618            status,
619            reason: failure,
620            ms: started.elapsed().as_millis(),
621            steps,
622        });
623    }
624
625    tracker.kill_all();
626    let failed = outcomes
627        .iter()
628        .filter(|o| o.status == CheckStatus::Failed)
629        .count();
630    let passed = outcomes
631        .iter()
632        .filter(|o| o.status == CheckStatus::Passed)
633        .count();
634    // Skips are only ever optional scenarios; a run whose scenarios were all skipped proves nothing.
635    bundle.finish(if failed == 0 && passed > 0 {
636        RunStatus::Passed
637    } else {
638        RunStatus::Failed
639    });
640    let evidence_path = ws.evidence_dir.join("evidence.json");
641    let results_path = ws.evidence_dir.join("results.json");
642    std::fs::write(
643        &results_path,
644        serde_json::to_vec_pretty(&json!({
645            "app": cfg.app, "platform": platform, "runId": run_id, "tier": tier, "kind": run_kind.as_str(), "finishedAt": now_iso(),
646            "passed": passed, "failed": failed, "skipped": outcomes.len() - passed - failed,
647            "scenarios": outcomes.iter().map(Outcome::to_value).collect::<Vec<_>>(),
648        }))?,
649    )?;
650    bundle.write(&evidence_path)?;
651    let _ = std::fs::write(
652        ws.root.parent().unwrap_or(&ws.root).join("latest.json"),
653        serde_json::to_vec_pretty(
654            &json!({"runId": run_id, "evidence": evidence_path.to_string_lossy(), "results": results_path.to_string_lossy()}),
655        )?,
656    );
657    // Record pass or fail in the run marker before it is completed: retention reads only this.
658    let _ = ws.set_outcome(if failed == 0 && passed > 0 {
659        RunOutcome::Passed
660    } else {
661        RunOutcome::Failed
662    });
663    ws.finish()?;
664    if !opts.keep_data && failed > 0 && storage::keep_failed_home_requested() {
665        println!(
666            "[qa] kept QA home of the failed run: {}",
667            storage::describe_kept_home(&ws.home)
668        );
669    } else if !opts.keep_data {
670        let report = workspace::dispose(&ws, true, 5, 200);
671        for (p, why) in report.retained {
672            println!("[qa] WARN could not remove {}: {why}", p.display());
673        }
674    }
675    println!(
676        "[qa] {passed} passed, {failed} failed, {} skipped; evidence {}",
677        outcomes.len() - passed - failed,
678        evidence_path.display()
679    );
680    ownership.release();
681    // Retention right after the job: failed runs and the newest pass per kind stay, the rest of
682    // the passing runs of this app go. It never fails the job.
683    let pruned = crate::retention::prune_runs_after_job(&managed_root, &cfg.app);
684    println!("[qa] run retention: {}", pruned.summary());
685    for (path, why) in pruned
686        .kept
687        .iter()
688        .filter(|(_, why)| why.starts_with("not removed"))
689    {
690        println!("[qa] WARN {}: {why}", path.display());
691    }
692    Ok(SuiteReport {
693        exit_code: if failed == 0 && passed > 0 { 0 } else { 1 },
694        run_id,
695        run_dir: ws.root.clone(),
696        evidence_path,
697        results_path,
698        outcomes,
699    })
700}
701
702fn file_sha(path: &std::path::Path) -> Result<String> {
703    let bytes = std::fs::read(path).map_err(|e| {
704        crate::util::Error(format!(
705            "cannot hash {} for the evidence identity: {e}",
706            path.display()
707        ))
708    })?;
709    Ok(crate::util::sha256_hex(&bytes))
710}
711
712fn git_revision(root: &std::path::Path) -> Value {
713    let git = |args: &[&str]| {
714        let mut git = std::process::Command::new("git");
715        git.arg("-C").arg(root).args(args);
716        #[cfg(windows)]
717        {
718            use std::os::windows::process::CommandExt;
719            git.creation_flags(0x0800_0000); // CREATE_NO_WINDOW: never flash a console from a GUI host
720        }
721        crate::process::output_bounded(&mut git, Duration::from_secs(30))
722            .ok()
723            .flatten()
724            .filter(|o| o.status.success())
725            .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
726    };
727    match git(&["rev-parse", "HEAD"]) {
728        Some(head) => {
729            json!({"head": head, "dirty": git(&["status", "--porcelain"]).map(|s| !s.is_empty())})
730        }
731        None => Value::Null,
732    }
733}
734
735/// What this run exercises: config, scenario files, engine/UI binaries and the source revision,
736/// all by digest, so a receipt cannot be reused for different bytes.
737fn run_identity(
738    cfg: &QaConfig,
739    scenarios: &[Scenario],
740    engine: Option<&std::path::Path>,
741    ui: Option<&std::path::Path>,
742    needs_engine: bool,
743    needs_ui: bool,
744) -> Result<Value> {
745    let mut scenario_ids = vec![];
746    for sc in scenarios {
747        scenario_ids.push(json!({"name": sc.name, "file": sc.file.to_string_lossy(), "sha256": file_sha(&sc.file)?}));
748    }
749    let binary = |p: Option<&std::path::Path>, what: &str, needed: bool| -> Result<Value> {
750        match p {
751            Some(p) => {
752                let target = if p.is_dir() { None } else { Some(p) };
753                match target {
754                    Some(t) => Ok(json!({"path": t.to_string_lossy(), "sha256": file_sha(t)?})),
755                    None => {
756                        // A `.app` bundle: bind to every executable inside Contents/MacOS.
757                        let mut exes = vec![];
758                        if let Ok(rd) = std::fs::read_dir(p.join("Contents").join("MacOS")) {
759                            for e in rd.flatten() {
760                                exes.push(json!({"path": e.path().to_string_lossy(), "sha256": file_sha(&e.path())?}));
761                            }
762                        }
763                        if exes.is_empty() {
764                            return err(format!(
765                                "{} has no executable to bind into the evidence identity",
766                                p.display()
767                            ));
768                        }
769                        Ok(json!({"path": p.to_string_lossy(), "executables": exes}))
770                    }
771                }
772            }
773            None if needed => err(format!(
774                "evidence identity needs the {what} binary but none resolved"
775            )),
776            None => Ok(Value::Null),
777        }
778    };
779    Ok(json!({
780        "config": {"path": cfg.config_path.to_string_lossy(), "sha256": file_sha(&cfg.config_path)?},
781        "scenarios": scenario_ids,
782        "engine": binary(engine, "engine", needs_engine)?,
783        "ui": binary(ui, "UI", needs_ui)?,
784        "source": git_revision(&cfg.app_root),
785    }))
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791
792    #[test]
793    fn only_matches_substring_exact_stem_and_comma_lists() {
794        let f = Path::new("/x/scenarios/login-flow.scenario.toml");
795        assert!(only_matches("login-flow", "Some Name", f));
796        assert!(only_matches("login", "Some Name", f));
797        assert!(only_matches("Some", "Some Name", f));
798        assert!(only_matches("nope, login-flow", "Some Name", f));
799        assert!(only_matches("nope,Some Name", "Some Name", f));
800        assert!(!only_matches("nope,other", "Some Name", f));
801        assert!(!only_matches(" , ", "Some Name", f));
802        // Stem equality, even when the stem is not a substring of anything else matched.
803        let g = Path::new("/x/a/b.scenario.toml");
804        assert!(only_matches("b", "zzz", g));
805    }
806
807    #[test]
808    fn an_explicit_ui_binary_skips_the_resolver_checks() {
809        // Not named after the app, not a file, not a QA build: the caller named it, so it stands.
810        let p = std::env::temp_dir()
811            .join("rkqa-explicit")
812            .join("release")
813            .join("anything.bin");
814        assert_eq!(explicit_ui_binary(&p).unwrap(), p);
815        assert!(
816            crate::binary::validate(&p, "app").is_err(),
817            "the resolver path would have refused it"
818        );
819    }
820
821    #[cfg(any(target_os = "macos", windows))]
822    #[test]
823    fn an_explicit_ui_binary_in_an_installed_location_is_still_refused() {
824        if crate::installed::installed_mode() {
825            return;
826        }
827        #[cfg(target_os = "macos")]
828        let p = PathBuf::from("/Applications/CutRight.app/Contents/MacOS/CutRight");
829        #[cfg(windows)]
830        let Some(p) = std::env::var_os("ProgramFiles")
831            .map(|d| PathBuf::from(d).join("CutRight").join("cutright.exe"))
832        else {
833            return;
834        };
835        let e = explicit_ui_binary(&p).unwrap_err();
836        assert!(e.0.contains("RIGHT_RELEASE_QA_STAGE=installed"), "{e}");
837    }
838}