1use std::path::PathBuf;
2
3use anyhow::{Context, Result};
4
5use crate::scenario::{Outcome, ScenarioResult};
6
7pub 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#[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
26pub 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
56pub 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
75pub fn save_run_results(results: &[ScenarioResult]) -> Result<()> {
77 for r in results {
78 save_test_result(r)?;
79 }
80 Ok(())
81}
82
83pub 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 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
146pub 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
158pub 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
223fn escape_json(s: &str) -> String {
225 s.replace('\\', "\\\\").replace('"', "\\\"")
226}