1use 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
21pub type ScenarioHook = Arc<dyn for<'a> Fn(&mut Ctx<'a>, &Scenario) -> Result<()> + Send + Sync>;
23
24#[derive(Clone, Default)]
26pub struct SuiteHooks {
27 pub steps: StepHandlers,
29 pub setup: Option<ScenarioHook>,
33 pub teardown: Option<ScenarioHook>,
37}
38
39impl SuiteHooks {
40 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 pub ui_binary: Option<PathBuf>,
86 pub build_engine: bool,
87 pub build_ui: bool,
88 pub keep_data: bool,
89 pub kind: Option<RunKind>,
93 pub files: Vec<PathBuf>,
95 pub hooks: SuiteHooks,
97 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
134pub 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
232pub 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
241fn 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 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 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 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 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
306pub 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 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 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 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 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 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 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 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); }
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
735fn 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 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 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 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}