cli_testing_specialist/reporter/
junit.rs1use crate::error::Result;
2use crate::types::{TestReport, TestStatus};
3use std::fs;
4use std::path::Path;
5
6pub struct JunitReporter;
8
9impl JunitReporter {
10 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 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 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 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 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 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 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 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 fn xml_escape(s: &str) -> String {
154 s.replace('&', "&")
155 .replace('<', "<")
156 .replace('>', ">")
157 .replace('"', """)
158 .replace('\'', "'")
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 assert!(content.starts_with(r#"<?xml version="1.0" encoding="UTF-8"?>"#));
237 assert!(content.contains("<testsuites"));
238 assert!(content.contains("</testsuites>"));
239
240 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 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 assert!(content.contains("<failure"));
253 assert!(content.contains("assertion failed"));
254
255 assert!(content.contains("<skipped/>"));
257
258 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 <xml> & "quotes""
269 );
270 assert_eq!(JunitReporter::xml_escape("A & B"), "A & B");
271 assert_eq!(JunitReporter::xml_escape("'single'"), "'single'");
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 assert_eq!(content.matches("<testsuites").count(), 1);
285 assert_eq!(content.matches("</testsuites>").count(), 1);
286 assert_eq!(content.matches("<testsuite ").count(), 1); assert_eq!(content.matches("</testsuite>").count(), 1);
288
289 assert_eq!(content.matches("<testcase").count(), 3);
291
292 let property_count = content.matches("<property").count();
294 assert!(property_count >= 4); }
296}