1use std::fs;
2use std::path::Path;
3use std::time::Duration;
4
5use serde::Deserialize;
6use serde::Serialize;
7
8use crate::report::result::Outcome;
9use crate::report::result::TestResult;
10
11#[derive(Debug, Serialize, Deserialize)]
12pub struct RunSummary {
13 pub run: RunMeta,
14 pub tests: Vec<TestEntry>,
15}
16
17#[derive(Debug, Serialize, Deserialize)]
18pub struct RunMeta {
19 pub run_id: String,
20 pub timestamp: String,
21 pub duration_ms: u64,
22 pub hostname: String,
23}
24
25#[derive(Debug, Serialize, Deserialize)]
26pub struct TestEntry {
27 pub name: String,
28 pub path: String,
29 pub outcome: String,
30 pub duration_ms: u64,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub log_dir: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub failure_type: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub failure_summary: Option<String>,
41 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub cancellation_reason: Option<String>,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub cancellation_detail: Option<String>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub skip_reason: Option<String>,
47 #[serde(default)]
48 pub flaky_retries: u32,
49}
50
51pub fn write_run_summary(
52 run_dir: &Path,
53 run_id: &str,
54 results: &[TestResult],
55 total_duration: Duration,
56) {
57 let summary = build_summary(run_id, results, total_duration, run_dir);
58 let toml_string = toml::to_string_pretty(&summary).expect("failed to serialize run summary");
59 let path = run_dir.join("run_summary.toml");
60 let _ = fs::write(path, toml_string);
61}
62
63pub fn read_run_summary(run_dir: &Path) -> Result<RunSummary, String> {
64 let path = run_dir.join("run_summary.toml");
65 let content =
66 fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
67 toml::from_str(&content).map_err(|e| format!("cannot parse {}: {e}", path.display()))
68}
69
70pub fn failed_test_ids(summary: &RunSummary) -> Vec<(&str, &str)> {
72 summary
73 .tests
74 .iter()
75 .filter(|t| t.outcome == "fail")
76 .map(|t| (t.path.as_str(), t.name.as_str()))
77 .collect()
78}
79
80pub fn nonzero_test_ids(summary: &RunSummary) -> Vec<(&str, &str)> {
83 summary
84 .tests
85 .iter()
86 .filter(|t| t.outcome == "fail" || t.outcome == "cancelled" || t.outcome == "invalid")
87 .map(|t| (t.path.as_str(), t.name.as_str()))
88 .collect()
89}
90
91fn build_summary(
92 run_id: &str,
93 results: &[TestResult],
94 total_duration: Duration,
95 run_dir: &Path,
96) -> RunSummary {
97 let hostname = std::env::var("HOSTNAME")
98 .or_else(|_| std::env::var("HOST"))
99 .unwrap_or_else(|_| "unknown".into());
100
101 let run = RunMeta {
102 run_id: run_id.to_string(),
103 timestamp: chrono::Utc::now().to_rfc3339(),
104 duration_ms: total_duration.as_millis() as u64,
105 hostname,
106 };
107
108 let tests = results
109 .iter()
110 .map(|r| {
111 let (
112 outcome,
113 failure_type,
114 failure_summary,
115 cancellation_reason,
116 cancellation_detail,
117 skip_reason,
118 ) = match &r.outcome {
119 Outcome::Pass => ("pass".to_string(), None, None, None, None, None),
120 Outcome::Fail(f) => (
121 "fail".to_string(),
122 Some(f.failure_type().to_string()),
123 Some(f.summary()),
124 None,
125 None,
126 None,
127 ),
128 Outcome::Cancelled(c) => {
129 use crate::cancel::CancelReason;
130 let detail = match &c.reason {
131 CancelReason::TestTimeout { duration } => {
132 Some(format!("duration_ms={}", duration.as_millis()))
133 }
134 CancelReason::SuiteTimeout { duration } => {
135 Some(format!("duration_ms={}", duration.as_millis()))
136 }
137 CancelReason::FailFast { trigger_test } => {
138 Some(format!("trigger_test={trigger_test}"))
139 }
140 CancelReason::Sigint => None,
141 };
142 (
143 "cancelled".to_string(),
144 None,
145 None,
146 Some(c.reason_tag().to_string()),
147 detail,
148 None,
149 )
150 }
151 Outcome::Skipped(reason) => (
152 "skipped".to_string(),
153 None,
154 None,
155 None,
156 None,
157 Some(reason.clone()),
158 ),
159 Outcome::Invalid(reason) => (
160 "invalid".to_string(),
161 None,
162 None,
163 None,
164 None,
165 Some(reason.clone()),
166 ),
167 };
168
169 let log_dir = r
170 .log_dir
171 .as_ref()
172 .and_then(|d| d.strip_prefix(run_dir).ok())
173 .map(|rel| rel.display().to_string());
174
175 TestEntry {
176 name: r.test_name.clone(),
177 path: r.test_path.clone(),
178 outcome,
179 duration_ms: r.duration.as_millis() as u64,
180 log_dir,
181 failure_type,
182 failure_summary,
183 cancellation_reason,
184 cancellation_detail,
185 skip_reason,
186 flaky_retries: r.flaky_retries,
187 }
188 })
189 .collect();
190
191 RunSummary { run, tests }
192}
193
194#[cfg(test)]
195mod tests {
196 use super::*;
197 use crate::report::result::Failure;
198 use crate::report::result::FailureContext;
199 use relux_core::diagnostics::IrSpan;
200
201 fn make_result(name: &str, path: &str, outcome: Outcome) -> TestResult {
202 TestResult {
203 test_name: name.into(),
204 test_path: path.into(),
205 outcome,
206 duration: Duration::from_millis(100),
207
208 progress: String::new(),
209 log_dir: None,
210 warnings: Vec::new(),
211 flaky_retries: 0,
212 }
213 }
214
215 fn with_log_dir(mut result: TestResult, log_dir: &str) -> TestResult {
216 result.log_dir = Some(std::path::PathBuf::from(log_dir));
217 result
218 }
219
220 #[test]
221 fn round_trip_serialization() {
222 let results = vec![
223 with_log_dir(
224 make_result("passes", "basic/pass.relux", Outcome::Pass),
225 "/tmp/runs/test-run-id/logs/basic/pass/passes",
226 ),
227 make_result(
228 "fails",
229 "basic/fail.relux",
230 Outcome::Fail(Failure::MatchTimeout {
231 pattern: "/ready/".into(),
232 shell: "default".into(),
233 span: IrSpan::synthetic(),
234 effective: Box::new(relux_ir::IrTimeout::tolerance(Duration::from_secs(5))),
235 context: FailureContext::pre_vm(),
236 }),
237 ),
238 make_result(
239 "skipped",
240 "basic/skip.relux",
241 Outcome::Skipped("os:linux".into()),
242 ),
243 ];
244
245 let summary = build_summary(
246 "test-run-id",
247 &results,
248 Duration::from_secs(1),
249 Path::new("/tmp/runs/test-run-id"),
250 );
251 let toml_str = toml::to_string_pretty(&summary).unwrap();
252 let parsed: RunSummary = toml::from_str(&toml_str).unwrap();
253
254 assert_eq!(parsed.run.run_id, "test-run-id");
255 assert_eq!(parsed.run.duration_ms, 1000);
256 assert_eq!(parsed.tests.len(), 3);
257
258 assert_eq!(parsed.tests[0].outcome, "pass");
259 assert!(parsed.tests[0].failure_type.is_none());
260 assert_eq!(
261 parsed.tests[0].log_dir.as_deref(),
262 Some("logs/basic/pass/passes"),
263 );
264
265 assert_eq!(parsed.tests[1].outcome, "fail");
266 assert_eq!(
267 parsed.tests[1].failure_type.as_deref(),
268 Some("MatchTimeout")
269 );
270 assert!(parsed.tests[1].failure_summary.is_some());
271 assert!(parsed.tests[1].log_dir.is_none());
274
275 assert_eq!(parsed.tests[2].outcome, "skipped");
276 assert_eq!(parsed.tests[2].skip_reason.as_deref(), Some("os:linux"));
277 }
278
279 #[test]
280 fn failed_test_ids_filters_correctly() {
281 let results = vec![
282 make_result("passes", "basic/pass.relux", Outcome::Pass),
283 make_result(
284 "fails",
285 "basic/fail.relux",
286 Outcome::Fail(Failure::Runtime {
287 message: "boom".into(),
288 span: IrSpan::synthetic(),
289 shell: None,
290 context: FailureContext::pre_vm(),
291 }),
292 ),
293 make_result(
294 "also fails",
295 "basic/fail2.relux",
296 Outcome::Fail(Failure::Runtime {
297 message: "boom2".into(),
298 span: IrSpan::synthetic(),
299 shell: None,
300 context: FailureContext::pre_vm(),
301 }),
302 ),
303 make_result(
304 "skipped",
305 "basic/skip.relux",
306 Outcome::Skipped("reason".into()),
307 ),
308 ];
309
310 let summary = build_summary(
311 "run-1",
312 &results,
313 Duration::from_secs(2),
314 Path::new("/tmp/runs/run-1"),
315 );
316 let failed = failed_test_ids(&summary);
317
318 assert_eq!(failed.len(), 2);
319 assert_eq!(failed[0], ("basic/fail.relux", "fails"));
320 assert_eq!(failed[1], ("basic/fail2.relux", "also fails"));
321 }
322}