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    #[serde(skip_serializing_if = "Option::is_none")]
32    pub failure_type: Option<String>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub failure_summary: Option<String>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub skip_reason: Option<String>,
37    #[serde(default)]
38    pub flaky_retries: u32,
39}
40
41pub fn write_run_summary(
42    run_dir: &Path,
43    run_id: &str,
44    results: &[TestResult],
45    total_duration: Duration,
46) {
47    let summary = build_summary(run_id, results, total_duration);
48    let toml_string = toml::to_string_pretty(&summary).expect("failed to serialize run summary");
49    let path = run_dir.join("run_summary.toml");
50    let _ = fs::write(path, toml_string);
51}
52
53pub fn read_run_summary(run_dir: &Path) -> Result<RunSummary, String> {
54    let path = run_dir.join("run_summary.toml");
55    let content =
56        fs::read_to_string(&path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
57    toml::from_str(&content).map_err(|e| format!("cannot parse {}: {e}", path.display()))
58}
59
60/// Returns `(path, name)` pairs for all failed tests.
61pub fn failed_test_ids(summary: &RunSummary) -> Vec<(&str, &str)> {
62    summary
63        .tests
64        .iter()
65        .filter(|t| t.outcome == "fail")
66        .map(|t| (t.path.as_str(), t.name.as_str()))
67        .collect()
68}
69
70fn build_summary(run_id: &str, results: &[TestResult], total_duration: Duration) -> RunSummary {
71    let hostname = std::env::var("HOSTNAME")
72        .or_else(|_| std::env::var("HOST"))
73        .unwrap_or_else(|_| "unknown".into());
74
75    let run = RunMeta {
76        run_id: run_id.to_string(),
77        timestamp: chrono::Utc::now().to_rfc3339(),
78        duration_ms: total_duration.as_millis() as u64,
79        hostname,
80    };
81
82    let tests = results
83        .iter()
84        .map(|r| {
85            let (outcome, failure_type, failure_summary, skip_reason) = match &r.outcome {
86                Outcome::Pass => ("pass".to_string(), None, None, None),
87                Outcome::Fail(f) => (
88                    "fail".to_string(),
89                    Some(f.failure_type().to_string()),
90                    Some(f.summary()),
91                    None,
92                ),
93                Outcome::Skipped(reason) => {
94                    ("skipped".to_string(), None, None, Some(reason.clone()))
95                }
96                Outcome::Invalid(reason) => {
97                    ("invalid".to_string(), None, None, Some(reason.clone()))
98                }
99            };
100
101            TestEntry {
102                name: r.test_name.clone(),
103                path: r.test_path.clone(),
104                outcome,
105                duration_ms: r.duration.as_millis() as u64,
106                failure_type,
107                failure_summary,
108                skip_reason,
109                flaky_retries: r.flaky_retries,
110            }
111        })
112        .collect();
113
114    RunSummary { run, tests }
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use crate::report::result::Failure;
121    use relux_core::diagnostics::IrSpan;
122
123    fn make_result(name: &str, path: &str, outcome: Outcome) -> TestResult {
124        TestResult {
125            test_name: name.into(),
126            test_path: path.into(),
127            outcome,
128            duration: Duration::from_millis(100),
129
130            progress: String::new(),
131            log_dir: None,
132            warnings: Vec::new(),
133            flaky_retries: 0,
134        }
135    }
136
137    #[test]
138    fn round_trip_serialization() {
139        let results = vec![
140            make_result("passes", "basic/pass.relux", Outcome::Pass),
141            make_result(
142                "fails",
143                "basic/fail.relux",
144                Outcome::Fail(Failure::MatchTimeout {
145                    pattern: "/ready/".into(),
146                    shell: "default".into(),
147                    span: IrSpan::synthetic(),
148                }),
149            ),
150            make_result(
151                "skipped",
152                "basic/skip.relux",
153                Outcome::Skipped("os:linux".into()),
154            ),
155        ];
156
157        let summary = build_summary("test-run-id", &results, Duration::from_secs(1));
158        let toml_str = toml::to_string_pretty(&summary).unwrap();
159        let parsed: RunSummary = toml::from_str(&toml_str).unwrap();
160
161        assert_eq!(parsed.run.run_id, "test-run-id");
162        assert_eq!(parsed.run.duration_ms, 1000);
163        assert_eq!(parsed.tests.len(), 3);
164
165        assert_eq!(parsed.tests[0].outcome, "pass");
166        assert!(parsed.tests[0].failure_type.is_none());
167
168        assert_eq!(parsed.tests[1].outcome, "fail");
169        assert_eq!(
170            parsed.tests[1].failure_type.as_deref(),
171            Some("MatchTimeout")
172        );
173        assert!(parsed.tests[1].failure_summary.is_some());
174
175        assert_eq!(parsed.tests[2].outcome, "skipped");
176        assert_eq!(parsed.tests[2].skip_reason.as_deref(), Some("os:linux"));
177    }
178
179    #[test]
180    fn failed_test_ids_filters_correctly() {
181        let results = vec![
182            make_result("passes", "basic/pass.relux", Outcome::Pass),
183            make_result(
184                "fails",
185                "basic/fail.relux",
186                Outcome::Fail(Failure::Runtime {
187                    message: "boom".into(),
188                    span: None,
189                    shell: None,
190                }),
191            ),
192            make_result(
193                "also fails",
194                "basic/fail2.relux",
195                Outcome::Fail(Failure::Runtime {
196                    message: "boom2".into(),
197                    span: None,
198                    shell: None,
199                }),
200            ),
201            make_result(
202                "skipped",
203                "basic/skip.relux",
204                Outcome::Skipped("reason".into()),
205            ),
206        ];
207
208        let summary = build_summary("run-1", &results, Duration::from_secs(2));
209        let failed = failed_test_ids(&summary);
210
211        assert_eq!(failed.len(), 2);
212        assert_eq!(failed[0], ("basic/fail.relux", "fails"));
213        assert_eq!(failed[1], ("basic/fail2.relux", "also fails"));
214    }
215}