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};