Skip to main content

victauri_test/
reporting.rs

1//! `JUnit` XML report generation for CI integration.
2//!
3//! Converts [`VerifyReport`] results into `JUnit` XML format compatible with
4//! GitHub Actions, GitLab CI, Jenkins, and other CI systems.
5
6use std::time::Duration;
7
8use crate::assertions::{CheckResult, VerifyReport};
9
10/// A complete `JUnit` XML test suite for serialization.
11#[derive(Debug)]
12pub struct JunitReport {
13    /// Name of the test suite (defaults to "victauri").
14    pub name: String,
15    /// Total wall-clock duration of the suite.
16    pub duration: Duration,
17    /// Individual test results.
18    pub test_cases: Vec<JunitTestCase>,
19}
20
21/// A single test case within a `JUnit` report.
22#[derive(Debug)]
23pub struct JunitTestCase {
24    /// Name of the test case.
25    pub name: String,
26    /// Name of the class/suite this case belongs to.
27    pub classname: String,
28    /// Duration of this specific test.
29    pub duration: Duration,
30    /// Failure message, if the test failed.
31    pub failure: Option<JunitFailure>,
32}
33
34/// Failure details for a `JUnit` test case.
35#[derive(Debug)]
36pub struct JunitFailure {
37    /// Failure type label.
38    pub failure_type: String,
39    /// Human-readable failure message.
40    pub message: String,
41}
42
43impl JunitReport {
44    /// Creates a report from a [`VerifyReport`] with the given suite name and duration.
45    #[must_use]
46    pub fn from_verify_report(report: &VerifyReport, suite_name: &str, duration: Duration) -> Self {
47        let per_case = if report.results.is_empty() {
48            Duration::ZERO
49        } else {
50            duration / report.results.len() as u32
51        };
52
53        let test_cases = report
54            .results
55            .iter()
56            .map(|r| JunitTestCase::from_check_result(r, suite_name, per_case))
57            .collect();
58
59        Self {
60            name: suite_name.to_string(),
61            duration,
62            test_cases,
63        }
64    }
65
66    /// Renders the report as a `JUnit` XML string.
67    #[must_use]
68    pub fn to_xml(&self) -> String {
69        let tests = self.test_cases.len();
70        let failures = self
71            .test_cases
72            .iter()
73            .filter(|t| t.failure.is_some())
74            .count();
75        let time = format_duration(self.duration);
76        let name = xml_escape(&self.name);
77
78        let mut xml = String::with_capacity(1024);
79        xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
80        xml.push_str(&format!(
81            "<testsuite name=\"{name}\" tests=\"{tests}\" failures=\"{failures}\" errors=\"0\" time=\"{time}\">\n"
82        ));
83
84        for tc in &self.test_cases {
85            let tc_name = xml_escape(&tc.name);
86            let classname = xml_escape(&tc.classname);
87            let tc_time = format_duration(tc.duration);
88
89            if let Some(failure) = &tc.failure {
90                let ftype = xml_escape(&failure.failure_type);
91                let msg = xml_escape(&failure.message);
92                xml.push_str(&format!(
93                    "  <testcase name=\"{tc_name}\" classname=\"{classname}\" time=\"{tc_time}\">\n"
94                ));
95                xml.push_str(&format!(
96                    "    <failure type=\"{ftype}\" message=\"{msg}\" />\n"
97                ));
98                xml.push_str("  </testcase>\n");
99            } else {
100                xml.push_str(&format!(
101                    "  <testcase name=\"{tc_name}\" classname=\"{classname}\" time=\"{tc_time}\" />\n"
102                ));
103            }
104        }
105
106        xml.push_str("</testsuite>\n");
107        xml
108    }
109}
110
111impl JunitTestCase {
112    /// Creates a test case from a [`CheckResult`].
113    #[must_use]
114    pub fn from_check_result(result: &CheckResult, classname: &str, duration: Duration) -> Self {
115        let failure = if result.passed {
116            None
117        } else {
118            Some(JunitFailure {
119                failure_type: "AssertionError".to_string(),
120                message: result.detail.clone(),
121            })
122        };
123
124        Self {
125            name: result.description.clone(),
126            classname: classname.to_string(),
127            duration,
128            failure,
129        }
130    }
131}
132
133/// Writes a `JUnit` XML report to disk.
134///
135/// # Errors
136///
137/// Returns an IO error if the file cannot be written.
138pub fn write_junit_report(report: &JunitReport, path: &std::path::Path) -> std::io::Result<()> {
139    std::fs::write(path, report.to_xml())
140}
141
142fn format_duration(d: Duration) -> String {
143    format!("{:.3}", d.as_secs_f64())
144}
145
146fn xml_escape(s: &str) -> String {
147    let mut out = String::with_capacity(s.len());
148    for ch in s.chars() {
149        match ch {
150            '&' => out.push_str("&amp;"),
151            '<' => out.push_str("&lt;"),
152            '>' => out.push_str("&gt;"),
153            '"' => out.push_str("&quot;"),
154            '\'' => out.push_str("&apos;"),
155            other => out.push(other),
156        }
157    }
158    out
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn pass_result(desc: &str) -> CheckResult {
166        CheckResult {
167            description: desc.to_string(),
168            passed: true,
169            detail: String::new(),
170        }
171    }
172
173    fn fail_result(desc: &str, detail: &str) -> CheckResult {
174        CheckResult {
175            description: desc.to_string(),
176            passed: false,
177            detail: detail.to_string(),
178        }
179    }
180
181    #[test]
182    fn empty_report_produces_valid_xml() {
183        let report = VerifyReport { results: vec![] };
184        let junit = JunitReport::from_verify_report(&report, "smoke", Duration::from_millis(100));
185        let xml = junit.to_xml();
186
187        assert!(xml.contains("<?xml version=\"1.0\""));
188        assert!(xml.contains("tests=\"0\""));
189        assert!(xml.contains("failures=\"0\""));
190        assert!(xml.contains("</testsuite>"));
191    }
192
193    #[test]
194    fn passing_checks_have_no_failure_element() {
195        let report = VerifyReport {
196            results: vec![pass_result("ipc healthy"), pass_result("no console errors")],
197        };
198        let junit = JunitReport::from_verify_report(&report, "health", Duration::from_secs(1));
199        let xml = junit.to_xml();
200
201        assert!(xml.contains("tests=\"2\""));
202        assert!(xml.contains("failures=\"0\""));
203        assert!(!xml.contains("<failure"));
204        assert!(xml.contains("ipc healthy"));
205        assert!(xml.contains("no console errors"));
206    }
207
208    #[test]
209    fn failing_check_includes_failure_element() {
210        let report = VerifyReport {
211            results: vec![
212                pass_result("connected"),
213                fail_result("no ghost commands", "found 3 ghost commands"),
214            ],
215        };
216        let junit = JunitReport::from_verify_report(&report, "ghosts", Duration::from_secs(2));
217        let xml = junit.to_xml();
218
219        assert!(xml.contains("tests=\"2\""));
220        assert!(xml.contains("failures=\"1\""));
221        assert!(xml.contains("<failure type=\"AssertionError\""));
222        assert!(xml.contains("found 3 ghost commands"));
223    }
224
225    #[test]
226    fn xml_escapes_special_chars() {
227        let report = VerifyReport {
228            results: vec![fail_result("value <\"test\"> & 'check'", "a & b < c > d")],
229        };
230        let junit = JunitReport::from_verify_report(&report, "escape", Duration::from_millis(50));
231        let xml = junit.to_xml();
232
233        assert!(xml.contains("&lt;"));
234        assert!(xml.contains("&gt;"));
235        assert!(xml.contains("&amp;"));
236        assert!(xml.contains("&quot;"));
237        assert!(xml.contains("&apos;"));
238    }
239
240    #[test]
241    fn from_verify_report_distributes_duration() {
242        let report = VerifyReport {
243            results: vec![pass_result("a"), pass_result("b")],
244        };
245        let junit = JunitReport::from_verify_report(&report, "suite", Duration::from_secs(4));
246
247        assert_eq!(junit.test_cases.len(), 2);
248        assert_eq!(junit.test_cases[0].duration, Duration::from_secs(2));
249        assert_eq!(junit.test_cases[1].duration, Duration::from_secs(2));
250    }
251
252    #[test]
253    fn write_junit_report_creates_file() {
254        let dir = tempfile::tempdir().unwrap();
255        let path = dir.path().join("results.xml");
256
257        let report = VerifyReport {
258            results: vec![pass_result("works")],
259        };
260        let junit = JunitReport::from_verify_report(&report, "test", Duration::from_millis(100));
261        write_junit_report(&junit, &path).unwrap();
262
263        let content = std::fs::read_to_string(&path).unwrap();
264        assert!(content.contains("<?xml version=\"1.0\""));
265        assert!(content.contains("works"));
266    }
267}