use crate::assert::types::{FileResult, RunResult, StepResult};
use crate::report::redaction::sanitize_assertion;
pub fn render(result: &RunResult) -> String {
let mut xml = String::new();
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
let total = result.total_steps();
let failures = result.failed_steps();
let time_secs = result.duration_ms as f64 / 1000.0;
xml.push_str(&format!(
"<testsuites tests=\"{}\" failures=\"{}\" time=\"{:.3}\">\n",
total, failures, time_secs
));
for file in &result.file_results {
render_file(&mut xml, file);
}
xml.push_str("</testsuites>\n");
xml
}
fn render_file(xml: &mut String, file: &FileResult) {
let total = file.total_steps();
let failures = file.failed_steps();
let time_secs = file.duration_ms as f64 / 1000.0;
xml.push_str(&format!(
" <testsuite name=\"{}\" tests=\"{}\" failures=\"{}\" time=\"{:.3}\">\n",
escape_xml(&file.name),
total,
failures,
time_secs
));
for step in &file.setup_results {
render_test_case(xml, "setup", step, file);
}
for test in &file.test_results {
for step in &test.step_results {
render_test_case(xml, &test.name, step, file);
}
}
for step in &file.teardown_results {
render_test_case(xml, "teardown", step, file);
}
xml.push_str(" </testsuite>\n");
}
fn render_test_case(xml: &mut String, classname: &str, step: &StepResult, file: &FileResult) {
let time_secs = step.duration_ms as f64 / 1000.0;
if step.passed {
xml.push_str(&format!(
" <testcase classname=\"{}\" name=\"{}\" time=\"{:.3}\" />\n",
escape_xml(classname),
escape_xml(&step.name),
time_secs
));
} else {
xml.push_str(&format!(
" <testcase classname=\"{}\" name=\"{}\" time=\"{:.3}\">\n",
escape_xml(classname),
escape_xml(&step.name),
time_secs
));
for failure in step.failures() {
let failure = sanitize_assertion(failure, &file.redaction, &file.redacted_values);
xml.push_str(&format!(
" <failure message=\"{}\" type=\"AssertionFailure\">{}</failure>\n",
escape_xml(&failure.message),
escape_xml(&format!(
"Expected: {}\nActual: {}",
failure.expected, failure.actual
))
));
}
xml.push_str(" </testcase>\n");
}
}
fn escape_xml(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assert::types::*;
use std::collections::HashMap;
fn make_result(passed: bool) -> RunResult {
RunResult {
duration_ms: 500,
file_results: vec![FileResult {
file: "test.tarn.yaml".into(),
name: "Test Suite".into(),
passed,
duration_ms: 500,
redaction: crate::model::RedactionConfig::default(),
redacted_values: vec![],
setup_results: vec![],
test_results: vec![TestResult {
name: "my_test".into(),
description: None,
passed,
duration_ms: 500,
step_results: vec![StepResult {
name: "Check status".into(),
description: None,
debug: false,
passed,
duration_ms: 250,
assertion_results: if passed {
vec![AssertionResult::pass("status", "200", "200")]
} else {
vec![AssertionResult::fail(
"status",
"200",
"404",
"Expected 200, got 404",
)]
},
request_info: None,
response_info: None,
error_category: None,
response_status: None,
response_summary: None,
captures_set: vec![],
location: None,
response_shape_mismatch: None,
}],
captures: HashMap::new(),
}],
teardown_results: vec![],
}],
}
}
#[test]
fn junit_starts_with_xml_header() {
let output = render(&make_result(true));
assert!(output.starts_with("<?xml version=\"1.0\""));
}
#[test]
fn junit_has_testsuites_root() {
let output = render(&make_result(true));
assert!(output.contains("<testsuites"));
assert!(output.contains("</testsuites>"));
}
#[test]
fn junit_has_testsuite_for_file() {
let output = render(&make_result(true));
assert!(output.contains("<testsuite name=\"Test Suite\""));
}
#[test]
fn junit_passing_test_self_closing() {
let output = render(&make_result(true));
assert!(output.contains("testcase classname=\"my_test\" name=\"Check status\""));
assert!(output.contains("/>"));
}
#[test]
fn junit_failing_test_has_failure_element() {
let output = render(&make_result(false));
assert!(output.contains("<failure"));
assert!(output.contains("Expected 200, got 404"));
}
#[test]
fn junit_test_counts() {
let output = render(&make_result(false));
assert!(output.contains("tests=\"1\""));
assert!(output.contains("failures=\"1\""));
}
#[test]
fn junit_time_format() {
let output = render(&make_result(true));
assert!(output.contains("time=\"0.250\"") || output.contains("time=\"0.500\""));
}
#[test]
fn escape_xml_special_chars() {
assert_eq!(
escape_xml("a<b>c&d\"e'f"),
"a<b>c&d"e'f"
);
}
#[test]
fn junit_with_setup_and_teardown() {
let result = RunResult {
duration_ms: 300,
file_results: vec![FileResult {
file: "test.tarn.yaml".into(),
name: "Suite".into(),
passed: true,
duration_ms: 300,
redaction: crate::model::RedactionConfig::default(),
redacted_values: vec![],
setup_results: vec![StepResult {
name: "Auth".into(),
description: None,
debug: false,
passed: true,
duration_ms: 50,
assertion_results: vec![],
request_info: None,
response_info: None,
error_category: None,
response_status: None,
response_summary: None,
captures_set: vec![],
location: None,
response_shape_mismatch: None,
}],
test_results: vec![],
teardown_results: vec![StepResult {
name: "Cleanup".into(),
description: None,
debug: false,
passed: true,
duration_ms: 30,
assertion_results: vec![],
request_info: None,
response_info: None,
error_category: None,
response_status: None,
response_summary: None,
captures_set: vec![],
location: None,
response_shape_mismatch: None,
}],
}],
};
let output = render(&result);
assert!(output.contains("classname=\"setup\""));
assert!(output.contains("classname=\"teardown\""));
}
#[test]
fn junit_redacts_secret_values() {
let result = RunResult {
duration_ms: 10,
file_results: vec![FileResult {
file: "test.tarn.yaml".into(),
name: "Suite".into(),
passed: false,
duration_ms: 10,
redaction: crate::model::RedactionConfig {
replacement: "[hidden]".into(),
..crate::model::RedactionConfig::default()
},
redacted_values: vec!["secret-token".into()],
setup_results: vec![],
test_results: vec![TestResult {
name: "test".into(),
description: None,
passed: false,
duration_ms: 10,
step_results: vec![StepResult {
name: "step".into(),
description: None,
debug: false,
passed: false,
duration_ms: 10,
assertion_results: vec![AssertionResult::fail(
"body",
"secret-token",
"secret-token",
"Expected secret-token",
)],
request_info: None,
response_info: None,
error_category: None,
response_status: None,
response_summary: None,
captures_set: vec![],
location: None,
response_shape_mismatch: None,
}],
captures: HashMap::new(),
}],
teardown_results: vec![],
}],
};
let output = render(&result);
assert!(!output.contains("secret-token"));
assert!(output.contains("[hidden]"));
}
}