Skip to main content

ephemeris_report/
junit.rs

1//! The converter for `JUnit` XML, as written by nextest.
2
3use roxmltree::{Document, Node};
4
5use crate::{ConvertError, Converted, Details, Outcome, TestItem, TestSummary};
6
7/// Convert a `JUnit` XML document into test details. The terminal text is the
8/// document itself.
9///
10/// Every `testcase` becomes one item, named `<classname>::<name>` or just the
11/// name when it has no class. A `failure` or `error` child fails the test and a
12/// `skipped` child skips it. Items are sorted by name, because nextest writes
13/// them in completion order.
14///
15/// # Errors
16///
17/// Returns an error when the input is not UTF-8 or not well-formed XML.
18pub(crate) fn convert(bytes: &[u8]) -> Result<Converted, ConvertError> {
19    let text = str::from_utf8(bytes)?;
20    let document = Document::parse(text).map_err(|source| ConvertError::Xml { source })?;
21    let mut items: Vec<TestItem> = document
22        .descendants()
23        .filter(|node| node.has_tag_name("testcase"))
24        .map(|node| item(&node))
25        .collect();
26    items.sort_by(|left, right| left.name.cmp(&right.name));
27    let count = |outcome| items.iter().filter(|item| item.outcome == outcome).count() as u64;
28    Ok(Converted {
29        details: Details::Tests {
30            summary: TestSummary {
31                passed: count(Outcome::Pass),
32                failed: count(Outcome::Fail),
33                skipped: count(Outcome::Skip),
34            },
35            items,
36        },
37        terminal: text.to_owned(),
38    })
39}
40
41fn item(case: &Node<'_, '_>) -> TestItem {
42    let name = case.attribute("name").unwrap_or_default();
43    let name = match case.attribute("classname") {
44        Some(class) if !class.is_empty() => format!("{class}::{name}"),
45        _ => name.to_owned(),
46    };
47    let child = |tag| case.children().find(|node| node.has_tag_name(tag));
48    if let Some(failure) = child("failure").or_else(|| child("error")) {
49        let message = failure
50            .attribute("message")
51            .map(str::to_owned)
52            .or_else(|| failure.text().map(|text| text.trim().to_owned()))
53            .filter(|message| !message.is_empty());
54        return TestItem {
55            name,
56            outcome: Outcome::Fail,
57            message,
58        };
59    }
60    let outcome = if child("skipped").is_some() {
61        Outcome::Skip
62    } else {
63        Outcome::Pass
64    };
65    TestItem {
66        name,
67        outcome,
68        message: None,
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::convert;
75    use crate::{ConvertError, Details, Outcome};
76
77    const SAMPLE: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
78<testsuites name="nextest-run" tests="4" failures="1" errors="0">
79  <testsuite name="pkg" tests="4" disabled="0" errors="0" failures="1">
80    <testcase name="a::passes" classname="pkg" time="0.01"></testcase>
81    <testcase name="a::breaks" classname="pkg" time="0.02">
82      <failure message="assertion failed" type="test failure">stack</failure>
83    </testcase>
84    <testcase name="a::ignored" classname="pkg"><skipped/></testcase>
85    <testcase name="bare"><error>panicked</error></testcase>
86  </testsuite>
87</testsuites>"#;
88
89    #[test]
90    fn an_empty_suite_passes_with_zero_counts() {
91        let converted = convert(br#"<testsuites name="nextest-run"></testsuites>"#).unwrap();
92        let Details::Tests { summary, items } = converted.details else {
93            panic!("expected tests");
94        };
95        assert_eq!(items.len(), 0);
96        assert_eq!((summary.passed, summary.failed, summary.skipped), (0, 0, 0));
97    }
98
99    #[test]
100    fn cases_become_items() {
101        let Details::Tests { summary, items } = convert(SAMPLE.as_bytes()).unwrap().details else {
102            panic!("expected tests");
103        };
104        assert_eq!((summary.passed, summary.failed, summary.skipped), (1, 2, 1));
105        let names: Vec<_> = items.iter().map(|item| item.name.as_str()).collect();
106        assert_eq!(
107            names,
108            [
109                "bare",
110                "pkg::a::breaks",
111                "pkg::a::ignored",
112                "pkg::a::passes"
113            ]
114        );
115        assert_eq!(items[1].outcome, Outcome::Fail);
116        assert_eq!(items[1].message.as_deref(), Some("assertion failed"));
117        assert_eq!(items[2].outcome, Outcome::Skip);
118        assert_eq!(items[0].message.as_deref(), Some("panicked"));
119        assert_eq!(items[3].message, None);
120    }
121
122    #[test]
123    fn malformed_input_is_an_error() {
124        assert!(matches!(
125            convert(b"<testsuites>"),
126            Err(ConvertError::Xml { source: _ })
127        ));
128        assert!(matches!(convert(b"\xff"), Err(ConvertError::Utf8(_))));
129    }
130}