Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion tests/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
221 changes: 221 additions & 0 deletions tests/src/report/format.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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("&lt;"),
'>' => escaped.push_str("&gt;"),
'&' => escaped.push_str("&amp;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
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!(
"<testsuite name=\"mofa-testing\" tests=\"{}\" failures=\"{}\" errors=\"0\" skipped=\"{}\" time=\"{:.3}\">\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!(
" <testcase name=\"{}\" classname=\"mofa_testing\" time=\"{:.3}\"",
name, time
));

match r.status {
TestStatus::Passed => {
buf.push_str("/>\n");
}
TestStatus::Skipped => {
buf.push_str(">\n <skipped/>\n </testcase>\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::<String>();
(escape_xml(&summary), escape_xml(err))
}
None => (String::new(), String::new()),
};
buf.push_str(" <failure message=\"");
buf.push_str(&msg_attr);
buf.push_str("\">");
buf.push_str(&body);
buf.push_str("</failure>\n");
buf.push_str(" </testcase>\n");
}
}
}

buf.push_str("</testsuite>\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 = "<testsuite name=\"mofa-testing\" tests=\"2\" failures=\"0\" errors=\"0\" skipped=\"0\" time=\"1.750\">\n <testcase name=\"test_one\" classname=\"mofa_testing\" time=\"1.500\"/>\n <testcase name=\"test_two\" classname=\"mofa_testing\" time=\"0.250\"/>\n</testsuite>\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 = "<testsuite name=\"mofa-testing\" tests=\"3\" failures=\"1\" errors=\"0\" skipped=\"1\" time=\"0.300\">\n <testcase name=\"test_pass\" classname=\"mofa_testing\" time=\"0.100\"/>\n <testcase name=\"test_fail\" classname=\"mofa_testing\" time=\"0.200\">\n <failure message=\"something went wrong\">something went wrong</failure>\n </testcase>\n <testcase name=\"test_skip\" classname=\"mofa_testing\" time=\"0.000\">\n <skipped/>\n </testcase>\n</testsuite>\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 = "<testsuite name=\"mofa-testing\" tests=\"1\" failures=\"1\" errors=\"0\" skipped=\"0\" time=\"0.123\">\n <testcase name=\"test_escaping_&amp;_&lt;&apos;_&quot;&gt;\" classname=\"mofa_testing\" time=\"0.123\">\n <failure message=\"Error: x &lt; 5 &amp; y &gt; 10 with &quot;quotes&quot; and &apos;ticks&apos;\">Error: x &lt; 5 &amp; y &gt; 10 with &quot;quotes&quot; and &apos;ticks&apos;</failure>\n </testcase>\n</testsuite>\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 = "<testsuite name=\"mofa-testing\" tests=\"1\" failures=\"0\" errors=\"0\" skipped=\"0\" time=\"0.555\">\n <testcase name=\"test_metadata\" classname=\"mofa_testing\" time=\"0.555\"/>\n</testsuite>\n";
assert_eq!(output, expected);
}

#[test]
fn test_chaotic_escaping() {
let report = TestReport {
suite_name: "test_suite".to_string(),
results: vec![
TestCaseResult {
name: "test_πŸš€_<![CDATA[...]]>".to_string(),
status: TestStatus::Failed,
duration: Duration::from_millis(0),
error: Some("HTML <b>hello</b>\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 = "<testsuite name=\"mofa-testing\" tests=\"1\" failures=\"1\" errors=\"0\" skipped=\"0\" time=\"0.000\">\n <testcase name=\"test_πŸš€_&lt;![CDATA[...]]&gt;\" classname=\"mofa_testing\" time=\"0.000\">\n <failure message=\"HTML &lt;b&gt;hello&lt;/b&gt;\u{FFFD}null bytes deeply nested &quot;&apos;&quot;&apos;&quot;&apos;&quot;\">HTML &lt;b&gt;hello&lt;/b&gt;\u{FFFD}null bytes deeply nested &quot;&apos;&quot;&apos;&quot;&apos;&quot;</failure>\n </testcase>\n</testsuite>\n";
assert_eq!(output, expected);
}
}
2 changes: 1 addition & 1 deletion tests/src/report/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};