Skip to main content

ryra_test/
reports.rs

1use std::path::PathBuf;
2
3use anyhow::{Context, Result};
4
5use crate::scenario::{Outcome, ScenarioResult};
6
7/// Root directory where test reports live: under the host-test sandbox
8/// (`~/.local/share/services-test/reports/`), alongside the service data
9/// and ledger, so the whole test footprint is one folder.
10pub fn reports_dir() -> Result<PathBuf> {
11    crate::test_sandbox_root()
12        .map(|root| root.join("reports"))
13        .context("cannot resolve test sandbox root ($HOME unset)")
14}
15
16/// Per-test result stored as `reports/<name>/result.json`.
17#[derive(Clone, Debug)]
18pub struct TestResult {
19    pub name: String,
20    pub status: String,
21    pub duration_ms: u64,
22    pub timestamp: u64,
23    pub has_playwright: bool,
24}
25
26/// Save one test's result to `reports/<name>/result.json` and its log to
27/// `reports/<name>/run.log`. Previous results for other tests are untouched.
28pub fn save_test_result(result: &ScenarioResult) -> Result<()> {
29    let dir = reports_dir()?;
30    let tdir = dir.join(&result.name);
31    std::fs::create_dir_all(&tdir)?;
32
33    let timestamp = std::time::SystemTime::now()
34        .duration_since(std::time::UNIX_EPOCH)
35        .unwrap_or_default()
36        .as_secs();
37
38    let status = match &result.outcome {
39        Outcome::Passed => "pass",
40        Outcome::Failed(_) => "fail",
41        Outcome::Skipped => "skip",
42    };
43
44    let json = format!(
45        "{{\n  \"name\": \"{}\",\n  \"status\": \"{status}\",\n  \
46         \"duration_ms\": {},\n  \"timestamp\": {timestamp}\n}}\n",
47        escape_json(&result.name),
48        result.duration.as_millis(),
49    );
50    std::fs::write(tdir.join("result.json"), json)?;
51    std::fs::write(tdir.join("run.log"), format!("{result}"))?;
52
53    Ok(())
54}
55
56/// Remove a single test's results (report dir and per-test sandbox).
57pub fn delete_test_result(name: &str) -> Result<()> {
58    let report = reports_dir()?.join(name);
59    if report.is_dir() {
60        std::fs::remove_dir_all(&report)
61            .with_context(|| format!("failed to remove report dir: {}", report.display()))?;
62    }
63
64    if let Some(sandbox) = crate::test_sandbox_root() {
65        let test_dir = sandbox.join("tests").join(name);
66        if test_dir.is_dir() {
67            std::fs::remove_dir_all(&test_dir)
68                .with_context(|| format!("failed to remove sandbox dir: {}", test_dir.display()))?;
69        }
70    }
71
72    Ok(())
73}
74
75/// Save results for a batch of tests.
76pub fn save_run_results(results: &[ScenarioResult]) -> Result<()> {
77    for r in results {
78        save_test_result(r)?;
79    }
80    Ok(())
81}
82
83/// Scan `reports/*/result.json` to discover all stored test results,
84/// the same way services are discovered by scanning directories.
85pub fn scan_results() -> Vec<TestResult> {
86    let dir = match reports_dir() {
87        Ok(d) => d,
88        Err(_) => return Vec::new(),
89    };
90    let entries = match std::fs::read_dir(&dir) {
91        Ok(e) => e,
92        Err(_) => return Vec::new(),
93    };
94
95    let mut results: Vec<TestResult> = entries
96        .filter_map(|e| e.ok())
97        .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false))
98        .filter_map(|e| {
99            let tdir = e.path();
100            let json = std::fs::read_to_string(tdir.join("result.json")).ok()?;
101            parse_result_json(&json, &tdir)
102        })
103        .collect();
104
105    results.sort_by(|a, b| a.name.cmp(&b.name));
106    results
107}
108
109fn parse_result_json(json: &str, tdir: &std::path::Path) -> Option<TestResult> {
110    // Minimal JSON parsing without pulling in serde for this crate.
111    let name = extract_json_str(json, "name")?;
112    let status = extract_json_str(json, "status")?;
113    let duration_ms = extract_json_u64(json, "duration_ms")?;
114    let timestamp = extract_json_u64(json, "timestamp").unwrap_or(0);
115    let has_playwright = tdir.join("playwright").join("index.html").exists();
116    Some(TestResult {
117        name,
118        status,
119        duration_ms,
120        timestamp,
121        has_playwright,
122    })
123}
124
125fn extract_json_str(json: &str, key: &str) -> Option<String> {
126    let needle = format!("\"{key}\"");
127    let pos = json.find(&needle)? + needle.len();
128    let rest = &json[pos..];
129    let start = rest.find('"')? + 1;
130    let end = start + rest[start..].find('"')?;
131    Some(rest[start..end].to_string())
132}
133
134fn extract_json_u64(json: &str, key: &str) -> Option<u64> {
135    let needle = format!("\"{key}\"");
136    let pos = json.find(&needle)? + needle.len();
137    let rest = &json[pos..];
138    let colon = rest.find(':')?;
139    let after = rest[colon + 1..].trim_start();
140    let end = after
141        .find(|c: char| !c.is_ascii_digit())
142        .unwrap_or(after.len());
143    after[..end].parse().ok()
144}
145
146/// Format a duration as a compact human string, e.g. `1091s` -> `18m 11s`.
147pub fn humanize_secs(total: u64) -> String {
148    let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
149    if h > 0 {
150        format!("{h}h {m}m {s}s")
151    } else if m > 0 {
152        format!("{m}m {s}s")
153    } else {
154        format!("{s}s")
155    }
156}
157
158/// Print the end-of-run results summary and point the user at file locations.
159pub fn print_results_paths(results: &[ScenarioResult], wall_clock: std::time::Duration) {
160    let dir = match reports_dir() {
161        Ok(d) => d,
162        Err(_) => return,
163    };
164    let display = tilde_path(&dir);
165
166    let passed = results.iter().filter(|r| r.passed()).count();
167    let failed = results
168        .iter()
169        .filter(|r| matches!(r.outcome, Outcome::Failed(_)))
170        .count();
171    let total = results.len();
172
173    let elapsed = humanize_secs(wall_clock.as_secs());
174    println!("\nResults: {passed}/{total} passed ({failed} failed) in {elapsed}");
175    println!("  dir: {display}/");
176
177    if failed > 0 {
178        println!("\n  Failed ({failed}):");
179        for r in results
180            .iter()
181            .filter(|r| matches!(r.outcome, Outcome::Failed(_)))
182        {
183            println!("    x {} ({:.1}s)", r.name, r.duration.as_secs_f64());
184            if let Some(why) = r.failure_summary() {
185                println!("        {why}");
186            }
187        }
188    }
189
190    for r in results {
191        let status = match &r.outcome {
192            Outcome::Passed => "PASS",
193            Outcome::Failed(_) => "FAIL",
194            Outcome::Skipped => "SKIP",
195        };
196        println!(
197            "\n  {}: {status} ({:.1}s)",
198            r.name,
199            r.duration.as_secs_f64()
200        );
201        println!("    log:     cat {display}/{}/run.log", r.name);
202        let playwright_index = dir.join(&r.name).join("playwright").join("index.html");
203        if playwright_index.exists() {
204            println!(
205                "    browser: cd registry/tests/browser && bunx playwright show-report {display}/{}/playwright",
206                r.name
207            );
208        }
209    }
210}
211
212fn tilde_path(path: &std::path::Path) -> String {
213    let home = std::env::var("HOME").unwrap_or_default();
214    match path.to_str() {
215        Some(s) if !home.is_empty() && s.starts_with(&home) => {
216            format!("~{}", &s[home.len()..])
217        }
218        Some(s) => s.to_string(),
219        None => path.display().to_string(),
220    }
221}
222
223/// Minimal JSON string escaping.
224fn escape_json(s: &str) -> String {
225    s.replace('\\', "\\\\").replace('"', "\\\"")
226}