Skip to main content

stormchaser_engine/
junit.rs

1use anyhow::Result;
2use quick_xml::de::from_str;
3use serde::Deserialize;
4use serde_json::Value;
5use stormchaser_model::RunId;
6use stormchaser_model::StepInstanceId;
7use stormchaser_model::TestReportId;
8use stormchaser_model::{TestCase, TestCaseStatus, TestSummary};
9
10#[derive(Debug, Deserialize)]
11#[allow(dead_code)]
12struct TestCaseXml {
13    #[serde(rename = "@name")]
14    name: String,
15    #[serde(rename = "@classname")]
16    classname: Option<String>,
17    #[serde(rename = "@time")]
18    time: Option<f64>,
19    failure: Option<TestFailureXml>,
20    error: Option<TestFailureXml>,
21    skipped: Option<Value>,
22}
23
24#[derive(Debug, Deserialize)]
25#[allow(dead_code)]
26struct TestFailureXml {
27    #[serde(rename = "@message")]
28    message: Option<String>,
29    #[serde(rename = "$value")]
30    content: Option<String>,
31}
32
33#[derive(Debug, Deserialize)]
34#[allow(dead_code)]
35struct TestSuite {
36    #[serde(rename = "@name")]
37    name: Option<String>,
38    #[serde(rename = "@tests")]
39    tests: Option<i32>,
40    #[serde(rename = "@failures")]
41    failures: Option<i32>,
42    #[serde(rename = "@errors")]
43    errors: Option<i32>,
44    #[serde(rename = "@skipped")]
45    skipped: Option<i32>,
46    #[serde(rename = "@time")]
47    time: Option<f64>,
48    #[serde(rename = "testcase", default)]
49    testcases: Vec<TestCaseXml>,
50}
51
52#[derive(Debug, Deserialize)]
53#[allow(dead_code)]
54struct TestSuites {
55    #[serde(rename = "testsuite", default)]
56    testsuites: Vec<TestSuite>,
57    #[serde(rename = "@tests")]
58    tests: Option<i32>,
59    #[serde(rename = "@failures")]
60    failures: Option<i32>,
61    #[serde(rename = "@errors")]
62    errors: Option<i32>,
63    #[serde(rename = "@time")]
64    time: Option<f64>,
65}
66
67/// Parses a JUnit XML report string into a `TestSummary` and a list of `TestCase` entities.
68pub fn parse_junit(
69    content: &str,
70    report_name: &str,
71    run_id: RunId,
72    step_id: StepInstanceId,
73) -> Result<(TestSummary, Vec<TestCase>)> {
74    let mut summary = TestSummary {
75        id: TestReportId::new_v4(),
76        run_id,
77        step_instance_id: step_id,
78        report_name: report_name.to_string(),
79        ..Default::default()
80    };
81    let mut test_cases = Vec::new();
82
83    let suites = if content.contains("<testsuites") {
84        from_str::<TestSuites>(content).ok().map(|s| s.testsuites)
85    } else if content.contains("<testsuite") {
86        from_str::<TestSuite>(content).ok().map(|s| vec![s])
87    } else {
88        None
89    };
90
91    if let Some(suites) = suites {
92        for suite in suites {
93            let suite_name = suite.name.clone();
94            summary.total_tests += suite.tests.unwrap_or(suite.testcases.len() as i32);
95            summary.failed += suite.failures.unwrap_or(0);
96            summary.errors += suite.errors.unwrap_or(0);
97            summary.skipped += suite.skipped.unwrap_or(0);
98            summary.duration_ms += (suite.time.unwrap_or(0.0) * 1000.0) as i64;
99
100            for tc in suite.testcases {
101                let status = if tc.failure.is_some() {
102                    TestCaseStatus::Failed
103                } else if tc.error.is_some() {
104                    TestCaseStatus::Error
105                } else if tc.skipped.is_some() {
106                    TestCaseStatus::Skipped
107                } else {
108                    TestCaseStatus::Passed
109                };
110
111                let message = tc
112                    .failure
113                    .as_ref()
114                    .or(tc.error.as_ref())
115                    .and_then(|f| f.message.clone().or_else(|| f.content.clone()));
116
117                test_cases.push(TestCase {
118                    id: TestReportId::new_v4(),
119                    run_id,
120                    step_instance_id: step_id,
121                    report_name: report_name.to_string(),
122                    test_suite: suite_name.clone(),
123                    test_case: tc.name,
124                    status,
125                    duration_ms: tc.time.map(|t| (t * 1000.0) as i64),
126                    message,
127                    created_at: chrono::Utc::now(),
128                });
129            }
130        }
131        summary.passed = summary.total_tests - summary.failed - summary.errors - summary.skipped;
132    }
133
134    Ok((summary, test_cases))
135}
136
137/// Aggregate summaries.
138pub fn aggregate_summaries(summaries: &[TestSummary]) -> Option<TestSummary> {
139    if summaries.is_empty() {
140        return None;
141    }
142
143    let mut first = summaries[0].clone();
144    for s in &summaries[1..] {
145        first.total_tests += s.total_tests;
146        first.passed += s.passed;
147        first.failed += s.failed;
148        first.skipped += s.skipped;
149        first.errors += s.errors;
150        first.duration_ms += s.duration_ms;
151    }
152    Some(first)
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn test_parse_junit_suites() {
161        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
162<testsuites tests="3" failures="1" errors="1" time="1.5">
163    <testsuite name="suite1" tests="2" failures="1" errors="0" skipped="0" time="1.0">
164        <testcase name="test1" classname="c1" time="0.5"/>
165        <testcase name="test2" classname="c1" time="0.5">
166            <failure message="failed">detail</failure>
167        </testcase>
168    </testsuite>
169    <testsuite name="suite2" tests="1" failures="0" errors="1" skipped="0" time="0.5">
170        <testcase name="test3" classname="c2" time="0.5">
171            <error message="error">detail</error>
172        </testcase>
173    </testsuite>
174</testsuites>"#;
175        let run_id = RunId::new_v4();
176        let step_id = StepInstanceId::new_v4();
177        let (summary, cases) = parse_junit(xml, "test-report", run_id, step_id).unwrap();
178
179        assert_eq!(summary.total_tests, 3);
180        assert_eq!(summary.failed, 1);
181        assert_eq!(summary.errors, 1);
182        assert_eq!(summary.passed, 1);
183        assert_eq!(summary.duration_ms, 1500);
184        assert_eq!(cases.len(), 3);
185        assert_eq!(cases[0].status, TestCaseStatus::Passed);
186        assert_eq!(cases[1].status, TestCaseStatus::Failed);
187        assert_eq!(cases[2].status, TestCaseStatus::Error);
188    }
189
190    #[test]
191    fn test_parse_junit_single_suite() {
192        let xml = r#"<?xml version="1.0" encoding="UTF-8"?>
193<testsuite name="suite1" tests="2" failures="0" errors="0" skipped="1" time="0.8">
194    <testcase name="test1" classname="c1" time="0.4"/>
195    <testcase name="test2" classname="c1" time="0.4">
196        <skipped/>
197    </testcase>
198</testsuite>"#;
199        let run_id = RunId::new_v4();
200        let step_id = StepInstanceId::new_v4();
201        let (summary, cases) = parse_junit(xml, "test-report", run_id, step_id).unwrap();
202
203        assert_eq!(summary.total_tests, 2);
204        assert_eq!(summary.skipped, 1);
205        assert_eq!(summary.passed, 1);
206        assert_eq!(summary.duration_ms, 800);
207        assert_eq!(cases.len(), 2);
208        assert_eq!(cases[1].status, TestCaseStatus::Skipped);
209    }
210
211    #[test]
212    fn test_aggregate_summaries() {
213        let s1 = TestSummary {
214            total_tests: 10,
215            passed: 8,
216            failed: 2,
217            duration_ms: 1000,
218            ..Default::default()
219        };
220        let s2 = TestSummary {
221            total_tests: 5,
222            passed: 4,
223            failed: 1,
224            duration_ms: 500,
225            ..Default::default()
226        };
227        let agg = aggregate_summaries(&[s1, s2]).unwrap();
228        assert_eq!(agg.total_tests, 15);
229        assert_eq!(agg.passed, 12);
230        assert_eq!(agg.failed, 3);
231        assert_eq!(agg.duration_ms, 1500);
232    }
233}