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