use std::time::Duration;
use crate::assertions::{CheckResult, VerifyReport};
#[derive(Debug)]
pub struct JunitReport {
pub name: String,
pub duration: Duration,
pub test_cases: Vec<JunitTestCase>,
}
#[derive(Debug)]
pub struct JunitTestCase {
pub name: String,
pub classname: String,
pub duration: Duration,
pub failure: Option<JunitFailure>,
}
#[derive(Debug)]
pub struct JunitFailure {
pub failure_type: String,
pub message: String,
}
impl JunitReport {
#[must_use]
pub fn from_verify_report(report: &VerifyReport, suite_name: &str, duration: Duration) -> Self {
let per_case = if report.results.is_empty() {
Duration::ZERO
} else {
duration / report.results.len() as u32
};
let test_cases = report
.results
.iter()
.map(|r| JunitTestCase::from_check_result(r, suite_name, per_case))
.collect();
Self {
name: suite_name.to_string(),
duration,
test_cases,
}
}
#[must_use]
pub fn to_xml(&self) -> String {
let tests = self.test_cases.len();
let failures = self
.test_cases
.iter()
.filter(|t| t.failure.is_some())
.count();
let time = format_duration(self.duration);
let name = xml_escape(&self.name);
let mut xml = String::with_capacity(1024);
xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
xml.push_str(&format!(
"<testsuite name=\"{name}\" tests=\"{tests}\" failures=\"{failures}\" errors=\"0\" time=\"{time}\">\n"
));
for tc in &self.test_cases {
let tc_name = xml_escape(&tc.name);
let classname = xml_escape(&tc.classname);
let tc_time = format_duration(tc.duration);
if let Some(failure) = &tc.failure {
let ftype = xml_escape(&failure.failure_type);
let msg = xml_escape(&failure.message);
xml.push_str(&format!(
" <testcase name=\"{tc_name}\" classname=\"{classname}\" time=\"{tc_time}\">\n"
));
xml.push_str(&format!(
" <failure type=\"{ftype}\" message=\"{msg}\" />\n"
));
xml.push_str(" </testcase>\n");
} else {
xml.push_str(&format!(
" <testcase name=\"{tc_name}\" classname=\"{classname}\" time=\"{tc_time}\" />\n"
));
}
}
xml.push_str("</testsuite>\n");
xml
}
}
impl JunitTestCase {
#[must_use]
pub fn from_check_result(result: &CheckResult, classname: &str, duration: Duration) -> Self {
let failure = if result.passed {
None
} else {
Some(JunitFailure {
failure_type: "AssertionError".to_string(),
message: result.detail.clone(),
})
};
Self {
name: result.description.clone(),
classname: classname.to_string(),
duration,
failure,
}
}
}
pub fn write_junit_report(report: &JunitReport, path: &std::path::Path) -> std::io::Result<()> {
std::fs::write(path, report.to_xml())
}
fn format_duration(d: Duration) -> String {
format!("{:.3}", d.as_secs_f64())
}
fn xml_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for ch in s.chars() {
match ch {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
other => out.push(other),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn pass_result(desc: &str) -> CheckResult {
CheckResult {
description: desc.to_string(),
passed: true,
detail: String::new(),
}
}
fn fail_result(desc: &str, detail: &str) -> CheckResult {
CheckResult {
description: desc.to_string(),
passed: false,
detail: detail.to_string(),
}
}
#[test]
fn empty_report_produces_valid_xml() {
let report = VerifyReport { results: vec![] };
let junit = JunitReport::from_verify_report(&report, "smoke", Duration::from_millis(100));
let xml = junit.to_xml();
assert!(xml.contains("<?xml version=\"1.0\""));
assert!(xml.contains("tests=\"0\""));
assert!(xml.contains("failures=\"0\""));
assert!(xml.contains("</testsuite>"));
}
#[test]
fn passing_checks_have_no_failure_element() {
let report = VerifyReport {
results: vec![pass_result("ipc healthy"), pass_result("no console errors")],
};
let junit = JunitReport::from_verify_report(&report, "health", Duration::from_secs(1));
let xml = junit.to_xml();
assert!(xml.contains("tests=\"2\""));
assert!(xml.contains("failures=\"0\""));
assert!(!xml.contains("<failure"));
assert!(xml.contains("ipc healthy"));
assert!(xml.contains("no console errors"));
}
#[test]
fn failing_check_includes_failure_element() {
let report = VerifyReport {
results: vec![
pass_result("connected"),
fail_result("no ghost commands", "found 3 ghost commands"),
],
};
let junit = JunitReport::from_verify_report(&report, "ghosts", Duration::from_secs(2));
let xml = junit.to_xml();
assert!(xml.contains("tests=\"2\""));
assert!(xml.contains("failures=\"1\""));
assert!(xml.contains("<failure type=\"AssertionError\""));
assert!(xml.contains("found 3 ghost commands"));
}
#[test]
fn xml_escapes_special_chars() {
let report = VerifyReport {
results: vec![fail_result("value <\"test\"> & 'check'", "a & b < c > d")],
};
let junit = JunitReport::from_verify_report(&report, "escape", Duration::from_millis(50));
let xml = junit.to_xml();
assert!(xml.contains("<"));
assert!(xml.contains(">"));
assert!(xml.contains("&"));
assert!(xml.contains("""));
assert!(xml.contains("'"));
}
#[test]
fn from_verify_report_distributes_duration() {
let report = VerifyReport {
results: vec![pass_result("a"), pass_result("b")],
};
let junit = JunitReport::from_verify_report(&report, "suite", Duration::from_secs(4));
assert_eq!(junit.test_cases.len(), 2);
assert_eq!(junit.test_cases[0].duration, Duration::from_secs(2));
assert_eq!(junit.test_cases[1].duration, Duration::from_secs(2));
}
#[test]
fn write_junit_report_creates_file() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("results.xml");
let report = VerifyReport {
results: vec![pass_result("works")],
};
let junit = JunitReport::from_verify_report(&report, "test", Duration::from_millis(100));
write_junit_report(&junit, &path).unwrap();
let content = std::fs::read_to_string(&path).unwrap();
assert!(content.contains("<?xml version=\"1.0\""));
assert!(content.contains("works"));
}
}