diff --git a/tests/src/lib.rs b/tests/src/lib.rs
index 3368dc49b..d648e8617 100644
--- a/tests/src/lib.rs
+++ b/tests/src/lib.rs
@@ -15,7 +15,7 @@ pub use backend::MockLLMBackend;
pub use bus::MockAgentBus;
pub use clock::{Clock, MockClock, SystemClock};
pub use report::{
- JsonFormatter, ReportFormatter, TestCaseResult, TestReport, TestReportBuilder, TestStatus,
+ JUnitFormatter, JsonFormatter, ReportFormatter, TestCaseResult, TestReport, TestReportBuilder, TestStatus,
TextFormatter,
};
pub use tools::MockTool;
diff --git a/tests/src/report/format.rs b/tests/src/report/format.rs
index 919cae59d..b134e67c6 100644
--- a/tests/src/report/format.rs
+++ b/tests/src/report/format.rs
@@ -102,3 +102,224 @@ impl ReportFormatter for TextFormatter {
buf
}
}
+
+fn escape_xml(input: &str) -> String {
+ let mut escaped = String::with_capacity(input.len());
+ for c in input.chars() {
+ match c {
+ '<' => escaped.push_str("<"),
+ '>' => escaped.push_str(">"),
+ '&' => escaped.push_str("&"),
+ '"' => escaped.push_str("""),
+ '\'' => escaped.push_str("'"),
+ c if c == '\u{0009}' || c == '\u{000A}' || c == '\u{000D}' => escaped.push(c),
+ c if c < '\u{0020}' => escaped.push_str("\u{FFFD}"), // Replace invalid control chars
+ _ => escaped.push(c),
+ }
+ }
+ escaped
+}
+
+/// Renders a report as a JUnit XML document.
+pub struct JUnitFormatter;
+
+impl ReportFormatter for JUnitFormatter {
+ fn format(&self, report: &TestReport) -> String {
+ let mut buf = String::new();
+ let total = report.total();
+ let failures = report.failed();
+ let skipped = report.skipped();
+ let time = report.total_duration.as_secs_f64();
+
+ buf.push_str(&format!(
+ "\n",
+ total, failures, skipped, time
+ ));
+
+ for r in &report.results {
+ let name = escape_xml(&r.name);
+ let time = r.duration.as_secs_f64();
+
+ buf.push_str(&format!(
+ " {
+ buf.push_str("/>\n");
+ }
+ TestStatus::Skipped => {
+ buf.push_str(">\n \n \n");
+ }
+ TestStatus::Failed => {
+ buf.push_str(">\n");
+ let (msg_attr, body) = match &r.error {
+ Some(err) => {
+ let summary = err.lines().next().unwrap_or("").chars().take(200).collect::();
+ (escape_xml(&summary), escape_xml(err))
+ }
+ None => (String::new(), String::new()),
+ };
+ buf.push_str(" ");
+ buf.push_str(&body);
+ buf.push_str("\n");
+ buf.push_str(" \n");
+ }
+ }
+ }
+
+ buf.push_str("\n");
+ buf
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::report::types::{TestCaseResult, TestReport, TestStatus};
+ use std::time::Duration;
+
+ #[test]
+ fn test_all_passing() {
+ let report = TestReport {
+ suite_name: "test_suite".to_string(),
+ results: vec![
+ TestCaseResult {
+ name: "test_one".to_string(),
+ status: TestStatus::Passed,
+ duration: Duration::from_millis(1500),
+ error: None,
+ metadata: vec![],
+ },
+ TestCaseResult {
+ name: "test_two".to_string(),
+ status: TestStatus::Passed,
+ duration: Duration::from_millis(250),
+ error: None,
+ metadata: vec![],
+ },
+ ],
+ total_duration: Duration::from_millis(1750),
+ timestamp: 1000,
+ };
+
+ let formatter = JUnitFormatter;
+ let output = formatter.format(&report);
+
+ let expected = "\n \n \n\n";
+ assert_eq!(output, expected);
+ }
+
+ #[test]
+ fn test_mixed_status() {
+ let report = TestReport {
+ suite_name: "test_suite".to_string(),
+ results: vec![
+ TestCaseResult {
+ name: "test_pass".to_string(),
+ status: TestStatus::Passed,
+ duration: Duration::from_millis(100),
+ error: None,
+ metadata: vec![],
+ },
+ TestCaseResult {
+ name: "test_fail".to_string(),
+ status: TestStatus::Failed,
+ duration: Duration::from_millis(200),
+ error: Some("something went wrong".to_string()),
+ metadata: vec![],
+ },
+ TestCaseResult {
+ name: "test_skip".to_string(),
+ status: TestStatus::Skipped,
+ duration: Duration::from_millis(0),
+ error: None,
+ metadata: vec![],
+ },
+ ],
+ total_duration: Duration::from_millis(300),
+ timestamp: 1000,
+ };
+
+ let formatter = JUnitFormatter;
+ let output = formatter.format(&report);
+
+ let expected = "\n \n \n something went wrong\n \n \n \n \n\n";
+ assert_eq!(output, expected);
+ }
+
+ #[test]
+ fn test_case_with_error_payload() {
+ let report = TestReport {
+ suite_name: "test_suite".to_string(),
+ results: vec![
+ TestCaseResult {
+ name: "test_escaping_&_<'_\">".to_string(),
+ status: TestStatus::Failed,
+ duration: Duration::from_millis(123),
+ error: Some("Error: x < 5 & y > 10 with \"quotes\" and 'ticks'".to_string()),
+ metadata: vec![],
+ },
+ ],
+ total_duration: Duration::from_millis(123),
+ timestamp: 1000,
+ };
+
+ let formatter = JUnitFormatter;
+ let output = formatter.format(&report);
+
+ let expected = "\n \n Error: x < 5 & y > 10 with "quotes" and 'ticks'\n \n\n";
+ assert_eq!(output, expected);
+ }
+
+ #[test]
+ fn test_case_with_metadata() {
+ let report = TestReport {
+ suite_name: "test_suite".to_string(),
+ results: vec![
+ TestCaseResult {
+ name: "test_metadata".to_string(),
+ status: TestStatus::Passed,
+ duration: Duration::from_millis(555),
+ error: None,
+ metadata: vec![("author".to_string(), "bob".to_string()), ("retries".to_string(), "2".to_string())],
+ },
+ ],
+ total_duration: Duration::from_millis(555),
+ timestamp: 1000,
+ };
+
+ let formatter = JUnitFormatter;
+ let output = formatter.format(&report);
+
+ let expected = "\n \n\n";
+ assert_eq!(output, expected);
+ }
+
+ #[test]
+ fn test_chaotic_escaping() {
+ let report = TestReport {
+ suite_name: "test_suite".to_string(),
+ results: vec![
+ TestCaseResult {
+ name: "test_🚀_".to_string(),
+ status: TestStatus::Failed,
+ duration: Duration::from_millis(0),
+ error: Some("HTML hello\0null bytes deeply nested \"'\"'\"'\"".to_string()),
+ metadata: vec![],
+ },
+ ],
+ total_duration: Duration::from_millis(0),
+ timestamp: 1000,
+ };
+
+ let formatter = JUnitFormatter;
+ let output = formatter.format(&report);
+
+ let expected = "\n \n HTML <b>hello</b>\u{FFFD}null bytes deeply nested "'"'"'"\n \n\n";
+ assert_eq!(output, expected);
+ }
+}
diff --git a/tests/src/report/mod.rs b/tests/src/report/mod.rs
index c869a86fd..657b1515e 100644
--- a/tests/src/report/mod.rs
+++ b/tests/src/report/mod.rs
@@ -5,5 +5,5 @@ mod format;
mod types;
pub use builder::TestReportBuilder;
-pub use format::{JsonFormatter, ReportFormatter, TextFormatter};
+pub use format::{JsonFormatter, JUnitFormatter, ReportFormatter, TextFormatter};
pub use types::{TestCaseResult, TestReport, TestStatus};