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