Skip to main content

rightkit_qa/
workspace.rs

1//! An isolated, reliably removable home for one QA run: own data dir (so the app
2//! takes its own single-instance lock), own evidence dir, retrying disposal that
3//! reports what it could not remove, and a sweep for crashed runs.
4use crate::storage;
5use crate::util::{new_id, require_text, Result};
6use std::collections::BTreeMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11#[derive(Debug, Clone)]
12pub struct QaWorkspace {
13    pub run_id: String,
14    /// Managed run root: `<managed>/runs/<app>/<run-id>`.
15    pub root: PathBuf,
16    /// Owned app home on internal system temp storage.
17    pub home: PathBuf,
18    pub data_dir: PathBuf,
19    pub evidence_dir: PathBuf,
20    pub capture_dir: PathBuf,
21    /// Environment that points an app at this workspace (profile isolation).
22    pub env: BTreeMap<String, String>,
23}
24
25#[derive(Debug, Default, Clone)]
26pub struct DisposeReport {
27    pub removed: Vec<PathBuf>,
28    pub retained: Vec<(PathBuf, String)>,
29}
30
31/// What happens before a run is created. `Full` sweeps dead homes and enforces the QA root limits
32/// (the product cap and the root ceiling). `SweepOnly` sweeps and leaves the limit check to the
33/// caller, which runs it later through [`storage::enforce_root_limits`].
34#[derive(Clone, Copy)]
35enum Preflight {
36    Full(storage::RootLimits),
37    SweepOnly,
38}
39
40/// Create a workspace. The QA root limits come from the environment (`RIGHTKIT_QA_ROOT_CAP_BYTES`,
41/// `RIGHTKIT_QA_ROOT_CEILING_BYTES`), else the defaults; see [`create_with_limits`] to set them in
42/// code.
43pub fn create(cache_root: &Path, run_id: Option<&str>, app_key: &str) -> Result<QaWorkspace> {
44    create_with_limits(cache_root, run_id, app_key, storage::RootLimits::default())
45}
46
47/// [`create`] with the QA root limits set in code. A limit set in `limits` wins over its
48/// environment variable (`0` disables it); a limit left `None` falls back to the environment, then
49/// the default. This is for a test process that cannot receive the environment variable, such as
50/// one run under `rightkit cargo`.
51pub fn create_with_limits(
52    cache_root: &Path,
53    run_id: Option<&str>,
54    app_key: &str,
55    limits: storage::RootLimits,
56) -> Result<QaWorkspace> {
57    create_impl(Some(cache_root), run_id, app_key, Preflight::Full(limits))
58}
59
60/// Create under RIGHTKIT_MANAGED_ROOT (or its platform default).
61pub fn create_managed(run_id: Option<&str>, app_key: &str) -> Result<QaWorkspace> {
62    create_impl(
63        None,
64        run_id,
65        app_key,
66        Preflight::Full(storage::RootLimits::default()),
67    )
68}
69
70/// The harness sweeps at construction and checks its limits at its first scenario, so limits set
71/// with `Harness::with_root_cap_bytes` / `with_root_ceiling_bytes` after construction apply.
72pub(crate) fn create_impl_for_harness(
73    managed_root: Option<&Path>,
74    app_key: &str,
75) -> Result<QaWorkspace> {
76    create_impl(managed_root, None, app_key, Preflight::SweepOnly)
77}
78
79fn create_impl(
80    cache_root: Option<&Path>,
81    run_id: Option<&str>,
82    app_key: &str,
83    preflight: Preflight,
84) -> Result<QaWorkspace> {
85    let app_key = require_text(app_key, "app")?;
86    // Sweep dead homes and, unless the caller checks later, refuse to start over a QA root limit,
87    // before a run exists.
88    match preflight {
89        Preflight::Full(limits) => storage::preflight_home_root(&app_key, limits)?,
90        Preflight::SweepOnly => storage::sweep_home_root(),
91    }
92    let mut managed =
93        storage::create(cache_root, &app_key, run_id, Duration::from_secs(24 * 3600))?;
94    // Storage works on canonical (on Windows verbatim, `\\?\C:\...`) paths and keeps them in the
95    // run marker. What this workspace hands to apps and tests is the ordinary drive path: some
96    // programs reject or mis-handle the verbatim form (Orthic-Labs/rightkit#185). Both name the
97    // same directory; every storage check canonicalises before it compares.
98    let home = crate::util::plain_path(&storage::create_app_root(&mut managed)?);
99    let run_id = managed.marker.run_id.clone();
100    let root = crate::util::plain_path(&managed.root);
101    // Generic managed storage creates this directory; QA profiles belong only in home.
102    fs::remove_dir(root.join("data"))?;
103    let data_dir = home.join("data");
104    let evidence_dir = root.join("evidence");
105    let capture_dir = root.join("captures");
106    let tmp_dir = home.join("tmp");
107    let cache_dir = home.join("cache");
108    fs::create_dir_all(&data_dir)?;
109    fs::create_dir_all(&evidence_dir)?;
110    fs::create_dir_all(&capture_dir)?;
111    fs::create_dir_all(&tmp_dir)?;
112    fs::create_dir_all(&cache_dir)?;
113    fs::create_dir_all(home.join("evidence"))?;
114    fs::create_dir_all(home.join("captures"))?;
115    create_profile_dirs(&home)?;
116    let mut env = BTreeMap::new();
117    env.insert("RIGHTKIT_QA_NATIVE".into(), "1".into());
118    env.insert("RIGHTKIT_QA_RUN_ID".into(), run_id.clone());
119    env.insert("RIGHTKIT_RUN_ROOT".into(), home.to_string_lossy().into());
120    env.insert(
121        "RIGHTKIT_SUITE_ROOT".into(),
122        home.join("suite").to_string_lossy().into(),
123    );
124    env.insert("HOME".into(), home.to_string_lossy().into());
125    env.insert("USERPROFILE".into(), home.to_string_lossy().into());
126    env.insert("APPDATA".into(), data_dir.to_string_lossy().into());
127    env.insert("LOCALAPPDATA".into(), data_dir.to_string_lossy().into());
128    env.insert("XDG_CACHE_HOME".into(), cache_dir.to_string_lossy().into());
129    env.insert(
130        "RIGHTKIT_QA_DATA_DIR".into(),
131        data_dir.to_string_lossy().into(),
132    );
133    env.insert(
134        "RIGHTKIT_QA_EVIDENCE_DIR".into(),
135        home.join("evidence").to_string_lossy().into(),
136    );
137    env.insert(
138        "RIGHTKIT_QA_CAPTURE_DIR".into(),
139        home.join("captures").to_string_lossy().into(),
140    );
141    env.insert("TMPDIR".into(), tmp_dir.to_string_lossy().into());
142    env.insert("TMP".into(), tmp_dir.to_string_lossy().into());
143    env.insert("TEMP".into(), tmp_dir.to_string_lossy().into());
144    env.insert("RIGHTKIT_QA_MUTE".into(), "1".into());
145    env.insert("XDG_DATA_HOME".into(), data_dir.to_string_lossy().into());
146    env.insert("XDG_CONFIG_HOME".into(), data_dir.to_string_lossy().into());
147    env.insert(
148        "WEBVIEW2_USER_DATA_FOLDER".into(),
149        data_dir.join("webview2").to_string_lossy().into(),
150    );
151    let key: String = app_key
152        .to_uppercase()
153        .chars()
154        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
155        .collect();
156    env.insert(format!("{key}_DATA_DIR"), data_dir.to_string_lossy().into());
157    // Every directory an exported variable points at must exist: apps and
158    // frameworks treat a missing one as "unknown path".
159    for value in env.values() {
160        let path = Path::new(value);
161        if path.starts_with(&home) {
162            fs::create_dir_all(path)?;
163        }
164    }
165    Ok(QaWorkspace {
166        run_id,
167        root,
168        home,
169        data_dir,
170        evidence_dir,
171        capture_dir,
172        env,
173    })
174}
175
176/// Create the per-user directories that platform known-folder lookups resolve
177/// under the isolated profile. On Windows `SHGetKnownFolderPath` expands the
178/// registry's `%USERPROFILE%\AppData\Local` against the overridden
179/// USERPROFILE and fails (Tauri "unknown path", `dirs::cache_dir` None) when the
180/// result is absent, even though LOCALAPPDATA itself points elsewhere.
181fn create_profile_dirs(home: &Path) -> Result<()> {
182    let relative: &[&str] = if cfg!(windows) {
183        &[
184            "AppData/Local",
185            "AppData/Local/Temp",
186            "AppData/LocalLow",
187            "AppData/Roaming",
188            "Desktop",
189            "Documents",
190            "Downloads",
191            "Music",
192            "Pictures",
193            "Videos",
194        ]
195    } else if cfg!(target_os = "macos") {
196        &[
197            "Library/Application Support",
198            "Library/Caches",
199            "Library/Preferences",
200            "Library/Logs",
201            "Desktop",
202            "Documents",
203            "Downloads",
204        ]
205    } else {
206        &[
207            ".local/share",
208            ".local/state",
209            ".config",
210            ".cache",
211            "Desktop",
212            "Documents",
213            "Downloads",
214        ]
215    };
216    for dir in relative {
217        fs::create_dir_all(home.join(dir))?;
218    }
219    Ok(())
220}
221
222impl QaWorkspace {
223    /// Persist an app-produced artifact before hashing it into managed evidence.
224    pub(crate) fn preserve_artifact(&self, path: &Path) -> Result<PathBuf> {
225        let Ok(relative) = path.strip_prefix(&self.home) else {
226            return Ok(path.to_path_buf());
227        };
228        if relative
229            .components()
230            .any(|c| c == std::path::Component::ParentDir)
231        {
232            return crate::util::err("app artifact escapes QA app home");
233        }
234        let target = if let Ok(p) = relative.strip_prefix("captures") {
235            self.capture_dir.join(p)
236        } else {
237            self.evidence_dir
238                .join("app")
239                .join(relative.strip_prefix("evidence").unwrap_or(relative))
240        };
241        storage::copy_tree(path, &target)?;
242        Ok(target)
243    }
244
245    /// Record the run kind in the run marker (see [`storage::RunKind`]).
246    pub fn set_kind(&self, kind: storage::RunKind) -> Result<()> {
247        storage::set_run_kind(&self.root, kind)
248    }
249
250    /// Record how the run ended in the run marker. Retention reads this and nothing else; a run
251    /// that never gets an outcome is never pruned by it.
252    pub fn set_outcome(&self, outcome: storage::RunOutcome) -> Result<()> {
253        storage::set_run_outcome(&self.root, outcome)
254    }
255
256    /// Preserve app-written outputs before marking this run complete.
257    pub fn finish(&self) -> Result<()> {
258        if let Some(marker) = storage::read_marker(&self.root.join(storage::RUN_MARKER)) {
259            storage::finish(&storage::ManagedRun {
260                root: self.root.clone(),
261                marker,
262            })?;
263        }
264        Ok(())
265    }
266    /// A fresh per-scenario directory inside the isolated data dir.
267    pub fn scenario_dir(&self, name: &str) -> Result<PathBuf> {
268        let safe: String = name
269            .chars()
270            .map(|c| {
271                if c.is_ascii_alphanumeric() || c == '-' {
272                    c
273                } else {
274                    '_'
275                }
276            })
277            .collect();
278        let dir = self.data_dir.join(format!("{safe}-{}", &new_id()[..8]));
279        fs::create_dir_all(&dir)?;
280        Ok(dir)
281    }
282}
283
284/// A process that still uses a QA home.
285#[derive(Debug, Clone, PartialEq, Eq)]
286pub struct HomeHolder {
287    pub pid: u32,
288    pub name: String,
289    /// Its executable is inside the home.
290    pub runs_from_home: bool,
291    /// It descends from an app this run launched into the home (see [`record_launched`]).
292    pub launched_by_run: bool,
293}
294
295impl HomeHolder {
296    /// Whether disposal may end it. A process is never ended merely because its command line
297    /// names the home: only one that runs from the home, or that the run's own app started.
298    pub fn owned_by_run(&self) -> bool {
299        self.runs_from_home || self.launched_by_run
300    }
301}
302
303/// One row of the process scan.
304#[derive(Debug, Clone, PartialEq, Eq)]
305struct ScannedProcess {
306    pid: u32,
307    parent: u32,
308    names_home: bool,
309    runs_from_home: bool,
310    name: String,
311}
312
313/// The process scan's output: `pid<TAB>parent pid<TAB>flags<TAB>name` per process, where flags
314/// holds `C` (the command line names the home) and `E` (the executable is inside it).
315#[cfg_attr(not(windows), allow(dead_code))]
316fn parse_scan(output: &str) -> Vec<ScannedProcess> {
317    output
318        .lines()
319        .filter_map(|line| {
320            let mut fields = line.trim().splitn(4, '\t');
321            let pid = fields.next()?.trim().parse::<u32>().ok()?;
322            let parent = fields.next()?.trim().parse::<u32>().ok()?;
323            let flags = fields.next()?;
324            let name = fields.next()?.trim().to_string();
325            Some(ScannedProcess {
326                pid,
327                parent,
328                names_home: flags.contains('C'),
329                runs_from_home: flags.contains('E'),
330                name,
331            })
332        })
333        .collect()
334}
335
336/// Add to `launched` every scanned process whose parent is already in it, until nothing is
337/// added: the apps the run launched and everything they started that is still listed. The set
338/// is kept across scans, so a helper stays known after its parent has exited.
339fn extend_descendants(launched: &mut std::collections::BTreeSet<u32>, scan: &[ScannedProcess]) {
340    loop {
341        let before = launched.len();
342        for process in scan {
343            if process.pid != 0 && launched.contains(&process.parent) {
344                launched.insert(process.pid);
345            }
346        }
347        if launched.len() == before {
348            return;
349        }
350    }
351}
352
353/// The scanned processes that use the home, without this process.
354fn holders_in(
355    scan: &[ScannedProcess],
356    launched: &std::collections::BTreeSet<u32>,
357) -> Vec<HomeHolder> {
358    let mut holders: Vec<HomeHolder> = scan
359        .iter()
360        .filter(|process| process.pid != std::process::id())
361        .filter(|process| process.names_home || process.runs_from_home)
362        .map(|process| HomeHolder {
363            pid: process.pid,
364            name: process.name.clone(),
365            runs_from_home: process.runs_from_home,
366            launched_by_run: launched.contains(&process.pid),
367        })
368        .collect();
369    holders.sort_by_key(|holder| holder.pid);
370    holders.dedup();
371    holders
372}
373
374fn describe_holders(holders: &[HomeHolder]) -> String {
375    holders
376        .iter()
377        .map(|holder| format!("{} (pid {})", holder.name, holder.pid))
378        .collect::<Vec<_>>()
379        .join(", ")
380}
381
382/// Windows: every process, marked when its command line names `home` or its executable lives in
383/// it. `None` when the scan itself failed.
384#[cfg(windows)]
385fn scan_processes(home: &Path) -> Option<Vec<ScannedProcess>> {
386    use std::os::windows::process::CommandExt;
387    use std::process::Command;
388    const SCAN: &str = "$h = $env:RIGHTKIT_QA_SCAN_HOME; \
389        Get-CimInstance Win32_Process | ForEach-Object { \
390        $f = '-'; \
391        if ($_.CommandLine -and $_.CommandLine.IndexOf($h, [StringComparison]::OrdinalIgnoreCase) -ge 0) { $f += 'C' }; \
392        if ($_.ExecutablePath -and $_.ExecutablePath.StartsWith($h + [char]92, [StringComparison]::OrdinalIgnoreCase)) { $f += 'E' }; \
393        [string]$_.ProcessId + [char]9 + [string]$_.ParentProcessId + [char]9 + $f + [char]9 + $_.Name }";
394    let system_root = std::env::var_os("SystemRoot").unwrap_or_else(|| "C:\\Windows".into());
395    let powershell = Path::new(&system_root)
396        .join("System32")
397        .join("WindowsPowerShell")
398        .join("v1.0")
399        .join("powershell.exe");
400    // Bounded: a stalled WMI query must not hang the run; no answer reads as "scan unavailable".
401    let output = crate::process::output_bounded(
402        Command::new(powershell)
403            .args(["-NoProfile", "-NonInteractive", "-Command", SCAN])
404            // The home travels in the environment, so the scanner's own command line never names it.
405            .env("RIGHTKIT_QA_SCAN_HOME", home)
406            .creation_flags(0x0800_0000),
407        Duration::from_secs(60),
408    )
409    .ok()??;
410    output
411        .status
412        .success()
413        .then(|| parse_scan(&String::from_utf8_lossy(&output.stdout)))
414}
415
416/// Other systems remove a directory whatever holds its files open: nothing to scan.
417#[cfg(not(windows))]
418fn scan_processes(_home: &Path) -> Option<Vec<ScannedProcess>> {
419    Some(Vec::new())
420}
421
422/// Windows: the processes that use `home`: their command line names it, or their executable is
423/// inside it. Empty on other systems. `None` when the scan itself failed.
424///
425/// An app launched into a QA home starts helpers that outlive it for a moment after it is
426/// stopped: WebView2 and GPU processes carry the home in `--user-data-dir=` and keep its
427/// `EBWebView`, shader cache and log files open while they exit, so removing the home at once
428/// fails with "Access is denied".
429pub fn home_holders(home: &Path) -> Option<Vec<HomeHolder>> {
430    let scan = scan_processes(home)?;
431    Some(holders_in(&scan, &Default::default()))
432}
433
434/// The file in a run root that lists the pids of the apps launched into its home.
435const LAUNCHED_PIDS: &str = ".launched-pids";
436
437/// Record that this run launched process `pid` into the workspace's home. Disposal may end
438/// what that process started and left behind; see [`dispose`]. Called by `control::launch`;
439/// call it for an app started by hand.
440pub fn record_launched(ws: &QaWorkspace, pid: u32) {
441    use std::io::Write;
442    if let Ok(mut file) = fs::OpenOptions::new()
443        .create(true)
444        .append(true)
445        .open(ws.root.join(LAUNCHED_PIDS))
446    {
447        let _ = writeln!(file, "{pid}");
448    }
449}
450
451fn launched_pids(ws: &QaWorkspace) -> std::collections::BTreeSet<u32> {
452    fs::read_to_string(ws.root.join(LAUNCHED_PIDS))
453        .unwrap_or_default()
454        .lines()
455        .filter_map(|line| line.trim().parse().ok())
456        .filter(|pid| *pid != 0 && *pid != std::process::id())
457        .collect()
458}
459
460/// Wait until no process uses the home, at most `wait`. Then end the holders that belong to the
461/// run (they run from the home, or descend from an app the run launched) and leave the others.
462/// Returns the holders that were still there when the wait ran out.
463fn settle_home_holders(ws: &QaWorkspace, wait: Duration) -> Vec<HomeHolder> {
464    let deadline = std::time::Instant::now() + wait;
465    let mut launched = launched_pids(ws);
466    let mut holders;
467    loop {
468        let Some(scan) = scan_processes(&ws.home) else {
469            return Vec::new();
470        };
471        extend_descendants(&mut launched, &scan);
472        holders = holders_in(&scan, &launched);
473        if holders.is_empty() || std::time::Instant::now() >= deadline {
474            break;
475        }
476        crate::util::sleep_ms(250);
477    }
478    for holder in holders.iter().filter(|holder| holder.owned_by_run()) {
479        crate::process::kill_tree(holder.pid);
480    }
481    holders
482}
483
484/// How long [`dispose`] waits for the helpers of a stopped app to leave its home.
485const HOLDER_WAIT: Duration = Duration::from_secs(20);
486
487/// Remove the workspace (or only its volatile data dir), retrying through the
488/// window where an exiting app still holds files.
489///
490/// On Windows it first waits (up to 20 seconds) for every process that still uses the home to
491/// exit. Of those that remain it ends only the run's own: a process whose executable is inside
492/// the home, or that descends from an app the run launched into it ([`record_launched`]). A
493/// process that merely names the home on its command line is never ended. A home that still
494/// cannot be removed is reported with the processes that hold it.
495pub fn dispose(
496    ws: &QaWorkspace,
497    keep_evidence: bool,
498    attempts: u32,
499    retry_ms: u64,
500) -> DisposeReport {
501    dispose_waiting(ws, keep_evidence, attempts, retry_ms, HOLDER_WAIT)
502}
503
504fn dispose_waiting(
505    ws: &QaWorkspace,
506    keep_evidence: bool,
507    attempts: u32,
508    retry_ms: u64,
509    holder_wait: Duration,
510) -> DisposeReport {
511    let mut report = DisposeReport::default();
512    if let Err(e) = ws.finish() {
513        report.retained.push((ws.root.clone(), e.to_string()));
514        return report;
515    }
516    let mut targets = vec![ws.home.clone()];
517    if !keep_evidence {
518        targets.push(ws.root.clone());
519    }
520    for target in targets {
521        if !target.exists() {
522            report.removed.push(target);
523            continue;
524        }
525        // Deletion happens only inside the QA root this runner owns, never by a caller's path.
526        if target == ws.home && !storage::is_owned_home(&target) {
527            report.retained.push((
528                target,
529                "not a QA home created by rightkit-qa; not deleted".into(),
530            ));
531            continue;
532        }
533        if target == ws.home {
534            settle_home_holders(ws, holder_wait);
535        }
536        let mut last = String::new();
537        for attempt in 1..=attempts.max(1) {
538            // The home goes through the link-safe removal, which keeps the binding file until
539            // everything else is gone. The run root keeps `remove_dir_all`: it holds only files
540            // this crate wrote and no app profile lives in it, so no app-made link can be there.
541            // If a profile is ever placed in the run root, it must go through the same removal.
542            let removal = if target == ws.home {
543                match target.parent() {
544                    Some(qa_root) => storage::remove_home_tree(qa_root, &target),
545                    None => Err("home has no parent directory".to_string()),
546                }
547            } else {
548                fs::remove_dir_all(&target).map_err(|e| e.to_string())
549            };
550            match removal {
551                Ok(()) => {
552                    last.clear();
553                    break;
554                }
555                Err(e) => {
556                    last = e;
557                    if attempt < attempts {
558                        crate::util::sleep_ms(retry_ms);
559                    }
560                }
561            }
562        }
563        if last.is_empty() && !target.exists() {
564            report.removed.push(target);
565        } else {
566            let mut why = if last.is_empty() {
567                "still present".to_string()
568            } else {
569                last
570            };
571            if target == ws.home {
572                // Name what holds it, so the report says which program to look at.
573                match home_holders(&target) {
574                    Some(holders) if !holders.is_empty() => {
575                        why.push_str(&format!("; held by {}", describe_holders(&holders)));
576                    }
577                    Some(_) if cfg!(windows) => {
578                        why.push_str("; no running process names this home");
579                    }
580                    _ => {}
581                }
582            }
583            report.retained.push((target.clone(), why));
584            if target == ws.home {
585                if !keep_evidence {
586                    report
587                        .retained
588                        .push((ws.root.clone(), "app temp cleanup pending".into()));
589                }
590                break;
591            }
592        }
593    }
594    report
595}
596
597/// Sweep run directories left by crashed runs. Bounded to `<root>/runs`, to
598/// entries older than `older_than`, and never touches `keep`.
599pub fn prune_abandoned(cache_root: &Path, older_than: Duration, keep: &[String]) -> DisposeReport {
600    let mut report = DisposeReport::default();
601    let runs_root = cache_root.join("runs");
602    let Ok(entries) = fs::read_dir(&runs_root) else {
603        return report;
604    };
605    let entries: Vec<_> = entries.flatten().collect();
606    for entry in &entries {
607        let Ok(ft) = entry.file_type() else { continue };
608        if ft.is_dir() && !ft.is_symlink() {
609            let app = entry.file_name().to_string_lossy().to_string();
610            if let Ok(r) = storage::prune_with_age_override(cache_root, &app, keep, older_than) {
611                report.removed.extend(r.removed);
612                report.retained.extend(r.retained);
613            }
614        }
615    }
616    report
617}
618
619#[cfg(test)]
620mod tests {
621    use super::*;
622
623    #[test]
624    fn isolates_and_disposes() {
625        let root = storage::app_temp_dir().join(format!("rkqa-ws-{}", new_id()));
626        let ws = create(&root, Some("run1"), "genright").unwrap();
627        assert!(ws.env.contains_key("GENRIGHT_DATA_DIR"));
628        assert!(ws.data_dir.exists());
629        assert!(!ws.root.join("data").exists());
630        // The home is created under the canonical temp directory, which on Windows differs in
631        // form from the one the environment names (short names, no extended-length prefix).
632        let temp = crate::util::canonical_plain(&storage::app_temp_dir()).unwrap();
633        assert!(ws.home.starts_with(temp.join("rightkit-qa")));
634        assert!(!ws.home.starts_with(&ws.root));
635        assert!(ws.evidence_dir.starts_with(&ws.root));
636        assert!(ws.capture_dir.starts_with(&ws.root));
637        for key in [
638            "RIGHTKIT_RUN_ROOT",
639            "RIGHTKIT_SUITE_ROOT",
640            "HOME",
641            "USERPROFILE",
642            "APPDATA",
643            "LOCALAPPDATA",
644            "XDG_CACHE_HOME",
645            "XDG_DATA_HOME",
646            "XDG_CONFIG_HOME",
647            "WEBVIEW2_USER_DATA_FOLDER",
648            "GENRIGHT_DATA_DIR",
649            "RIGHTKIT_QA_DATA_DIR",
650            "RIGHTKIT_QA_EVIDENCE_DIR",
651            "RIGHTKIT_QA_CAPTURE_DIR",
652            "TMPDIR",
653            "TMP",
654            "TEMP",
655        ] {
656            assert!(Path::new(&ws.env[key]).starts_with(&ws.home), "{key}");
657        }
658        for (key, value) in &ws.env {
659            let path = Path::new(value);
660            if path.starts_with(&ws.home) {
661                assert!(path.is_dir(), "{key} -> {value} must exist as a directory");
662            }
663        }
664        let known: &[&str] = if cfg!(windows) {
665            &["AppData/Local", "AppData/Roaming", "AppData/LocalLow"]
666        } else if cfg!(target_os = "macos") {
667            &["Library/Application Support", "Library/Caches"]
668        } else {
669            &[".local/share", ".config", ".cache"]
670        };
671        for dir in known {
672            assert!(ws.home.join(dir).is_dir(), "{dir}");
673        }
674        fs::write(ws.home.join("app-launch.log"), "live log").unwrap();
675        fs::write(ws.home.join("evidence/from-app.json"), "app evidence").unwrap();
676        fs::write(ws.home.join("captures/frame.png"), "frame").unwrap();
677        fs::write(ws.data_dir.join("result.txt"), "result").unwrap();
678        let artifact = ws
679            .preserve_artifact(&ws.data_dir.join("result.txt"))
680            .unwrap();
681        assert_eq!(artifact, ws.evidence_dir.join("app/data/result.txt"));
682        assert!(create(&root, Some("../x"), "a").is_err());
683        let r = dispose(&ws, true, 2, 1);
684        assert!(
685            r.retained.is_empty()
686                && !ws.data_dir.exists()
687                && ws.root.exists()
688                && ws.evidence_dir.exists()
689        );
690        assert!(!ws.home.exists());
691        assert_eq!(fs::read_to_string(artifact).unwrap(), "result");
692        assert_eq!(
693            fs::read_to_string(ws.evidence_dir.join("app-launch.log")).unwrap(),
694            "live log"
695        );
696        assert_eq!(
697            fs::read_to_string(ws.evidence_dir.join("app/from-app.json")).unwrap(),
698            "app evidence"
699        );
700        assert_eq!(
701            fs::read_to_string(ws.capture_dir.join("frame.png")).unwrap(),
702            "frame"
703        );
704        let r = dispose(&ws, false, 2, 1);
705        assert!(r.retained.is_empty() && !ws.root.exists());
706        let _ = fs::remove_dir_all(root);
707    }
708
709    #[test]
710    fn orphan_sweep_removes_registered_temp_home_but_spares_active_runs() {
711        // Canonical from the start, so the paths the sweep reports and the workspace's own
712        // root have the same form on Windows.
713        let root = fs::canonicalize(storage::app_temp_dir())
714            .unwrap()
715            .join(format!("rkqa-orphan-{}", new_id()));
716        let ws = create(&root, Some("orphan"), "app").unwrap();
717        assert!(prune_abandoned(&root, Duration::ZERO, &[])
718            .removed
719            .is_empty());
720        assert!(ws.home.exists());
721        ws.finish().unwrap();
722        let report = prune_abandoned(&root, Duration::ZERO, &[]);
723        assert!(report.retained.is_empty());
724        let removed: Vec<PathBuf> = report
725            .removed
726            .iter()
727            .map(|p| crate::util::plain_path(p))
728            .collect();
729        assert_eq!(removed, vec![ws.root.clone()]);
730        assert!(!ws.home.exists());
731        fs::remove_dir_all(root).unwrap();
732    }
733
734    /// Orthic-Labs/rightkit#185: apps and tests get ordinary drive paths, never `\\?\C:\...`.
735    #[test]
736    fn workspace_paths_and_env_are_plain_drive_paths() {
737        let root = storage::app_temp_dir().join(format!("rkqa-plain-{}", new_id()));
738        let ws = create(&root, Some("plain1"), "plainapp").unwrap();
739        let paths = [
740            &ws.root,
741            &ws.home,
742            &ws.data_dir,
743            &ws.evidence_dir,
744            &ws.capture_dir,
745        ];
746        for p in paths {
747            assert!(!p.to_string_lossy().starts_with(r"\\?\"), "{}", p.display());
748            assert!(p.exists(), "{}", p.display());
749        }
750        for (key, value) in &ws.env {
751            assert!(!value.starts_with(r"\\?\"), "{key}={value}");
752        }
753        // Plain does not break the safety checks: the finished run is still disposed.
754        let report = dispose(&ws, false, 2, 1);
755        assert!(report.retained.is_empty(), "{report:?}");
756        assert!(!ws.home.exists() && !ws.root.exists());
757        let _ = fs::remove_dir_all(root);
758    }
759
760    #[test]
761    fn prune_spares_young_and_kept_runs() {
762        let root = storage::app_temp_dir().join(format!("rkqa-prune-{}", new_id()));
763        let a = storage::create(Some(&root), "x", Some("a"), Duration::from_secs(3600)).unwrap();
764        let _b = storage::create(Some(&root), "x", Some("b"), Duration::from_secs(3600)).unwrap();
765        storage::finish(&a).unwrap();
766        let r = prune_abandoned(&root, Duration::from_secs(3600), &["b".into()]);
767        assert!(r.removed.is_empty());
768        let r = prune_abandoned(&root, Duration::ZERO, &["b".into()]);
769        assert_eq!(r.removed.len(), 1);
770        assert!(root.join("runs/x/b").exists() && !root.join("runs/x/a").exists());
771        let _ = fs::remove_dir_all(root);
772    }
773
774    #[test]
775    fn dispose_deletes_only_a_home_the_runner_created() {
776        let root = storage::app_temp_dir().join(format!("rkqa-ws-own-{}", new_id()));
777        let mut ws = create(&root, Some("own1"), "ownapp").unwrap();
778        // A caller-supplied path that is not a QA home is never deleted.
779        let foreign = root.join("precious");
780        fs::create_dir_all(&foreign).unwrap();
781        fs::write(foreign.join("keep"), b"keep").unwrap();
782        let real_home = ws.home.clone();
783        ws.home = foreign.clone();
784        let report = dispose(&ws, true, 1, 0);
785        assert!(foreign.join("keep").exists(), "{report:?}");
786        assert!(report.retained.iter().any(|(p, _)| p == &foreign));
787        // The genuine home is removed.
788        ws.home = real_home.clone();
789        let report = dispose(&ws, false, 1, 0);
790        assert!(report.retained.is_empty(), "{report:?}");
791        assert!(!real_home.exists());
792        let _ = fs::remove_dir_all(root);
793    }
794
795    #[cfg(windows)]
796    #[test]
797    fn dispose_removes_a_home_that_holds_the_wininet_junction_and_a_link_out() {
798        // Gap ledger 216: every home of an app that used WinINet stayed behind.
799        let root = storage::app_temp_dir().join(format!("rkqa-ws-links-{}", new_id()));
800        let ws = create(&root, Some("links1"), "linkapp").unwrap();
801        let models = root.join("models-cache");
802        fs::create_dir_all(&models).unwrap();
803        fs::write(models.join("model.bin"), b"model bytes").unwrap();
804        storage::test_links::wininet_cache(&ws.home);
805        fs::create_dir_all(ws.home.join("data").join("shared")).unwrap();
806        storage::test_links::junction(&ws.home.join("data").join("shared").join("models"), &models);
807        let report = dispose(&ws, true, 1, 0);
808        assert!(report.retained.is_empty(), "{report:?}");
809        assert!(!ws.home.exists());
810        assert_eq!(fs::read(models.join("model.bin")).unwrap(), b"model bytes");
811        let _ = fs::remove_dir_all(root);
812    }
813
814    fn scanned(pid: u32, parent: u32, flags: &str, name: &str) -> ScannedProcess {
815        ScannedProcess {
816            pid,
817            parent,
818            names_home: flags.contains('C'),
819            runs_from_home: flags.contains('E'),
820            name: name.into(),
821        }
822    }
823
824    #[test]
825    fn the_process_scan_is_read_row_by_row() {
826        let scan = parse_scan(
827            "  412\t98\t-C\tmsedgewebview2.exe\r\n98\t4\t-\tCutRight.exe\n\nnot a row\n7\t1\t-CE\ttool.exe\n",
828        );
829        assert_eq!(
830            scan,
831            vec![
832                scanned(412, 98, "C", "msedgewebview2.exe"),
833                scanned(98, 4, "", "CutRight.exe"),
834                scanned(7, 1, "CE", "tool.exe"),
835            ]
836        );
837        assert!(parse_scan("").is_empty());
838    }
839
840    #[test]
841    fn only_the_runs_own_processes_may_be_ended_and_every_holder_is_named() {
842        let me = std::process::id();
843        let scan = vec![
844            // The launched app (already recorded), its WebView2 browser and that one's GPU child.
845            scanned(98, 4, "", "CutRight.exe"),
846            scanned(412, 98, "C", "msedgewebview2.exe"),
847            scanned(413, 412, "C", "msedgewebview2.exe"),
848            // A tool that runs from inside the home.
849            scanned(7, 1, "CE", "tool.exe"),
850            // Someone's editor or shell that merely names the home: a holder, never ended.
851            scanned(900, 1, "C", "notepad.exe"),
852            // Unrelated, and this process itself even if its command line named the home.
853            scanned(5000, 1, "", "explorer.exe"),
854            scanned(me, 1, "C", "rightkit-qa.exe"),
855        ];
856        let mut launched = std::collections::BTreeSet::from([98]);
857        extend_descendants(&mut launched, &scan);
858        assert_eq!(launched, std::collections::BTreeSet::from([98, 412, 413]));
859
860        let holders = holders_in(&scan, &launched);
861        assert_eq!(
862            holders
863                .iter()
864                .map(|h| (h.pid, h.owned_by_run()))
865                .collect::<Vec<_>>(),
866            vec![(7, true), (412, true), (413, true), (900, false)]
867        );
868        assert_eq!(
869            describe_holders(&holders),
870            "tool.exe (pid 7), msedgewebview2.exe (pid 412), msedgewebview2.exe (pid 413), notepad.exe (pid 900)"
871        );
872
873        // A later scan, after the browser process has exited: its child is still known.
874        let later = vec![scanned(413, 412, "C", "msedgewebview2.exe")];
875        extend_descendants(&mut launched, &later);
876        assert!(holders_in(&later, &launched)[0].launched_by_run);
877        // Without a recorded launch nothing that only names the home may be ended.
878        assert!(!holders_in(&later, &Default::default())[0].owned_by_run());
879    }
880
881    #[test]
882    fn launched_pids_are_recorded_in_the_run_root() {
883        let root = storage::app_temp_dir().join(format!("rkqa-ws-pids-{}", new_id()));
884        let ws = create(&root, Some("pids1"), "pidsapp").unwrap();
885        assert!(launched_pids(&ws).is_empty());
886        record_launched(&ws, 4242);
887        record_launched(&ws, 4343);
888        record_launched(&ws, std::process::id());
889        assert_eq!(
890            launched_pids(&ws),
891            std::collections::BTreeSet::from([4242, 4343])
892        );
893        let report = dispose(&ws, false, 2, 1);
894        assert!(report.retained.is_empty(), "{report:?}");
895        let _ = fs::remove_dir_all(root);
896    }
897
898    /// Ledger 174. A helper that the launched app started outlives it, sits in the home (its
899    /// working directory) and names it on its command line, as WebView2 does. Removing the home
900    /// at once fails; disposal finds the helper, waits, ends it and then removes the home.
901    #[cfg(windows)]
902    #[test]
903    fn dispose_waits_for_a_helper_the_app_left_in_the_home_then_removes_it() {
904        use std::os::windows::process::CommandExt;
905        let root = storage::app_temp_dir().join(format!("rkqa-ws-held-{}", new_id()));
906        let ws = create(&root, Some("held1"), "heldapp").unwrap();
907        // The "app": a shell the run launched. Its child keeps running in the home.
908        let mut app = std::process::Command::new("cmd.exe")
909            .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
910            .arg(format!("--user-data-dir={}", ws.home.display()))
911            .current_dir(&ws.home)
912            .creation_flags(0x0800_0000)
913            .spawn()
914            .unwrap();
915        record_launched(&ws, app.id());
916
917        // The scan names it, and it is not this process.
918        let holders = home_holders(&ws.home).expect("the process scan runs");
919        assert!(
920            holders.iter().any(|holder| holder.pid == app.id()),
921            "{holders:?}"
922        );
923        assert!(holders
924            .iter()
925            .all(|holder| holder.pid != std::process::id()));
926
927        let report = dispose_waiting(&ws, false, 20, 250, Duration::from_secs(2));
928        assert!(report.retained.is_empty(), "{report:?}");
929        assert!(!ws.home.exists());
930        let _ = app.kill();
931        let _ = app.wait();
932        let _ = fs::remove_dir_all(root);
933    }
934
935    /// A process that only names the home on its command line, and that the run did not launch,
936    /// is left running and named in the report.
937    #[cfg(windows)]
938    #[test]
939    fn dispose_leaves_a_foreign_process_that_names_the_home_and_reports_it() {
940        use std::os::windows::process::CommandExt;
941        let root = storage::app_temp_dir().join(format!("rkqa-ws-foreign-{}", new_id()));
942        let ws = create(&root, Some("foreign1"), "foreignapp").unwrap();
943        let mut foreign = std::process::Command::new("cmd.exe")
944            .args(["/d", "/c", "ping -n 120 127.0.0.1 >nul & rem"])
945            .arg(format!("--watch={}", ws.home.display()))
946            .current_dir(&ws.home)
947            .creation_flags(0x0800_0000)
948            .spawn()
949            .unwrap();
950
951        let report = dispose_waiting(&ws, false, 2, 50, Duration::from_millis(500));
952        assert!(matches!(foreign.try_wait(), Ok(None)), "it must still run");
953        let (_, why) = report
954            .retained
955            .iter()
956            .find(|(path, _)| path == &ws.home)
957            .unwrap_or_else(|| panic!("the home is held: {report:?}"));
958        assert!(
959            why.contains(&format!("held by cmd.exe (pid {})", foreign.id())),
960            "{why}"
961        );
962
963        crate::process::kill_tree(foreign.id());
964        let _ = foreign.wait();
965        // With the holder gone the home can be removed.
966        crate::util::sleep_ms(300);
967        assert!(fs::remove_dir_all(&ws.home).is_ok());
968        let _ = fs::remove_dir_all(root);
969    }
970}