api_testing_core/suite/
junit.rs1use std::path::Path;
2
3use anyhow::Context;
4
5use crate::Result;
6use crate::suite::results::SuiteRunResults;
7
8fn xml_escape(s: &str) -> String {
9 s.replace('&', "&")
10 .replace('<', "<")
11 .replace('>', ">")
12 .replace('"', """)
13 .replace('\'', "'")
14}
15
16pub fn render_junit_xml(results: &SuiteRunResults) -> String {
17 let suite_name = if results.suite.trim().is_empty() {
18 "suite".to_string()
19 } else {
20 results.suite.clone()
21 };
22
23 let mut out = String::new();
24 out.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
25 out.push_str(&format!(
26 "<testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" skipped=\"{}\">\n",
27 xml_escape(&suite_name),
28 results.summary.total,
29 results.summary.failed,
30 results.summary.skipped
31 ));
32
33 for case in &results.cases {
34 let seconds = (case.duration_ms as f64) / 1000.0;
35 out.push_str(&format!(
36 " <testcase name=\"{}\" classname=\"{}\" time=\"{:.3}\">",
37 xml_escape(&case.id),
38 xml_escape(&case.case_type),
39 seconds
40 ));
41
42 let message = case.message.clone().unwrap_or_default();
43 let message_trim = message.trim();
44 match case.status.as_str() {
45 "skipped" => {
46 out.push_str(&format!(
47 "<skipped message=\"{}\"/>",
48 xml_escape(message_trim)
49 ));
50 }
51 "failed" => {
52 let failure_message = if message_trim.is_empty() {
53 "failed"
54 } else {
55 message_trim
56 };
57 out.push_str(&format!(
58 "<failure message=\"{}\">",
59 xml_escape(failure_message)
60 ));
61 let mut detail = String::new();
62 if let Some(cmd) = &case.command {
63 detail.push_str(&format!("command: {cmd}\n"));
64 }
65 if let Some(p) = &case.stdout_file {
66 detail.push_str(&format!("stdoutFile: {p}\n"));
67 }
68 if let Some(p) = &case.stderr_file {
69 detail.push_str(&format!("stderrFile: {p}\n"));
70 }
71 out.push_str(&xml_escape(&detail));
72 out.push_str("</failure>");
73 }
74 _ => {}
75 }
76
77 out.push_str("</testcase>\n");
78 }
79
80 out.push_str("</testsuite>\n");
81 out
82}
83
84pub fn write_junit_file(results: &SuiteRunResults, path: &Path) -> Result<()> {
85 let xml = render_junit_xml(results);
86 let Some(parent) = path.parent() else {
87 anyhow::bail!("invalid junit path: {}", path.display());
88 };
89 std::fs::create_dir_all(parent)
90 .with_context(|| format!("create directory: {}", parent.display()))?;
91 std::fs::write(path, xml).with_context(|| format!("write junit file: {}", path.display()))?;
92 Ok(())
93}
94
95#[cfg(test)]
96mod tests {
97 use super::*;
98
99 #[test]
100 fn junit_emits_basic_structure() {
101 let results = SuiteRunResults {
102 version: 1,
103 suite: "smoke".to_string(),
104 suite_file: "tests/api/suites/smoke.suite.json".to_string(),
105 run_id: "20260131-000000Z".to_string(),
106 started_at: "2026-01-31T00:00:00Z".to_string(),
107 finished_at: "2026-01-31T00:00:01Z".to_string(),
108 output_dir: "out/api-test-runner/20260131-000000Z".to_string(),
109 summary: crate::suite::results::SuiteRunSummary {
110 total: 1,
111 passed: 0,
112 failed: 1,
113 skipped: 0,
114 },
115 cases: vec![crate::suite::results::SuiteCaseResult {
116 id: "case".to_string(),
117 case_type: "rest".to_string(),
118 status: "failed".to_string(),
119 duration_ms: 10,
120 tags: vec![],
121 command: Some("api-rest call".to_string()),
122 message: Some("rest_runner_failed".to_string()),
123 assertions: None,
124 stdout_file: Some("out/x.response.json".to_string()),
125 stderr_file: Some("out/x.stderr.log".to_string()),
126 }],
127 };
128
129 let xml = render_junit_xml(&results);
130 assert!(xml.contains("<testsuite"));
131 assert!(xml.contains("<testcase"));
132 assert!(xml.contains("<failure"));
133 }
134}