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