Skip to main content

rightkit_qa/
harness.rs

1//! Scenario wrapper for plain Rust integration tests (`#[test] fn x() { h.scenario(...) }`).
2//!
3//! What the declarative TOML runner gives a scenario, for Rust tests:
4//! * **tier gating**: `fast` always runs; `full`, `paid` (or any name) run when selected by a
5//!   cargo feature ([`Harness::with_feature_tiers`]) or the settings key
6//!   ([`Harness::with_tier_key`], default `RIGHTKIT_QA_TIER`, a comma list or `all`);
7//! * **requires-skip**: `env:NAME` (answered by the [`Settings`] file, so it survives
8//!   `rightkit cargo`), `file:path`, `cmd:program`, `platform:darwin|windows`; plus a runtime
9//!   [`Scenario::skip`] for prerequisites only discoverable inside the test;
10//! * **skip accounting**: libtest reports a skipped test as `ok`, so every outcome is printed
11//!   (`[qa] PASS|SKIP|FAIL name`) and appended to `<evidence root>/summary.jsonl`;
12//! * **per-scenario evidence**: `<evidence root>/<scenario-slug>/` holds the scenario's files and
13//!   a hashed `evidence.json` [`Bundle`] (artifacts recorded with [`Scenario::keep`]);
14//! * **orphan check**: every process started through [`Scenario::tracker`] must be gone when the
15//!   scenario returns, or the scenario fails (survivors are killed first);
16//! * optional **run lock** shared by every test binary ([`Harness::with_run_lock`]).
17//!
18//! ```no_run
19//! use rightkit_qa::harness::Harness;
20//! fn h() -> Harness {
21//!     Harness::new("myapp", env!("CARGO_MANIFEST_DIR"))
22//!         .unwrap()
23//!         .with_feature_tiers(&[("full", cfg!(feature = "full")), ("paid", cfg!(feature = "paid"))])
24//! }
25//! #[test]
26//! fn export_is_loud_enough() {
27//!     h().scenario("export loudness", "full", &["cmd:ffmpeg"], |sc| {
28//!         sc.note("measuring");
29//!     });
30//! }
31//! ```
32use crate::evidence::{Bundle, CheckStatus, RunStatus};
33use crate::lock::{acquire, LockOptions};
34use crate::process::Tracker;
35use crate::scenario::{tier_selected, unmet_requirement_with};
36use crate::settings::Settings;
37use crate::util::{new_id, sha256_hex, Result};
38use serde_json::{json, Value};
39use std::fs;
40use std::io::Write;
41use std::path::{Path, PathBuf};
42use std::sync::Arc;
43use std::sync::Mutex;
44use std::time::{Duration, Instant};
45
46/// Settings key read for the tier override unless [`Harness::with_tier_key`] changes it.
47pub const DEFAULT_TIER_KEY: &str = "RIGHTKIT_QA_TIER";
48/// Settings key read for the evidence root (relative values resolve against the app root).
49pub const EVIDENCE_KEY: &str = "RIGHTKIT_QA_EVIDENCE";
50
51/// How a scenario ended without failing (a failure panics, so libtest marks the test failed).
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum Outcome {
54    Passed,
55    Skipped(String),
56}
57
58impl Outcome {
59    pub fn is_skipped(&self) -> bool {
60        matches!(self, Outcome::Skipped(_))
61    }
62}
63
64/// Shared configuration for every scenario of a test crate. Cheap to rebuild per test.
65#[derive(Debug, Clone)]
66pub struct Harness {
67    app: String,
68    root: PathBuf,
69    settings: Settings,
70    tier_key: String,
71    feature_tiers: Vec<String>,
72    explicit_tiers: Option<String>,
73    evidence_root: PathBuf,
74    lock: Option<(PathBuf, Duration)>,
75    identity: Value,
76    prefix: String,
77    run: Arc<Mutex<Option<crate::workspace::QaWorkspace>>>,
78    run_root: PathBuf,
79    scratch_root: PathBuf,
80    allow_external_evidence: bool,
81}
82
83impl Drop for Harness {
84    fn drop(&mut self) {
85        if Arc::strong_count(&self.run) != 1 {
86            return;
87        }
88        if let Ok(mut run) = self.run.lock() {
89            if let Some(ws) = run.take() {
90                let _ = crate::workspace::dispose(&ws, true, 3, 10);
91            }
92        }
93    }
94}
95
96impl Harness {
97    /// `app` names the evidence; `root` (usually the repository root) resolves `file:`
98    /// requirements and is where [`Settings::discover`] searches.
99    pub fn new(app: &str, root: impl Into<PathBuf>) -> Result<Self> {
100        let root = root.into();
101        let settings = Settings::discover(&root)?;
102        Ok(Self::with_parts(app, root, settings))
103    }
104
105    /// Test and embedding constructor with settings discovery and an explicit managed root.
106    pub fn new_in_managed_root(
107        app: &str,
108        root: impl Into<PathBuf>,
109        managed_root: impl Into<PathBuf>,
110    ) -> Result<Self> {
111        let root = root.into();
112        let settings = Settings::discover(&root)?;
113        Ok(Self::with_parts_in_managed_root(
114            app,
115            root,
116            settings,
117            managed_root,
118        ))
119    }
120
121    /// Like [`Harness::new`] with settings already loaded.
122    pub fn with_parts(app: &str, root: PathBuf, settings: Settings) -> Self {
123        Self::with_parts_at(app, root, settings, None)
124    }
125
126    /// Test and embedding constructor with an explicit managed root. This keeps
127    /// fixture runs off machine storage while preserving production defaults in
128    /// [`Harness::with_parts`].
129    pub fn with_parts_in_managed_root(
130        app: &str,
131        root: PathBuf,
132        settings: Settings,
133        managed_root: impl Into<PathBuf>,
134    ) -> Self {
135        let managed_root = managed_root.into();
136        Self::with_parts_at(app, root, settings, Some(managed_root.as_path()))
137    }
138
139    fn with_parts_at(
140        app: &str,
141        root: PathBuf,
142        settings: Settings,
143        managed_root: Option<&Path>,
144    ) -> Self {
145        if let Some(p) = locate_ffprobe(app, &settings) {
146            crate::media::set_ffprobe_bin(p);
147        }
148        let run = Some(
149            crate::workspace::create_impl_for_harness(managed_root, app)
150                .unwrap_or_else(|e| panic!("cannot create managed QA run: {e}")),
151        );
152        let evidence_root = run.as_ref().expect("managed run").evidence_dir.clone();
153        let run_root = run.as_ref().expect("managed run").root.clone();
154        Self {
155            app: app.to_string(),
156            root,
157            settings,
158            tier_key: DEFAULT_TIER_KEY.into(),
159            feature_tiers: vec![],
160            explicit_tiers: None,
161            evidence_root,
162            lock: None,
163            identity: Value::Null,
164            prefix: "[qa]".into(),
165            scratch_root: run.as_ref().expect("managed run").home.join("scratch"),
166            run: Arc::new(Mutex::new(run)),
167            run_root,
168            allow_external_evidence: managed_root.is_some(),
169        }
170    }
171
172    /// Tiers enabled at compile time: `&[("full", cfg!(feature = "full")), ...]`.
173    pub fn with_feature_tiers(mut self, tiers: &[(&str, bool)]) -> Self {
174        self.feature_tiers = tiers
175            .iter()
176            .filter(|(_, on)| *on)
177            .map(|(t, _)| t.to_string())
178            .collect();
179        self
180    }
181
182    /// Settings key whose value (comma list or `all`) replaces the computed tier selection.
183    pub fn with_tier_key(mut self, key: &str) -> Self {
184        self.tier_key = key.into();
185        self
186    }
187
188    /// Fixed selection, ignoring features and settings.
189    pub fn with_tiers(mut self, list: &str) -> Self {
190        self.explicit_tiers = Some(list.into());
191        self
192    }
193
194    pub fn with_evidence_root(mut self, dir: impl Into<PathBuf>) -> Self {
195        let dir = dir.into();
196        if !self.allow_external_evidence && !dir.starts_with(&self.run_root) {
197            panic!(
198                "QA evidence root must be inside managed run root: {}",
199                self.run_root.display()
200            );
201        }
202        self.evidence_root = dir;
203        self
204    }
205
206    /// Serialize scenarios across threads and test binaries through a [`crate::lock`] directory.
207    pub fn with_run_lock(mut self, lock_dir: impl Into<PathBuf>, timeout: Duration) -> Self {
208        self.lock = Some((lock_dir.into(), timeout));
209        self
210    }
211
212    /// Extra identity fields bound into every evidence bundle (binary digests, revisions).
213    /// `config.sha256` defaults to the settings file digest.
214    pub fn with_identity(mut self, identity: Value) -> Self {
215        self.identity = identity;
216        self
217    }
218
219    /// Hash a file (the binary under test) into the identity as `binaries.<label>`.
220    pub fn with_identity_file(mut self, label: &str, path: &Path) -> Result<Self> {
221        let digest = sha256_hex(&fs::read(path)?);
222        if !self.identity.is_object() {
223            self.identity = json!({});
224        }
225        self.identity["binaries"][label] = json!({"path": path, "sha256": digest});
226        Ok(self)
227    }
228
229    /// Log line prefix (default `[qa]`; GenRight greps `[e2e]`).
230    pub fn with_log_prefix(mut self, prefix: &str) -> Self {
231        self.prefix = prefix.into();
232        self
233    }
234
235    pub fn settings(&self) -> &Settings {
236        &self.settings
237    }
238    pub fn root(&self) -> &Path {
239        &self.root
240    }
241    pub fn evidence_root(&self) -> &Path {
242        &self.evidence_root
243    }
244
245    /// The active tier list: explicit, else the settings key, else `fast` plus feature tiers.
246    pub fn selected_tiers(&self) -> String {
247        if let Some(t) = &self.explicit_tiers {
248            return t.clone();
249        }
250        if let Some(t) = self.settings.get(&self.tier_key) {
251            return t;
252        }
253        let mut t = vec!["fast".to_string()];
254        t.extend(self.feature_tiers.iter().cloned());
255        t.join(",")
256    }
257
258    /// First unmet requirement, with `env:` answered by the settings (environment first).
259    pub fn unmet(&self, requires: &[&str]) -> Option<String> {
260        let reqs: Vec<String> = requires.iter().map(|s| s.to_string()).collect();
261        unmet_requirement_with(&reqs, &self.root, &|k| self.settings.get(k))
262    }
263
264    /// Run one scenario. Returns [`Outcome::Passed`] or [`Outcome::Skipped`]; a failing body,
265    /// a leaked process, or a lock timeout panics after its evidence is written.
266    pub fn scenario(
267        &self,
268        name: &str,
269        tier: &str,
270        requires: &[&str],
271        f: impl FnOnce(&Scenario),
272    ) -> Outcome {
273        let started = Instant::now();
274        let dir = self.evidence_root.join(slug(name));
275        let selected = self.selected_tiers();
276        let gate = if !tier_selected(tier, &selected) {
277            Some(format!("tier {tier} not selected (selected: {selected})"))
278        } else {
279            self.unmet(requires)
280        };
281        if let Some(reason) = gate {
282            self.finish(
283                name,
284                tier,
285                &dir,
286                CheckStatus::Skipped,
287                json!({"reason": reason}),
288                &[],
289                started,
290            );
291            return Outcome::Skipped(reason);
292        }
293        if let Err(e) = fs::create_dir_all(&dir) {
294            panic!(
295                "{} cannot create evidence dir {}: {e}",
296                self.prefix,
297                dir.display()
298            );
299        }
300        let mut lock = match &self.lock {
301            Some((lock_dir, timeout)) => {
302                if let Some(p) = lock_dir.parent() {
303                    let _ = fs::create_dir_all(p);
304                }
305                match acquire(
306                    lock_dir,
307                    &format!("{}:{name}", self.app),
308                    &LockOptions {
309                        timeout: *timeout,
310                        ..Default::default()
311                    },
312                ) {
313                    Ok(l) => Some(l),
314                    Err(e) => {
315                        let msg = format!("run lock: {e}");
316                        self.finish(
317                            name,
318                            tier,
319                            &dir,
320                            CheckStatus::Failed,
321                            json!({"error": msg}),
322                            &[],
323                            started,
324                        );
325                        panic!("{msg}");
326                    }
327                }
328            }
329            None => None,
330        };
331        let sc = Scenario {
332            name: name.to_string(),
333            tier: tier.to_string(),
334            dir: dir.clone(),
335            tracker: Tracker::new(),
336            settings: self.settings.clone(),
337            artifacts: Mutex::new(vec![]),
338            notes: Mutex::new(vec![]),
339            skipped: Mutex::new(None),
340            scratches: Mutex::new(vec![]),
341            scratch_root: self.scratch_root.clone(),
342        };
343        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(&sc)));
344        let orphans = match &result {
345            Ok(()) => sc.tracker.assert_no_orphans(),
346            Err(_) => {
347                sc.tracker.kill_all();
348                Ok(())
349            }
350        };
351        if let Some(l) = lock.as_mut() {
352            l.release();
353        }
354        let arts = sc.artifacts.lock().unwrap().clone();
355        let notes = sc.notes.lock().unwrap().clone();
356        let skipped = sc.skipped.lock().unwrap().clone();
357        let scratches = sc.scratches.lock().unwrap().clone();
358        // Scratch dirs are removed once the scenario passes or skips (kept on failure for
359        // debugging, when they hold a kept artifact, or with the `keep_scratch` setting): they
360        // live in the OS temp dir, and model-sized fixtures leaked hundreds of GB otherwise.
361        let clean_scratch = || {
362            if self
363                .settings
364                .get_bool("keep_scratch")
365                .ok()
366                .flatten()
367                .unwrap_or(false)
368            {
369                return;
370            }
371            for d in &scratches {
372                if arts.iter().any(|(_, p)| p.starts_with(d)) {
373                    continue;
374                }
375                let _ = fs::remove_dir_all(d);
376            }
377        };
378        match (result, orphans) {
379            (Ok(()), Ok(())) => match skipped {
380                Some(reason) => {
381                    self.finish(
382                        name,
383                        tier,
384                        &dir,
385                        CheckStatus::Skipped,
386                        json!({"reason": reason, "notes": notes}),
387                        &arts,
388                        started,
389                    );
390                    clean_scratch();
391                    Outcome::Skipped(reason)
392                }
393                None => {
394                    self.finish(
395                        name,
396                        tier,
397                        &dir,
398                        CheckStatus::Passed,
399                        json!({"notes": notes}),
400                        &arts,
401                        started,
402                    );
403                    clean_scratch();
404                    Outcome::Passed
405                }
406            },
407            (Ok(()), Err(e)) => {
408                self.finish(
409                    name,
410                    tier,
411                    &dir,
412                    CheckStatus::Failed,
413                    json!({"error": e.0, "notes": notes}),
414                    &arts,
415                    started,
416                );
417                panic!("{e}");
418            }
419            (Err(p), _) => {
420                let msg = p
421                    .downcast_ref::<String>()
422                    .cloned()
423                    .or_else(|| p.downcast_ref::<&str>().map(|s| s.to_string()))
424                    .unwrap_or_else(|| "panic".into());
425                self.finish(
426                    name,
427                    tier,
428                    &dir,
429                    CheckStatus::Failed,
430                    json!({"error": msg, "notes": notes}),
431                    &arts,
432                    started,
433                );
434                std::panic::resume_unwind(p);
435            }
436        }
437    }
438
439    #[allow(clippy::too_many_arguments)]
440    fn finish(
441        &self,
442        name: &str,
443        tier: &str,
444        dir: &Path,
445        status: CheckStatus,
446        mut details: Value,
447        arts: &[(String, PathBuf)],
448        started: Instant,
449    ) {
450        let ms = started.elapsed().as_millis() as u64;
451        let (word, reason) = match status {
452            CheckStatus::Passed => ("PASS", None),
453            CheckStatus::Skipped => ("SKIP", details["reason"].as_str().map(String::from)),
454            CheckStatus::Failed => ("FAIL", details["error"].as_str().map(String::from)),
455        };
456        match &reason {
457            Some(r) => println!("{} {word} {name}: {r}", self.prefix),
458            None => println!("{} {word} {name} ({ms} ms)", self.prefix),
459        }
460        details["ms"] = json!(ms);
461        details["tier"] = json!(tier);
462        let evidence = dir.join("evidence.json");
463        let written = self.write_bundle(name, status, details, arts, &evidence);
464        if let Err(e) = &written {
465            println!("{} evidence for {name} not written: {e}", self.prefix);
466        }
467        let line = json!({
468            "name": name, "tier": tier, "status": status, "reason": reason, "ms": ms,
469            "evidence": written.ok().map(|_| evidence),
470        });
471        let _ = fs::create_dir_all(&self.evidence_root);
472        if let Ok(mut f) = fs::OpenOptions::new()
473            .create(true)
474            .append(true)
475            .open(self.evidence_root.join("summary.jsonl"))
476        {
477            let _ = f.write_all(format!("{line}\n").as_bytes());
478        }
479    }
480
481    fn write_bundle(
482        &self,
483        name: &str,
484        status: CheckStatus,
485        details: Value,
486        arts: &[(String, PathBuf)],
487        out: &Path,
488    ) -> Result<()> {
489        let platform = crate::util::platform().unwrap_or(std::env::consts::OS);
490        let mut b = Bundle::new(
491            &self.app,
492            platform,
493            &format!("{}-{}", std::process::id(), new_id()),
494        )?;
495        let mut names = vec![];
496        let mut problems = vec![];
497        for (n, p) in arts {
498            match b.add_artifact(n, p) {
499                Ok(_) => names.push(n.clone()),
500                Err(e) => problems.push(e.0),
501            }
502        }
503        let mut identity = if self.identity.is_object() {
504            self.identity.clone()
505        } else {
506            json!({})
507        };
508        if identity.get("config").is_none() {
509            let digest = self
510                .settings
511                .source()
512                .and_then(|p| fs::read(p).ok())
513                .map(|b| sha256_hex(&b))
514                .unwrap_or_else(|| sha256_hex(b""));
515            identity["config"] = json!({"sha256": digest, "settings": self.settings.source()});
516        }
517        identity["scenarios"] = json!([name]);
518        identity["tiers"] = json!(self.selected_tiers());
519        b.set_identity(identity)?;
520        let mut details = details;
521        if !problems.is_empty() {
522            details["artifact_errors"] = json!(problems);
523        }
524        // An artifact that could not be hashed downgrades a pass: the receipt would be a claim.
525        let status = if status == CheckStatus::Passed && !problems.is_empty() {
526            CheckStatus::Failed
527        } else {
528            status
529        };
530        b.add_check(name, status, details, &names)?;
531        b.finish(if status == CheckStatus::Failed {
532            RunStatus::Failed
533        } else {
534            RunStatus::Passed
535        });
536        b.write(out)
537    }
538}
539
540/// The handle a scenario body receives.
541pub struct Scenario {
542    name: String,
543    tier: String,
544    dir: PathBuf,
545    tracker: Tracker,
546    settings: Settings,
547    artifacts: Mutex<Vec<(String, PathBuf)>>,
548    notes: Mutex<Vec<String>>,
549    skipped: Mutex<Option<String>>,
550    scratches: Mutex<Vec<PathBuf>>,
551    scratch_root: PathBuf,
552}
553
554impl Scenario {
555    pub fn name(&self) -> &str {
556        &self.name
557    }
558    pub fn tier(&self) -> &str {
559        &self.tier
560    }
561    /// This scenario's evidence directory (exists; files written here sit beside `evidence.json`).
562    pub fn dir(&self) -> &Path {
563        &self.dir
564    }
565    /// Register every process the scenario starts here (`run_owned`, engine targets, sidecars,
566    /// or `tracker().register(pid, label)` for anything spawned by hand).
567    pub fn tracker(&self) -> &Tracker {
568        &self.tracker
569    }
570    pub fn settings(&self) -> &Settings {
571        &self.settings
572    }
573    /// Mark the scenario skipped from inside (a prerequisite only discoverable at run time);
574    /// return from the body afterwards.
575    pub fn skip(&self, reason: &str) {
576        *self.skipped.lock().unwrap() = Some(reason.to_string());
577    }
578    /// Record a produced file; it is hashed into the evidence bundle when the scenario ends.
579    pub fn keep(&self, name: &str, path: &Path) {
580        self.artifacts
581            .lock()
582            .unwrap()
583            .push((name.to_string(), path.to_path_buf()));
584    }
585    pub fn note(&self, msg: impl Into<String>) {
586        self.notes.lock().unwrap().push(msg.into());
587    }
588    /// A fresh scratch directory inside this run's managed root. It is
589    /// removed when the scenario passes or skips; kept on failure for debugging.
590    pub fn scratch(&self, label: &str) -> PathBuf {
591        let d = self
592            .scratch_root
593            .join(format!("rkqa-{}-{}", slug(label), &new_id()[..8]));
594        let _ = fs::create_dir_all(&d);
595        let d = d.canonicalize().unwrap_or(d);
596        self.scratches.lock().unwrap().push(d.clone());
597        d
598    }
599}
600
601// Resolve explicit Settings pins before PATH discovery. Generic key wins, followed
602// by this app's key and GenRight's historical key for existing settings files.
603fn locate_ffprobe(app: &str, settings: &Settings) -> Option<PathBuf> {
604    let app_key: String = app
605        .to_uppercase()
606        .chars()
607        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
608        .collect();
609    let app_key = format!("{app_key}_FFPROBE");
610    let key = ["RIGHTKIT_QA_FFPROBE", app_key.as_str(), "GENRIGHT_FFPROBE"]
611        .into_iter()
612        .find(|key| settings.get(key).is_some())
613        .unwrap_or("RIGHTKIT_QA_FFPROBE");
614    crate::media::locate_tool("ffprobe", settings, key)
615}
616
617/// Filesystem-safe scenario directory name (bounded length, hash suffix keeps it unique).
618pub fn slug(name: &str) -> String {
619    let base: String = name
620        .chars()
621        .map(|c| {
622            if c.is_ascii_alphanumeric() {
623                c.to_ascii_lowercase()
624            } else {
625                '-'
626            }
627        })
628        .collect::<String>()
629        .split('-')
630        .filter(|s| !s.is_empty())
631        .collect::<Vec<_>>()
632        .join("-");
633    let short: String = base.chars().take(60).collect();
634    format!("{short}-{}", &sha256_hex(name.as_bytes())[..8])
635}
636
637/// Read back `summary.jsonl` (one JSON object per finished scenario).
638pub fn read_summary(evidence_root: &Path) -> Result<Vec<Value>> {
639    let text = fs::read_to_string(evidence_root.join("summary.jsonl"))?;
640    text.lines()
641        .filter(|l| !l.trim().is_empty())
642        .map(|l| Ok(serde_json::from_str(l)?))
643        .collect()
644}
645
646#[cfg(test)]
647mod tool_settings_tests {
648    use super::*;
649
650    #[test]
651    fn ffprobe_settings_accept_app_and_legacy_keys_before_path_discovery() {
652        let root = std::env::temp_dir().join(format!("rkqa-ffprobe-{}", new_id()));
653        fs::create_dir_all(&root).unwrap();
654        let generic = root.join("generic");
655        let app = root.join("app");
656        let legacy = root.join("legacy");
657        for p in [&generic, &app, &legacy] {
658            fs::write(p, "fixture tool").unwrap();
659        }
660        let settings_file = root.join("settings.env");
661        fs::write(
662            &settings_file,
663            format!("GENRIGHT_FFPROBE={}\n", legacy.display()),
664        )
665        .unwrap();
666        let mut settings = Settings::load(&settings_file)
667            .unwrap()
668            .with_env_override(false);
669        assert_eq!(locate_ffprobe("genright", &settings), Some(legacy.clone()));
670        assert_eq!(locate_ffprobe("scraperight", &settings), Some(legacy));
671        settings.set("SCRAPE_RIGHT_FFPROBE", app.to_string_lossy());
672        assert_eq!(locate_ffprobe("scrape-right", &settings), Some(app));
673        settings.set("RIGHTKIT_QA_FFPROBE", generic.to_string_lossy());
674        assert_eq!(locate_ffprobe("scrape-right", &settings), Some(generic));
675        settings.set(
676            "RIGHTKIT_QA_FFPROBE",
677            root.join("missing").to_string_lossy(),
678        );
679        assert_eq!(locate_ffprobe("scrape-right", &settings), None);
680        fs::remove_dir_all(root).unwrap();
681    }
682}