cucumber/writer/
junit.rs

1// Copyright (c) 2018-2025  Brendan Molloy <brendan@bbqsrc.net>,
2//                          Ilya Solovyiov <ilya.solovyiov@gmail.com>,
3//                          Kai Ren <tyranron@gmail.com>
4//
5// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
6// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
7// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
8// option. This file may not be copied, modified, or distributed
9// except according to those terms.
10
11//! [JUnit XML report][1] [`Writer`] implementation.
12//!
13//! [1]: https://llg.cubic.org/docs/junit
14
15use std::{fmt::Debug, io, mem, time::SystemTime};
16
17use junit_report::{
18    Duration, Report, TestCase, TestCaseBuilder, TestSuite, TestSuiteBuilder,
19};
20
21use crate::{
22    Event, World, Writer, event, parser,
23    writer::{
24        self, Ext as _, Verbosity,
25        basic::{Coloring, coerce_error, trim_path},
26        discard,
27        out::WritableString,
28    },
29};
30
31/// Advice phrase to use in panic messages of incorrect [events][1] ordering.
32///
33/// [1]: event::Scenario
34const WRAP_ADVICE: &str = "Consider wrapping `Writer` into `writer::Normalize`";
35
36/// CLI options of a [`JUnit`] [`Writer`].
37#[derive(Clone, Copy, Debug, Default, clap::Args)]
38#[group(skip)]
39pub struct Cli {
40    /// Verbosity of JUnit XML report output.
41    ///
42    /// `0` is default verbosity, `1` additionally outputs world on failed
43    /// steps.
44    #[arg(id = "junit-v", long = "junit-v", value_name = "0|1", global = true)]
45    pub verbose: Option<u8>,
46}
47
48/// [JUnit XML report][1] [`Writer`] implementation outputting XML to an
49/// [`io::Write`] implementor.
50///
51/// # Ordering
52///
53/// This [`Writer`] isn't [`Normalized`] by itself, so should be wrapped into
54/// a [`writer::Normalize`], otherwise will panic in runtime as won't be able to
55/// form correct [JUnit `testsuite`s][1].
56///
57/// [`Normalized`]: writer::Normalized
58/// [1]: https://llg.cubic.org/docs/junit
59#[derive(Debug)]
60pub struct JUnit<W, Out: io::Write> {
61    /// [`io::Write`] implementor to output XML report into.
62    output: Out,
63
64    /// [JUnit XML report][1].
65    ///
66    /// [1]: https://llg.cubic.org/docs/junit
67    report: Report,
68
69    /// Current [JUnit `testsuite`][1].
70    ///
71    /// [1]: https://llg.cubic.org/docs/junit
72    suit: Option<TestSuite>,
73
74    /// [`SystemTime`] when the current [`Scenario`] has started.
75    ///
76    /// [`Scenario`]: gherkin::Scenario
77    scenario_started_at: Option<SystemTime>,
78
79    /// Current [`Scenario`] [events][1].
80    ///
81    /// [`Scenario`]: gherkin::Scenario
82    /// [1]: event::Scenario
83    events: Vec<event::RetryableScenario<W>>,
84
85    /// [`Verbosity`] of this [`Writer`].
86    verbosity: Verbosity,
87}
88
89// Implemented manually to omit redundant `World: Clone` trait bound, imposed by
90// `#[derive(Clone)]`.
91impl<World, Out: Clone + io::Write> Clone for JUnit<World, Out> {
92    fn clone(&self) -> Self {
93        Self {
94            output: self.output.clone(),
95            report: self.report.clone(),
96            suit: self.suit.clone(),
97            scenario_started_at: self.scenario_started_at,
98            events: self.events.clone(),
99            verbosity: self.verbosity,
100        }
101    }
102}
103
104impl<W, Out> Writer<W> for JUnit<W, Out>
105where
106    W: World + Debug,
107    Out: io::Write,
108{
109    type Cli = Cli;
110
111    async fn handle_event(
112        &mut self,
113        event: parser::Result<Event<event::Cucumber<W>>>,
114        cli: &Self::Cli,
115    ) {
116        use event::{Cucumber, Feature, Rule};
117
118        self.apply_cli(*cli);
119
120        match event.map(Event::split) {
121            Err(err) => self.handle_error(&err),
122            Ok((Cucumber::Started | Cucumber::ParsingFinished { .. }, _)) => {}
123            Ok((Cucumber::Feature(feat, ev), meta)) => match ev {
124                Feature::Started => {
125                    self.suit = Some(
126                        TestSuiteBuilder::new(&format!(
127                            "Feature: {}{}",
128                            &feat.name,
129                            feat.path
130                                .as_deref()
131                                .and_then(|p| p.to_str().map(trim_path))
132                                .map(|path| format!(": {path}"))
133                                .unwrap_or_default(),
134                        ))
135                        .set_timestamp(meta.at.into())
136                        .build(),
137                    );
138                }
139                Feature::Rule(_, Rule::Started | Rule::Finished) => {}
140                Feature::Rule(r, Rule::Scenario(sc, ev)) => {
141                    self.handle_scenario_event(&feat, Some(&r), &sc, ev, meta);
142                }
143                Feature::Scenario(sc, ev) => {
144                    self.handle_scenario_event(&feat, None, &sc, ev, meta);
145                }
146                Feature::Finished => {
147                    let suite = self.suit.take().unwrap_or_else(|| {
148                        panic!(
149                            "no `TestSuit` for `Feature` \"{}\"\n{WRAP_ADVICE}",
150                            feat.name,
151                        )
152                    });
153                    self.report.add_testsuite(suite);
154                }
155            },
156            Ok((Cucumber::Finished, _)) => {
157                self.report
158                    .write_xml(&mut self.output)
159                    .unwrap_or_else(|e| panic!("failed to write XML: {e}"));
160            }
161        }
162    }
163}
164
165impl<W, O: io::Write> writer::NonTransforming for JUnit<W, O> {}
166
167impl<W: Debug, Out: io::Write> JUnit<W, Out> {
168    /// Creates a new [`Normalized`] [`JUnit`] [`Writer`] outputting XML report
169    /// into the given `output`.
170    ///
171    /// [`Normalized`]: writer::Normalized
172    #[must_use]
173    pub fn new(
174        output: Out,
175        verbosity: impl Into<Verbosity>,
176    ) -> writer::Normalize<W, Self> {
177        Self::raw(output, verbosity).normalized()
178    }
179
180    /// Creates a new non-[`Normalized`] [`JUnit`] [`Writer`] outputting XML
181    /// report into the given `output`, and suitable for feeding into [`tee()`].
182    ///
183    /// [`Normalized`]: writer::Normalized
184    /// [`tee()`]: crate::WriterExt::tee
185    /// [1]: https://llg.cubic.org/docs/junit
186    /// [2]: crate::event::Cucumber
187    #[must_use]
188    pub fn for_tee(
189        output: Out,
190        verbosity: impl Into<Verbosity>,
191    ) -> discard::Arbitrary<discard::Stats<Self>> {
192        Self::raw(output, verbosity)
193            .discard_stats_writes()
194            .discard_arbitrary_writes()
195    }
196
197    /// Creates a new raw and non-[`Normalized`] [`JUnit`] [`Writer`] outputting
198    /// XML report into the given `output`.
199    ///
200    /// Use it only if you know what you're doing. Otherwise, consider using
201    /// [`JUnit::new()`] which creates an already [`Normalized`] version of
202    /// [`JUnit`] [`Writer`].
203    ///
204    /// [`Normalized`]: writer::Normalized
205    /// [1]: https://llg.cubic.org/docs/junit
206    /// [2]: crate::event::Cucumber
207    #[must_use]
208    pub fn raw(output: Out, verbosity: impl Into<Verbosity>) -> Self {
209        Self {
210            output,
211            report: Report::new(),
212            suit: None,
213            scenario_started_at: None,
214            events: vec![],
215            verbosity: verbosity.into(),
216        }
217    }
218
219    /// Applies the given [`Cli`] options to this [`JUnit`] [`Writer`].
220    pub const fn apply_cli(&mut self, cli: Cli) {
221        match cli.verbose {
222            None => {}
223            Some(0) => self.verbosity = Verbosity::Default,
224            _ => self.verbosity = Verbosity::ShowWorld,
225        }
226    }
227
228    /// Handles the given [`parser::Error`].
229    fn handle_error(&mut self, err: &parser::Error) {
230        let (name, ty) = match err {
231            parser::Error::Parsing(err) => {
232                let path = match err.as_ref() {
233                    gherkin::ParseFileError::Reading { path, .. }
234                    | gherkin::ParseFileError::Parsing { path, .. } => path,
235                };
236                (
237                    format!(
238                        "Feature{}",
239                        path.to_str()
240                            .map(|p| format!(": {}", trim_path(p)))
241                            .unwrap_or_default(),
242                    ),
243                    "Parser Error",
244                )
245            }
246            parser::Error::ExampleExpansion(err) => (
247                format!(
248                    "Feature: {}{}:{}",
249                    err.path
250                        .as_deref()
251                        .and_then(|p| p.to_str().map(trim_path))
252                        .map(|p| format!("{p}:"))
253                        .unwrap_or_default(),
254                    err.pos.line,
255                    err.pos.col,
256                ),
257                "Example Expansion Error",
258            ),
259        };
260
261        self.report.add_testsuite(
262            TestSuiteBuilder::new("Errors")
263                .add_testcase(TestCase::failure(
264                    &name,
265                    Duration::ZERO,
266                    ty,
267                    &err.to_string(),
268                ))
269                .build(),
270        );
271    }
272
273    /// Handles the given [`event::Scenario`].
274    fn handle_scenario_event(
275        &mut self,
276        feat: &gherkin::Feature,
277        rule: Option<&gherkin::Rule>,
278        sc: &gherkin::Scenario,
279        ev: event::RetryableScenario<W>,
280        meta: Event<()>,
281    ) {
282        use event::Scenario;
283
284        match &ev.event {
285            Scenario::Started => {
286                self.scenario_started_at = Some(meta.at);
287                self.events.push(ev);
288            }
289            Scenario::Log(_)
290            | Scenario::Hook(..)
291            | Scenario::Background(..)
292            | Scenario::Step(..) => {
293                self.events.push(ev);
294            }
295            Scenario::Finished => {
296                let dur = self.scenario_duration(meta.at, sc);
297                let events = mem::take(&mut self.events);
298                let case = self.test_case(feat, rule, sc, &events, dur);
299
300                self.suit
301                    .as_mut()
302                    .unwrap_or_else(|| {
303                        panic!(
304                            "no `TestSuit` for `Scenario` \"{}\"\n\
305                             {WRAP_ADVICE}",
306                            sc.name,
307                        )
308                    })
309                    .add_testcase(case);
310            }
311        }
312    }
313
314    /// Forms a [`TestCase`] on [`event::Scenario::Finished`].
315    fn test_case(
316        &self,
317        feat: &gherkin::Feature,
318        rule: Option<&gherkin::Rule>,
319        sc: &gherkin::Scenario,
320        events: &[event::RetryableScenario<W>],
321        duration: Duration,
322    ) -> TestCase {
323        use event::{Hook, HookType, Scenario, Step};
324
325        let last_event = events
326            .iter()
327            .rev()
328            .find(|ev| {
329                !matches!(
330                    ev.event,
331                    Scenario::Log(_)
332                        | Scenario::Hook(
333                            HookType::After,
334                            Hook::Passed | Hook::Started,
335                        ),
336                )
337            })
338            .unwrap_or_else(|| {
339                panic!(
340                    "no events for `Scenario` \"{}\"\n{WRAP_ADVICE}",
341                    sc.name,
342                )
343            });
344
345        let case_name = format!(
346            "{}Scenario: {}: {}{}:{}",
347            rule.map(|r| format!("Rule: {}: ", r.name)).unwrap_or_default(),
348            sc.name,
349            feat.path
350                .as_ref()
351                .and_then(|p| p.to_str().map(trim_path))
352                .map(|path| format!("{path}:"))
353                .unwrap_or_default(),
354            sc.position.line,
355            sc.position.col,
356        );
357
358        let mut case = match &last_event.event {
359            Scenario::Started
360            | Scenario::Log(_)
361            | Scenario::Hook(_, Hook::Started | Hook::Passed)
362            | Scenario::Background(_, Step::Started | Step::Passed(_, _))
363            | Scenario::Step(_, Step::Started | Step::Passed(_, _)) => {
364                TestCaseBuilder::success(&case_name, duration).build()
365            }
366            Scenario::Background(_, Step::Skipped)
367            | Scenario::Step(_, Step::Skipped) => {
368                TestCaseBuilder::skipped(&case_name).build()
369            }
370            Scenario::Hook(_, Hook::Failed(_, e)) => TestCaseBuilder::failure(
371                &case_name,
372                duration,
373                "Hook Panicked",
374                coerce_error(e).as_ref(),
375            )
376            .build(),
377            Scenario::Background(_, Step::Failed(_, _, _, e))
378            | Scenario::Step(_, Step::Failed(_, _, _, e)) => {
379                TestCaseBuilder::failure(
380                    &case_name,
381                    duration,
382                    "Step Panicked",
383                    &e.to_string(),
384                )
385                .build()
386            }
387            Scenario::Finished => {
388                panic!(
389                    "Duplicated `Finished` event for `Scenario`: \"{}\"\n\
390                     {WRAP_ADVICE}",
391                    sc.name,
392                );
393            }
394        };
395
396        // We should be passing normalized events here,
397        // so using `writer::Basic::raw()` is OK.
398        let mut basic_wr = writer::Basic::raw(
399            WritableString(String::new()),
400            Coloring::Never,
401            self.verbosity,
402        );
403        let output = events
404            .iter()
405            .map(|ev| {
406                basic_wr.scenario(feat, sc, ev)?;
407                Ok(mem::take(&mut **basic_wr))
408            })
409            .collect::<io::Result<String>>()
410            .unwrap_or_else(|e| {
411                panic!("Failed to write with `writer::Basic`: {e}")
412            });
413
414        case.set_system_out(&output);
415
416        case
417    }
418
419    /// Returns [`Scenario`]'s [`Duration`] on [`event::Scenario::Finished`].
420    ///
421    /// [`Scenario`]: gherkin::Scenario
422    fn scenario_duration(
423        &mut self,
424        ended: SystemTime,
425        sc: &gherkin::Scenario,
426    ) -> Duration {
427        let started_at = self.scenario_started_at.take().unwrap_or_else(|| {
428            panic!(
429                "no `Started` event for `Scenario` \"{}\"\n{WRAP_ADVICE}",
430                sc.name,
431            )
432        });
433        Duration::try_from(ended.duration_since(started_at).unwrap_or_else(
434            |e| {
435                panic!(
436                    "failed to compute duration between {ended:?} and \
437                     {started_at:?}: {e}",
438                )
439            },
440        ))
441        .unwrap_or_else(|e| {
442            panic!(
443                "cannot covert `std::time::Duration` to `time::Duration`: {e}",
444            )
445        })
446    }
447}