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
31pub fn create(cache_root: &Path, run_id: Option<&str>, app_key: &str) -> Result<QaWorkspace> {
32    create_impl(Some(cache_root), run_id, app_key)
33}
34
35/// Create under RIGHTKIT_MANAGED_ROOT (or its platform default).
36pub fn create_managed(run_id: Option<&str>, app_key: &str) -> Result<QaWorkspace> {
37    create_impl(None, run_id, app_key)
38}
39
40pub(crate) fn create_impl_for_harness(
41    managed_root: Option<&Path>,
42    app_key: &str,
43) -> Result<QaWorkspace> {
44    create_impl(managed_root, None, app_key)
45}
46
47fn create_impl(
48    cache_root: Option<&Path>,
49    run_id: Option<&str>,
50    app_key: &str,
51) -> Result<QaWorkspace> {
52    let app_key = require_text(app_key, "app")?;
53    let mut managed =
54        storage::create(cache_root, &app_key, run_id, Duration::from_secs(24 * 3600))?;
55    let home = storage::create_app_root(&mut managed)?;
56    let run_id = managed.marker.run_id.clone();
57    let root = managed.root;
58    // Generic managed storage creates this directory; QA profiles belong only in home.
59    fs::remove_dir(root.join("data"))?;
60    let data_dir = home.join("data");
61    let evidence_dir = root.join("evidence");
62    let capture_dir = root.join("captures");
63    let tmp_dir = home.join("tmp");
64    let cache_dir = home.join("cache");
65    fs::create_dir_all(&data_dir)?;
66    fs::create_dir_all(&evidence_dir)?;
67    fs::create_dir_all(&capture_dir)?;
68    fs::create_dir_all(&tmp_dir)?;
69    fs::create_dir_all(&cache_dir)?;
70    fs::create_dir_all(home.join("evidence"))?;
71    fs::create_dir_all(home.join("captures"))?;
72    let mut env = BTreeMap::new();
73    env.insert("RIGHTKIT_QA_NATIVE".into(), "1".into());
74    env.insert("RIGHTKIT_QA_RUN_ID".into(), run_id.clone());
75    env.insert("RIGHTKIT_RUN_ROOT".into(), home.to_string_lossy().into());
76    env.insert(
77        "RIGHTKIT_SUITE_ROOT".into(),
78        home.join("suite").to_string_lossy().into(),
79    );
80    env.insert("HOME".into(), home.to_string_lossy().into());
81    env.insert("USERPROFILE".into(), home.to_string_lossy().into());
82    env.insert("APPDATA".into(), data_dir.to_string_lossy().into());
83    env.insert("LOCALAPPDATA".into(), data_dir.to_string_lossy().into());
84    env.insert("XDG_CACHE_HOME".into(), cache_dir.to_string_lossy().into());
85    env.insert(
86        "RIGHTKIT_QA_DATA_DIR".into(),
87        data_dir.to_string_lossy().into(),
88    );
89    env.insert(
90        "RIGHTKIT_QA_EVIDENCE_DIR".into(),
91        home.join("evidence").to_string_lossy().into(),
92    );
93    env.insert(
94        "RIGHTKIT_QA_CAPTURE_DIR".into(),
95        home.join("captures").to_string_lossy().into(),
96    );
97    env.insert("TMPDIR".into(), tmp_dir.to_string_lossy().into());
98    env.insert("TMP".into(), tmp_dir.to_string_lossy().into());
99    env.insert("TEMP".into(), tmp_dir.to_string_lossy().into());
100    env.insert("RIGHTKIT_QA_MUTE".into(), "1".into());
101    env.insert("XDG_DATA_HOME".into(), data_dir.to_string_lossy().into());
102    env.insert("XDG_CONFIG_HOME".into(), data_dir.to_string_lossy().into());
103    env.insert(
104        "WEBVIEW2_USER_DATA_FOLDER".into(),
105        data_dir.join("webview2").to_string_lossy().into(),
106    );
107    let key: String = app_key
108        .to_uppercase()
109        .chars()
110        .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
111        .collect();
112    env.insert(format!("{key}_DATA_DIR"), data_dir.to_string_lossy().into());
113    Ok(QaWorkspace {
114        run_id,
115        root,
116        home,
117        data_dir,
118        evidence_dir,
119        capture_dir,
120        env,
121    })
122}
123
124impl QaWorkspace {
125    /// Persist an app-produced artifact before hashing it into managed evidence.
126    pub(crate) fn preserve_artifact(&self, path: &Path) -> Result<PathBuf> {
127        let Ok(relative) = path.strip_prefix(&self.home) else {
128            return Ok(path.to_path_buf());
129        };
130        if relative
131            .components()
132            .any(|c| c == std::path::Component::ParentDir)
133        {
134            return crate::util::err("app artifact escapes QA app home");
135        }
136        let target = if let Ok(p) = relative.strip_prefix("captures") {
137            self.capture_dir.join(p)
138        } else {
139            self.evidence_dir
140                .join("app")
141                .join(relative.strip_prefix("evidence").unwrap_or(relative))
142        };
143        storage::copy_tree(path, &target)?;
144        Ok(target)
145    }
146
147    /// Preserve app-written outputs before marking this run complete.
148    pub fn finish(&self) -> Result<()> {
149        if let Some(marker) = storage::read_marker(&self.root.join(storage::RUN_MARKER)) {
150            storage::finish(&storage::ManagedRun {
151                root: self.root.clone(),
152                marker,
153            })?;
154        }
155        Ok(())
156    }
157    /// A fresh per-scenario directory inside the isolated data dir.
158    pub fn scenario_dir(&self, name: &str) -> Result<PathBuf> {
159        let safe: String = name
160            .chars()
161            .map(|c| {
162                if c.is_ascii_alphanumeric() || c == '-' {
163                    c
164                } else {
165                    '_'
166                }
167            })
168            .collect();
169        let dir = self.data_dir.join(format!("{safe}-{}", &new_id()[..8]));
170        fs::create_dir_all(&dir)?;
171        Ok(dir)
172    }
173}
174
175/// Remove the workspace (or only its volatile data dir), retrying through the
176/// window where an exiting app still holds files.
177pub fn dispose(
178    ws: &QaWorkspace,
179    keep_evidence: bool,
180    attempts: u32,
181    retry_ms: u64,
182) -> DisposeReport {
183    let mut report = DisposeReport::default();
184    if let Err(e) = ws.finish() {
185        report.retained.push((ws.root.clone(), e.to_string()));
186        return report;
187    }
188    let mut targets = vec![ws.home.clone()];
189    if !keep_evidence {
190        targets.push(ws.root.clone());
191    }
192    for target in targets {
193        if !target.exists() {
194            report.removed.push(target);
195            continue;
196        }
197        let mut last = String::new();
198        for attempt in 1..=attempts.max(1) {
199            match fs::remove_dir_all(&target) {
200                Ok(()) => {
201                    last.clear();
202                    break;
203                }
204                Err(e) => {
205                    last = e.to_string();
206                    if attempt < attempts {
207                        crate::util::sleep_ms(retry_ms);
208                    }
209                }
210            }
211        }
212        if last.is_empty() && !target.exists() {
213            report.removed.push(target);
214        } else {
215            report.retained.push((
216                target.clone(),
217                if last.is_empty() {
218                    "still present".into()
219                } else {
220                    last
221                },
222            ));
223            if target == ws.home {
224                if !keep_evidence {
225                    report
226                        .retained
227                        .push((ws.root.clone(), "app temp cleanup pending".into()));
228                }
229                break;
230            }
231        }
232    }
233    report
234}
235
236/// Sweep run directories left by crashed runs. Bounded to `<root>/runs`, to
237/// entries older than `older_than`, and never touches `keep`.
238pub fn prune_abandoned(cache_root: &Path, older_than: Duration, keep: &[String]) -> DisposeReport {
239    let mut report = DisposeReport::default();
240    let runs_root = cache_root.join("runs");
241    let Ok(entries) = fs::read_dir(&runs_root) else {
242        return report;
243    };
244    let entries: Vec<_> = entries.flatten().collect();
245    for entry in &entries {
246        let Ok(ft) = entry.file_type() else { continue };
247        if ft.is_dir() && !ft.is_symlink() {
248            let app = entry.file_name().to_string_lossy().to_string();
249            if let Ok(r) = storage::prune_with_age_override(cache_root, &app, keep, older_than) {
250                report.removed.extend(r.removed);
251                report.retained.extend(r.retained);
252            }
253        }
254    }
255    report
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn isolates_and_disposes() {
264        let root = std::env::temp_dir().join(format!("rkqa-ws-{}", new_id()));
265        let ws = create(&root, Some("run1"), "genright").unwrap();
266        assert!(ws.env.contains_key("GENRIGHT_DATA_DIR"));
267        assert!(ws.data_dir.exists());
268        assert!(!ws.root.join("data").exists());
269        assert!(ws
270            .home
271            .starts_with(storage::app_temp_dir().join("rightkit-qa")));
272        assert!(!ws.home.starts_with(&ws.root));
273        assert!(ws.evidence_dir.starts_with(&ws.root));
274        assert!(ws.capture_dir.starts_with(&ws.root));
275        for key in [
276            "RIGHTKIT_RUN_ROOT",
277            "RIGHTKIT_SUITE_ROOT",
278            "HOME",
279            "USERPROFILE",
280            "APPDATA",
281            "LOCALAPPDATA",
282            "XDG_CACHE_HOME",
283            "XDG_DATA_HOME",
284            "XDG_CONFIG_HOME",
285            "WEBVIEW2_USER_DATA_FOLDER",
286            "GENRIGHT_DATA_DIR",
287            "RIGHTKIT_QA_DATA_DIR",
288            "RIGHTKIT_QA_EVIDENCE_DIR",
289            "RIGHTKIT_QA_CAPTURE_DIR",
290            "TMPDIR",
291            "TMP",
292            "TEMP",
293        ] {
294            assert!(Path::new(&ws.env[key]).starts_with(&ws.home), "{key}");
295        }
296        fs::write(ws.home.join("app-launch.log"), "live log").unwrap();
297        fs::write(ws.home.join("evidence/from-app.json"), "app evidence").unwrap();
298        fs::write(ws.home.join("captures/frame.png"), "frame").unwrap();
299        fs::write(ws.data_dir.join("result.txt"), "result").unwrap();
300        let artifact = ws
301            .preserve_artifact(&ws.data_dir.join("result.txt"))
302            .unwrap();
303        assert_eq!(artifact, ws.evidence_dir.join("app/data/result.txt"));
304        assert!(create(&root, Some("../x"), "a").is_err());
305        let r = dispose(&ws, true, 2, 1);
306        assert!(
307            r.retained.is_empty()
308                && !ws.data_dir.exists()
309                && ws.root.exists()
310                && ws.evidence_dir.exists()
311        );
312        assert!(!ws.home.exists());
313        assert_eq!(fs::read_to_string(artifact).unwrap(), "result");
314        assert_eq!(
315            fs::read_to_string(ws.evidence_dir.join("app-launch.log")).unwrap(),
316            "live log"
317        );
318        assert_eq!(
319            fs::read_to_string(ws.evidence_dir.join("app/from-app.json")).unwrap(),
320            "app evidence"
321        );
322        assert_eq!(
323            fs::read_to_string(ws.capture_dir.join("frame.png")).unwrap(),
324            "frame"
325        );
326        let r = dispose(&ws, false, 2, 1);
327        assert!(r.retained.is_empty() && !ws.root.exists());
328        let _ = fs::remove_dir_all(root);
329    }
330
331    #[test]
332    fn orphan_sweep_removes_registered_temp_home_but_spares_active_runs() {
333        let root = storage::app_temp_dir().join(format!("rkqa-orphan-{}", new_id()));
334        let ws = create(&root, Some("orphan"), "app").unwrap();
335        assert!(prune_abandoned(&root, Duration::ZERO, &[])
336            .removed
337            .is_empty());
338        assert!(ws.home.exists());
339        ws.finish().unwrap();
340        let report = prune_abandoned(&root, Duration::ZERO, &[]);
341        assert!(report.retained.is_empty());
342        assert_eq!(report.removed, vec![ws.root]);
343        assert!(!ws.home.exists());
344        fs::remove_dir_all(root).unwrap();
345    }
346
347    #[test]
348    fn prune_spares_young_and_kept_runs() {
349        let root = std::env::temp_dir().join(format!("rkqa-prune-{}", new_id()));
350        let a = storage::create(Some(&root), "x", Some("a"), Duration::from_secs(3600)).unwrap();
351        let _b = storage::create(Some(&root), "x", Some("b"), Duration::from_secs(3600)).unwrap();
352        storage::finish(&a).unwrap();
353        let r = prune_abandoned(&root, Duration::from_secs(3600), &["b".into()]);
354        assert!(r.removed.is_empty());
355        let r = prune_abandoned(&root, Duration::ZERO, &["b".into()]);
356        assert_eq!(r.removed.len(), 1);
357        assert!(root.join("runs/x/b").exists() && !root.join("runs/x/a").exists());
358        let _ = fs::remove_dir_all(root);
359    }
360}