cli_testing_specialist/reporter/
junit.rs

1use crate::error::Result;
2use crate::types::{TestReport, TestStatus};
3use std::fs;
4use std::path::Path;
5
6/// JUnit XML report generator
7pub struct JunitReporter;
8
9impl JunitReporter {
10    /// Generate JUnit XML report from test results
11    pub fn generate(report: &TestReport, output_path: &Path) -> Result<()> {
12        let xml = Self::render_xml(report);
13        fs::write(output_path, xml)?;
14        Ok(())
15    }
16
17    /// Render complete JUnit XML document
18    fn render_xml(report: &TestReport) -> String {
19        let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
20        xml.push('\n');
21
22        // Root testsuites element
23        xml.push_str(&format!(
24            r#"<testsuites name="{}" tests="{}" failures="{}" errors="0" skipped="{}" time="{:.3}" timestamp="{}">"#,
25            Self::xml_escape(&report.binary_name),
26            report.total_tests(),
27            report.total_failed(),
28            report.total_skipped(),
29            report.total_duration.as_secs_f64(),
30            report.started_at.to_rfc3339(),
31        ));
32        xml.push('\n');
33
34        // Add properties for environment
35        xml.push_str("  <properties>\n");
36        xml.push_str(&format!(
37            r#"    <property name="os" value="{}"/>"#,
38            Self::xml_escape(&format!(
39                "{} {}",
40                report.environment.os, report.environment.os_version
41            ))
42        ));
43        xml.push('\n');
44        xml.push_str(&format!(
45            r#"    <property name="shell" value="{}"/>"#,
46            Self::xml_escape(&report.environment.shell_version)
47        ));
48        xml.push('\n');
49        xml.push_str(&format!(
50            r#"    <property name="bats_version" value="{}"/>"#,
51            Self::xml_escape(&report.environment.bats_version)
52        ));
53        xml.push('\n');
54        xml.push_str(&format!(
55            r#"    <property name="hostname" value="{}"/>"#,
56            Self::xml_escape(&report.environment.hostname)
57        ));
58        xml.push('\n');
59        if let Some(version) = &report.binary_version {
60            xml.push_str(&format!(
61                r#"    <property name="binary_version" value="{}"/>"#,
62                Self::xml_escape(version)
63            ));
64            xml.push('\n');
65        }
66        xml.push_str("  </properties>\n");
67
68        // Add each test suite
69        for suite in &report.suites {
70            xml.push_str(&Self::render_suite(suite));
71        }
72
73        xml.push_str("</testsuites>\n");
74        xml
75    }
76
77    /// Render a single test suite
78    fn render_suite(suite: &crate::types::TestSuite) -> String {
79        let mut xml = String::new();
80
81        xml.push_str(&format!(
82            r#"  <testsuite name="{}" tests="{}" failures="{}" errors="0" skipped="{}" time="{:.3}" timestamp="{}" file="{}">"#,
83            Self::xml_escape(&suite.name),
84            suite.total_count(),
85            suite.failed_count(),
86            suite.skipped_count(),
87            suite.duration.as_secs_f64(),
88            suite.started_at.to_rfc3339(),
89            Self::xml_escape(&suite.file_path),
90        ));
91        xml.push('\n');
92
93        // Add each test case
94        for test in &suite.tests {
95            xml.push_str(&Self::render_test(test, &suite.name));
96        }
97
98        xml.push_str("  </testsuite>\n");
99        xml
100    }
101
102    /// Render a single test case
103    fn render_test(test: &crate::types::TestResult, suite_name: &str) -> String {
104        let mut xml = String::new();
105
106        xml.push_str(&format!(
107            r#"    <testcase name="{}" classname="{}" time="{:.3}""#,
108            Self::xml_escape(&test.name),
109            Self::xml_escape(suite_name),
110            test.duration.as_secs_f64(),
111        ));
112
113        match test.status {
114            TestStatus::Passed => {
115                xml.push_str("/>\n");
116            }
117            TestStatus::Failed => {
118                xml.push_str(">\n");
119                let error_msg = test
120                    .error_message
121                    .as_deref()
122                    .unwrap_or("Test failed without error message");
123                xml.push_str(&format!(
124                    r#"      <failure message="{}" type="AssertionError">"#,
125                    Self::xml_escape(error_msg)
126                ));
127                xml.push('\n');
128                if !test.output.is_empty() {
129                    xml.push_str(&Self::xml_escape(&test.output));
130                    xml.push('\n');
131                }
132                xml.push_str("      </failure>\n");
133                xml.push_str("    </testcase>\n");
134            }
135            TestStatus::Skipped => {
136                xml.push_str(">\n");
137                xml.push_str(r#"      <skipped/>"#);
138                xml.push('\n');
139                xml.push_str("    </testcase>\n");
140            }
141            TestStatus::Timeout => {
142                xml.push_str(">\n");
143                xml.push_str(r#"      <error message="Test timed out" type="TimeoutError"/>"#);
144                xml.push('\n');
145                xml.push_str("    </testcase>\n");
146            }
147        }
148
149        xml
150    }
151
152    /// Escape XML special characters
153    fn xml_escape(s: &str) -> String {
154        s.replace('&', "&amp;")
155            .replace('<', "&lt;")
156            .replace('>', "&gt;")
157            .replace('"', "&quot;")
158            .replace('\'', "&apos;")
159    }
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use crate::types::{EnvironmentInfo, TestResult, TestSuite};
166    use chrono::Utc;
167    use std::time::Duration;
168    use tempfile::NamedTempFile;
169
170    fn create_test_report() -> TestReport {
171        let suite = TestSuite {
172            name: "test_suite".to_string(),
173            file_path: "/path/to/test.bats".to_string(),
174            tests: vec![
175                TestResult {
176                    name: "successful test".to_string(),
177                    status: TestStatus::Passed,
178                    duration: Duration::from_millis(150),
179                    output: String::new(),
180                    error_message: None,
181                    file_path: "/path/to/test.bats".to_string(),
182                    line_number: Some(5),
183                    tags: vec![],
184                    priority: crate::types::TestPriority::Important,
185                },
186                TestResult {
187                    name: "failed test".to_string(),
188                    status: TestStatus::Failed,
189                    duration: Duration::from_millis(200),
190                    output: "error output".to_string(),
191                    error_message: Some("assertion failed".to_string()),
192                    file_path: "/path/to/test.bats".to_string(),
193                    line_number: Some(10),
194                    tags: vec![],
195                    priority: crate::types::TestPriority::Important,
196                },
197                TestResult {
198                    name: "skipped test".to_string(),
199                    status: TestStatus::Skipped,
200                    duration: Duration::from_millis(0),
201                    output: String::new(),
202                    error_message: None,
203                    file_path: "/path/to/test.bats".to_string(),
204                    line_number: Some(15),
205                    tags: vec![],
206                    priority: crate::types::TestPriority::Important,
207                },
208            ],
209            duration: Duration::from_millis(350),
210            started_at: Utc::now(),
211            finished_at: Utc::now(),
212        };
213
214        TestReport {
215            binary_name: "test-cli".to_string(),
216            binary_version: Some("1.0.0".to_string()),
217            suites: vec![suite],
218            total_duration: Duration::from_millis(350),
219            started_at: Utc::now(),
220            finished_at: Utc::now(),
221            environment: EnvironmentInfo::default(),
222            security_findings: vec![],
223        }
224    }
225
226    #[test]
227    fn test_junit_generation() {
228        let report = create_test_report();
229        let temp_file = NamedTempFile::new().unwrap();
230
231        JunitReporter::generate(&report, temp_file.path()).unwrap();
232
233        let content = fs::read_to_string(temp_file.path()).unwrap();
234
235        // Verify XML structure
236        assert!(content.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
237        assert!(content.contains("<testsuites"));
238        assert!(content.contains("</testsuites>"));
239
240        // Verify test suite
241        assert!(content.contains(r#"<testsuite name="test_suite""#));
242        assert!(content.contains(r#"tests="3""#));
243        assert!(content.contains(r#"failures="1""#));
244        assert!(content.contains(r#"skipped="1""#));
245
246        // Verify test cases
247        assert!(content.contains(r#"<testcase name="successful test""#));
248        assert!(content.contains(r#"<testcase name="failed test""#));
249        assert!(content.contains(r#"<testcase name="skipped test""#));
250
251        // Verify failure element
252        assert!(content.contains("<failure"));
253        assert!(content.contains("assertion failed"));
254
255        // Verify skipped element
256        assert!(content.contains("<skipped/>"));
257
258        // Verify properties
259        assert!(content.contains("<properties>"));
260        assert!(content.contains(r#"<property name="os""#));
261        assert!(content.contains(r#"<property name="bats_version""#));
262    }
263
264    #[test]
265    fn test_xml_escape() {
266        assert_eq!(
267            JunitReporter::xml_escape("Test <xml> & \"quotes\""),
268            "Test &lt;xml&gt; &amp; &quot;quotes&quot;"
269        );
270        assert_eq!(JunitReporter::xml_escape("A & B"), "A &amp; B");
271        assert_eq!(JunitReporter::xml_escape("'single'"), "&apos;single&apos;");
272    }
273
274    #[test]
275    fn test_junit_valid_xml() {
276        let report = create_test_report();
277        let temp_file = NamedTempFile::new().unwrap();
278
279        JunitReporter::generate(&report, temp_file.path()).unwrap();
280
281        let content = fs::read_to_string(temp_file.path()).unwrap();
282
283        // Basic XML well-formedness checks
284        assert_eq!(content.matches("<testsuites").count(), 1);
285        assert_eq!(content.matches("</testsuites>").count(), 1);
286        assert_eq!(content.matches("<testsuite ").count(), 1); // Space ensures we match opening tag only
287        assert_eq!(content.matches("</testsuite>").count(), 1);
288
289        // Count testcase elements (3 tests)
290        assert_eq!(content.matches("<testcase").count(), 3);
291
292        // Verify properties are well-formed
293        let property_count = content.matches("<property").count();
294        assert!(property_count >= 4); // At least os, shell, bats_version, hostname
295    }
296}