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::util::{err, new_id, require_text, Result};
5use std::collections::BTreeMap;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::{Duration, SystemTime};
9
10#[derive(Debug, Clone)]
11pub struct QaWorkspace {
12    pub run_id: String,
13    /// Evidence side: lives under the cache root next to the app.
14    pub root: PathBuf,
15    /// App side: under the system temp dir. Apps started by launchd on macOS cannot
16    /// write to external volumes, so everything the app touches (data dir, port file,
17    /// live log) lives here and evidence is copied back.
18    pub home: PathBuf,
19    pub data_dir: PathBuf,
20    pub evidence_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
31pub fn create(cache_root: &Path, run_id: Option<&str>, app_key: &str) -> Result<QaWorkspace> {
32    let app_key = require_text(app_key, "app")?;
33    let run_id = run_id.map(str::to_string).unwrap_or_else(new_id);
34    if run_id.is_empty()
35        || !run_id.chars().next().unwrap().is_ascii_alphanumeric()
36        || !run_id
37            .chars()
38            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
39    {
40        return err(format!("unsafe QA run id: {run_id}"));
41    }
42    let root = cache_root.join("runs").join(&run_id);
43    let home = std::env::temp_dir()
44        .join("rightkit-qa")
45        .join(format!("{}-{run_id}", app_key));
46    let data_dir = home.join("data");
47    let evidence_dir = root.join("evidence");
48    fs::create_dir_all(&data_dir)?;
49    fs::create_dir_all(&evidence_dir)?;
50    let mut env = BTreeMap::new();
51    env.insert("RIGHTKIT_QA_NATIVE".into(), "1".into());
52    env.insert("RIGHTKIT_QA_RUN_ID".into(), run_id.clone());
53    env.insert(
54        "RIGHTKIT_QA_DATA_DIR".into(),
55        data_dir.to_string_lossy().into(),
56    );
57    env.insert(
58        "RIGHTKIT_QA_EVIDENCE_DIR".into(),
59        evidence_dir.to_string_lossy().into(),
60    );
61    env.insert("RIGHTKIT_QA_MUTE".into(), "1".into());
62    env.insert("XDG_DATA_HOME".into(), data_dir.to_string_lossy().into());
63    env.insert("XDG_CONFIG_HOME".into(), data_dir.to_string_lossy().into());
64    env.insert(
65        "WEBVIEW2_USER_DATA_FOLDER".into(),
66        data_dir.join("webview2").to_string_lossy().into(),
67    );
68    let key: String = app_key
69        .to_uppercase()
70        .chars()
71        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
72        .collect();
73    env.insert(format!("{key}_DATA_DIR"), data_dir.to_string_lossy().into());
74    Ok(QaWorkspace {
75        run_id,
76        root,
77        home,
78        data_dir,
79        evidence_dir,
80        env,
81    })
82}
83
84impl QaWorkspace {
85    /// A fresh per-scenario directory inside the isolated data dir.
86    pub fn scenario_dir(&self, name: &str) -> Result<PathBuf> {
87        let safe: String = name
88            .chars()
89            .map(|c| {
90                if c.is_ascii_alphanumeric() || c == '-' {
91                    c
92                } else {
93                    '_'
94                }
95            })
96            .collect();
97        let dir = self.data_dir.join(format!("{safe}-{}", &new_id()[..8]));
98        fs::create_dir_all(&dir)?;
99        Ok(dir)
100    }
101}
102
103/// Remove the workspace (or only its volatile data dir), retrying through the
104/// window where an exiting app still holds files.
105pub fn dispose(
106    ws: &QaWorkspace,
107    keep_evidence: bool,
108    attempts: u32,
109    retry_ms: u64,
110) -> DisposeReport {
111    let mut report = DisposeReport::default();
112    let mut targets = vec![ws.home.clone()];
113    if !keep_evidence {
114        targets.push(ws.root.clone());
115    }
116    for target in targets {
117        if !target.exists() {
118            report.removed.push(target);
119            continue;
120        }
121        let mut last = String::new();
122        for attempt in 1..=attempts.max(1) {
123            match fs::remove_dir_all(&target) {
124                Ok(()) => {
125                    last.clear();
126                    break;
127                }
128                Err(e) => {
129                    last = e.to_string();
130                    if attempt < attempts {
131                        crate::util::sleep_ms(retry_ms);
132                    }
133                }
134            }
135        }
136        if last.is_empty() && !target.exists() {
137            report.removed.push(target);
138        } else {
139            report.retained.push((
140                target,
141                if last.is_empty() {
142                    "still present".into()
143                } else {
144                    last
145                },
146            ));
147        }
148    }
149    report
150}
151
152/// Sweep run directories left by crashed runs. Bounded to `<root>/runs`, to
153/// entries older than `older_than`, and never touches `keep`.
154pub fn prune_abandoned(cache_root: &Path, older_than: Duration, keep: &[String]) -> DisposeReport {
155    let mut report = DisposeReport::default();
156    let Ok(entries) = fs::read_dir(cache_root.join("runs")) else {
157        return report;
158    };
159    for entry in entries.flatten() {
160        let name = entry.file_name().to_string_lossy().to_string();
161        let Ok(ft) = entry.file_type() else { continue };
162        if !ft.is_dir() || ft.is_symlink() || keep.contains(&name) {
163            continue;
164        }
165        let path = entry.path();
166        let age = fs::metadata(&path)
167            .and_then(|m| m.modified())
168            .ok()
169            .and_then(|t| SystemTime::now().duration_since(t).ok());
170        match age {
171            Some(a) if a >= older_than => match fs::remove_dir_all(&path) {
172                Ok(()) => report.removed.push(path),
173                Err(e) => report.retained.push((path, e.to_string())),
174            },
175            Some(a) => report.retained.push((
176                path,
177                format!("only {}s old; may belong to a live run", a.as_secs()),
178            )),
179            None => continue,
180        }
181    }
182    report
183}
184
185/// Sweep temp-side homes (`<temp>/rightkit-qa/<app>-<run>`) of crashed runs for one app.
186pub fn prune_abandoned_homes(
187    app_key: &str,
188    older_than: Duration,
189    keep_run_ids: &[String],
190) -> DisposeReport {
191    let mut report = DisposeReport::default();
192    let Ok(rd) = fs::read_dir(std::env::temp_dir().join("rightkit-qa")) else {
193        return report;
194    };
195    for e in rd.flatten() {
196        let name = e.file_name().to_string_lossy().to_string();
197        if !name.starts_with(&format!("{app_key}-"))
198            || keep_run_ids.iter().any(|k| name.ends_with(k.as_str()))
199        {
200            continue;
201        }
202        let age = fs::metadata(e.path())
203            .and_then(|m| m.modified())
204            .ok()
205            .and_then(|t| SystemTime::now().duration_since(t).ok());
206        if age.map(|a| a >= older_than).unwrap_or(false) {
207            match fs::remove_dir_all(e.path()) {
208                Ok(()) => report.removed.push(e.path()),
209                Err(err) => report.retained.push((e.path(), err.to_string())),
210            }
211        }
212    }
213    report
214}
215
216#[cfg(test)]
217mod tests {
218    use super::*;
219
220    #[test]
221    fn isolates_and_disposes() {
222        let root = std::env::temp_dir().join(format!("rkqa-ws-{}", new_id()));
223        let ws = create(&root, Some("run1"), "genright").unwrap();
224        assert!(ws.env.contains_key("GENRIGHT_DATA_DIR"));
225        assert!(ws.data_dir.exists());
226        assert!(create(&root, Some("../x"), "a").is_err());
227        let r = dispose(&ws, true, 2, 1);
228        assert!(
229            r.retained.is_empty()
230                && !ws.data_dir.exists()
231                && !ws.home.exists()
232                && ws.evidence_dir.exists()
233        );
234        let r = dispose(&ws, false, 2, 1);
235        assert!(r.retained.is_empty() && !ws.root.exists());
236        let _ = fs::remove_dir_all(root);
237    }
238
239    #[test]
240    fn prune_spares_young_and_kept_runs() {
241        let root = std::env::temp_dir().join(format!("rkqa-prune-{}", new_id()));
242        create(&root, Some("a"), "x").unwrap();
243        create(&root, Some("b"), "x").unwrap();
244        let r = prune_abandoned(&root, Duration::from_secs(3600), &["b".into()]);
245        assert!(r.removed.is_empty());
246        let r = prune_abandoned(&root, Duration::ZERO, &["b".into()]);
247        assert_eq!(r.removed.len(), 1);
248        assert!(root.join("runs/b").exists() && !root.join("runs/a").exists());
249        let _ = fs::remove_dir_all(root);
250    }
251}