Skip to main content

relux_runtime/report/
run_summary.rs

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    /// Per-test log directory, relative to the run directory. When set,
32    /// consumers can concatenate `<log_dir>/events.json` or
33    /// `<log_dir>/event.html`. Absent when the runtime did not produce a
34    /// log directory for the test (e.g. some invalid-test paths).
35    #[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
70/// Returns `(path, name)` pairs for all failed tests.
71pub 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
80/// Returns `(path, name)` pairs for all tests with a nonzero outcome
81/// (`fail`, `cancelled`, or `invalid`).
82pub 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        // `make_result` leaves `log_dir` unset; `skip_serializing_if`
272        // means the parsed entry preserves `None`.
273        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}