//! JUnit XML test reports (Maven Surefire / Failsafe `TEST-*.xml`, Gradle //! `build/test-results/**`): one result per test case, including the passed //! ones that console output does not list. use serde::{Deserialize, Serialize}; use specta::Type; use std::path::{Path, PathBuf}; use std::time::SystemTime; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub enum TestCaseStatus { Passed, Failed, Skipped, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct TestCaseReport { /// Fully qualified class (`com.acme.OrderTest`, nested: `com.acme.OrderTest$Nested`). pub class_name: String, /// Method name without `()`. pub name: String, pub status: TestCaseStatus, pub duration_ms: Option, /// Failure / error / skip message. pub message: Option, /// Failure stack trace and captured output. pub output: Option, } fn decode(s: &str) -> String { if !s.contains('&') { return s.to_owned(); } let mut out = String::with_capacity(s.len()); let mut rest = s; while let Some(i) = rest.find('&') { out.push_str(&rest[..i]); let tail = &rest[i..]; let Some(end) = tail.find(';').filter(|e| *e <= 10) else { out.push('&'); rest = &tail[1..]; continue; }; let entity = &tail[1..end]; let ch = match entity { "lt" => Some('<'), "gt" => Some('>'), "amp" => Some('&'), "quot" => Some('"'), "apos" => Some('\''), e if e.starts_with("#x") => u32::from_str_radix(&e[2..], 16).ok().and_then(char::from_u32), e if e.starts_with('#') => e[1..].parse().ok().and_then(char::from_u32), _ => None, }; match ch { Some(c) => { out.push(c); rest = &tail[end + 1..]; } None => { out.push('&'); rest = &tail[1..]; } } } out.push_str(rest); out } fn attrs(tag: &str) -> Vec<(String, String)> { let mut out = Vec::new(); let mut rest = tag; while let Some(eq) = rest.find('=') { let name = rest[..eq].trim().rsplit(char::is_whitespace).next().unwrap_or_default().to_owned(); let after = rest[eq + 1..].trim_start(); let Some(quote) = after.chars().next().filter(|c| *c == '"' || *c == '\'') else { break }; let body = &after[1..]; let Some(close) = body.find(quote) else { break }; out.push((name, decode(&body[..close]))); rest = &body[close + 1..]; } out } fn attr<'a>(attrs: &'a [(String, String)], name: &str) -> Option<&'a str> { attrs.iter().find(|(k, _)| k == name).map(|(_, v)| v.as_str()) } fn push_text(buf: &mut Option, text: &str) { if let Some(b) = buf { b.push_str(text); } } /// Test cases of one report file. pub fn parse_report(xml: &str) -> Vec { let mut out = Vec::new(); let mut current: Option = None; // Text of the open //// element. let mut text: Option = None; let mut rest = xml; while let Some(lt) = rest.find('<') { push_text(&mut text, &decode(&rest[..lt])); rest = &rest[lt..]; if let Some(body) = rest.strip_prefix("").unwrap_or(body.len()); push_text(&mut text, &body[..end]); rest = body.get(end + 3..).unwrap_or_default(); continue; } if rest.starts_with("").map(|e| &rest[e + 3..]).unwrap_or_default(); continue; } let Some(gt) = rest.find('>') else { break }; let tag = &rest[1..gt]; rest = &rest[gt + 1..]; if tag.starts_with('?') || tag.starts_with('!') { continue; } if let Some(name) = tag.strip_prefix('/') { match name.trim() { "testcase" => out.extend(current.take()), "failure" | "error" | "rerunFailure" | "rerunError" | "skipped" | "system-out" | "system-err" => { if let (Some(t), Some(c)) = (text.take(), current.as_mut()) { let t = t.trim_matches('\n'); if !t.trim().is_empty() { let o = c.output.get_or_insert_with(String::new); if !o.is_empty() { o.push('\n'); } o.push_str(t); } } } _ => {} } continue; } let self_closing = tag.ends_with('/'); let tag = tag.trim_end_matches('/'); let name = tag.split(char::is_whitespace).next().unwrap_or_default(); let a = attrs(&tag[name.len()..]); match name { "testcase" => { out.extend(current.take()); let class_name = attr(&a, "classname").unwrap_or_default().to_owned(); let test = attr(&a, "name").unwrap_or_default(); // JUnit 5 display names may carry the signature: `shouldX()`, `add(int, int)[1]`. let base = test.split('(').next().unwrap_or(test).trim().to_owned(); let duration_ms = attr(&a, "time").and_then(|t| t.replace(',', "").parse::().ok()).map(|s| s * 1000.0); let case = TestCaseReport { class_name, name: if base.is_empty() { test.to_owned() } else { base }, status: TestCaseStatus::Passed, duration_ms, message: None, output: None, }; if self_closing { out.push(case); } else { current = Some(case); } } "failure" | "error" | "skipped" => { if let Some(c) = current.as_mut() { c.status = if name == "skipped" { TestCaseStatus::Skipped } else { TestCaseStatus::Failed }; c.message = attr(&a, "message").map(str::to_owned).or_else(|| c.message.take()); if !self_closing { text = Some(String::new()); } } } // Flaky reruns: the test eventually passed; output only. "rerunFailure" | "rerunError" | "system-out" | "system-err" if !self_closing && current.is_some() => { text = Some(String::new()); } _ => {} } } out.extend(current.take()); out } /// Report directories of a module: Surefire, Failsafe and Gradle test results. pub fn report_dirs(module_dir: &Path) -> Vec { let mut dirs = vec![module_dir.join("target/surefire-reports"), module_dir.join("target/failsafe-reports")]; if let Ok(rd) = std::fs::read_dir(module_dir.join("build/test-results")) { let mut tasks: Vec = rd.flatten().map(|e| e.path()).filter(|p| p.is_dir()).collect(); tasks.sort(); dirs.extend(tasks); } dirs } /// Test cases from every `TEST-*.xml` in `dirs` written at or after `since` /// (reports of earlier runs are ignored). pub fn read_reports(dirs: &[PathBuf], since: SystemTime) -> Vec { let mut files: Vec = Vec::new(); for dir in dirs { let Ok(rd) = std::fs::read_dir(dir) else { continue }; for e in rd.flatten() { let path = e.path(); let name = e.file_name().to_string_lossy().into_owned(); if !(name.starts_with("TEST-") && name.ends_with(".xml")) { continue; } let fresh = e.metadata().and_then(|m| m.modified()).is_ok_and(|t| t >= since); if fresh { files.push(path); } } } files.sort(); files.iter().filter_map(|f| std::fs::read_to_string(f).ok()).flat_map(|xml| parse_report(&xml)).collect() } #[cfg(test)] mod tests { use super::*; const SUREFIRE: &str = r#" ]]> but was: <2> at com.example.demo.todo.TodoServiceTests.fails(TodoServiceTests.java:30) ]]> "#; #[test] fn parses_surefire_reports() { let cases = parse_report(SUREFIRE); assert_eq!(cases.len(), 4); assert_eq!(cases[0].name, "createsAndCompletesTodos"); assert_eq!(cases[0].class_name, "com.example.demo.todo.TodoServiceTests"); assert_eq!(cases[0].status, TestCaseStatus::Passed); assert_eq!(cases[0].duration_ms, Some(31.0)); assert_eq!(cases[1].name, "deleteReturnsFalseForUnknownId"); assert_eq!(cases[1].output.as_deref(), Some("hello ")); assert_eq!(cases[2].status, TestCaseStatus::Failed); assert_eq!(cases[2].message.as_deref(), Some("expected: <1> but was: <2>")); assert!(cases[2].output.as_deref().unwrap().contains("TodoServiceTests.java:30")); assert_eq!(cases[2].duration_ms, Some(1_234_500.0)); assert_eq!(cases[3].status, TestCaseStatus::Skipped); assert_eq!(cases[3].message.as_deref(), Some("not yet")); } #[test] fn errors_reruns_and_entities() { let xml = r#"trace AB t1 "#; let cases = parse_report(xml); assert_eq!(cases.len(), 2); assert_eq!(cases[0].class_name, "a.B$Inner"); assert_eq!(cases[0].name, "x"); assert_eq!(cases[0].status, TestCaseStatus::Failed); assert_eq!(cases[0].message.as_deref(), Some("boom & bust")); assert_eq!(cases[0].output.as_deref(), Some("trace AB")); assert_eq!(cases[1].status, TestCaseStatus::Passed, "passed on rerun"); assert!(parse_report("not xml").is_empty()); } #[test] fn reads_only_fresh_reports() { let tmp = tempfile::tempdir().unwrap(); let module = tmp.path(); let dir = module.join("target/surefire-reports"); std::fs::create_dir_all(&dir).unwrap(); std::fs::create_dir_all(module.join("build/test-results/test")).unwrap(); std::fs::write(dir.join("TEST-a.B.xml"), SUREFIRE).unwrap(); std::fs::write(dir.join("a.B.txt"), "ignored").unwrap(); std::fs::write(module.join("build/test-results/test/TEST-c.D.xml"), r#""#) .unwrap(); let dirs = report_dirs(module); assert!(dirs.contains(&module.join("build/test-results/test"))); let all = read_reports(&dirs, SystemTime::UNIX_EPOCH); assert_eq!(all.len(), 5); let future = SystemTime::now() + std::time::Duration::from_secs(3600); assert!(read_reports(&dirs, future).is_empty()); } }