cucumber 0.19.1

Cucumber testing framework for Rust, with async support. Fully native, no external test runners or dependencies.
Documentation
// Copyright (c) 2018-2022  Brendan Molloy <brendan@bbqsrc.net>,
//                          Ilya Solovyiov <ilya.solovyiov@gmail.com>,
//                          Kai Ren <tyranron@gmail.com>
//
// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE or
// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
// option. This file may not be copied, modified, or distributed
// except according to those terms.

//! [JUnit XML report][1] [`Writer`] implementation.
//!
//! [1]: https://llg.cubic.org/docs/junit

use std::{fmt::Debug, io, mem, time::SystemTime};

use async_trait::async_trait;
use junit_report::{
    Duration, Report, TestCase, TestCaseBuilder, TestSuite, TestSuiteBuilder,
};

use crate::{
    event, parser,
    writer::{
        self,
        basic::{coerce_error, trim_path, Coloring},
        discard,
        out::WritableString,
        Ext as _, Verbosity,
    },
    Event, World, Writer,
};

/// Advice phrase to use in panic messages of incorrect [events][1] ordering.
///
/// [1]: event::Scenario
const WRAP_ADVICE: &str = "Consider wrapping `Writer` into `writer::Normalize`";

/// CLI options of a [`JUnit`] [`Writer`].
#[derive(clap::Args, Clone, Copy, Debug, Default)]
#[group(skip)]
pub struct Cli {
    /// Verbosity of JUnit XML report output.
    ///
    /// `0` is default verbosity, `1` additionally outputs world on failed
    /// steps.
    #[arg(id = "junit-v", long = "junit-v", value_name = "0|1", global = true)]
    pub verbose: Option<u8>,
}

/// [JUnit XML report][1] [`Writer`] implementation outputting XML to an
/// [`io::Write`] implementor.
///
/// # Ordering
///
/// This [`Writer`] isn't [`Normalized`] by itself, so should be wrapped into
/// a [`writer::Normalize`], otherwise will panic in runtime as won't be able to
/// form correct [JUnit `testsuite`s][1].
///
/// [`Normalized`]: writer::Normalized
/// [1]: https://llg.cubic.org/docs/junit
#[derive(Debug)]
pub struct JUnit<W, Out: io::Write> {
    /// [`io::Write`] implementor to output XML report into.
    output: Out,

    /// [JUnit XML report][1].
    ///
    /// [1]: https://llg.cubic.org/docs/junit
    report: Report,

    /// Current [JUnit `testsuite`][1].
    ///
    /// [1]: https://llg.cubic.org/docs/junit
    suit: Option<TestSuite>,

    /// [`SystemTime`] when the current [`Scenario`] has started.
    ///
    /// [`Scenario`]: gherkin::Scenario
    scenario_started_at: Option<SystemTime>,

    /// Current [`Scenario`] [events][1].
    ///
    /// [`Scenario`]: gherkin::Scenario
    /// [1]: event::Scenario
    events: Vec<event::RetryableScenario<W>>,

    /// [`Verbosity`] of this [`Writer`].
    verbosity: Verbosity,
}

// Implemented manually to omit redundant `World: Clone` trait bound, imposed by
// `#[derive(Clone)]`.
impl<World, Out: Clone + io::Write> Clone for JUnit<World, Out> {
    fn clone(&self) -> Self {
        Self {
            output: self.output.clone(),
            report: self.report.clone(),
            suit: self.suit.clone(),
            scenario_started_at: self.scenario_started_at,
            events: self.events.clone(),
            verbosity: self.verbosity,
        }
    }
}

#[async_trait(?Send)]
impl<W, Out> Writer<W> for JUnit<W, Out>
where
    W: World + Debug,
    Out: io::Write,
{
    type Cli = Cli;

    async fn handle_event(
        &mut self,
        ev: parser::Result<Event<event::Cucumber<W>>>,
        opts: &Self::Cli,
    ) {
        use event::{Cucumber, Feature, Rule};

        self.apply_cli(*opts);

        match ev.map(Event::split) {
            Err(err) => self.handle_error(&err),
            Ok((Cucumber::Started | Cucumber::ParsingFinished { .. }, _)) => {}
            Ok((Cucumber::Feature(feat, ev), meta)) => match ev {
                Feature::Started => {
                    self.suit = Some(
                        TestSuiteBuilder::new(&format!(
                            "Feature: {}{}",
                            &feat.name,
                            feat.path
                                .as_deref()
                                .and_then(|p| p.to_str().map(trim_path))
                                .map(|path| format!(": {path}"))
                                .unwrap_or_default(),
                        ))
                        .set_timestamp(meta.at.into())
                        .build(),
                    );
                }
                Feature::Rule(_, Rule::Started | Rule::Finished) => {}
                Feature::Rule(r, Rule::Scenario(sc, ev)) => {
                    self.handle_scenario_event(&feat, Some(&r), &sc, ev, meta);
                }
                Feature::Scenario(sc, ev) => {
                    self.handle_scenario_event(&feat, None, &sc, ev, meta);
                }
                Feature::Finished => {
                    let suite = self.suit.take().unwrap_or_else(|| {
                        panic!(
                            "No `TestSuit` for `Feature` \"{}\"\n{WRAP_ADVICE}",
                            feat.name,
                        )
                    });
                    self.report.add_testsuite(suite);
                }
            },
            Ok((Cucumber::Finished, _)) => {
                self.report
                    .write_xml(&mut self.output)
                    .unwrap_or_else(|e| panic!("Failed to write XML: {e}"));
            }
        }
    }
}

impl<W, O: io::Write> writer::NonTransforming for JUnit<W, O> {}

impl<W: Debug, Out: io::Write> JUnit<W, Out> {
    /// Creates a new [`Normalized`] [`JUnit`] [`Writer`] outputting XML report
    /// into the given `output`.
    ///
    /// [`Normalized`]: writer::Normalized
    #[must_use]
    pub fn new(
        output: Out,
        verbosity: impl Into<Verbosity>,
    ) -> writer::Normalize<W, Self> {
        Self::raw(output, verbosity).normalized()
    }

    /// Creates a new non-[`Normalized`] [`JUnit`] [`Writer`] outputting XML
    /// report into the given `output`, and suitable for feeding into [`tee()`].
    ///
    /// [`Normalized`]: writer::Normalized
    /// [`tee()`]: crate::WriterExt::tee
    /// [1]: https://llg.cubic.org/docs/junit
    /// [2]: crate::event::Cucumber
    #[must_use]
    pub fn for_tee(
        output: Out,
        verbosity: impl Into<Verbosity>,
    ) -> discard::Arbitrary<discard::Stats<Self>> {
        Self::raw(output, verbosity)
            .discard_stats_writes()
            .discard_arbitrary_writes()
    }

    /// Creates a new raw and non-[`Normalized`] [`JUnit`] [`Writer`] outputting
    /// XML report into the given `output`.
    ///
    /// Use it only if you know what you're doing. Otherwise, consider using
    /// [`JUnit::new()`] which creates an already [`Normalized`] version of
    /// [`JUnit`] [`Writer`].
    ///
    /// [`Normalized`]: writer::Normalized
    /// [1]: https://llg.cubic.org/docs/junit
    /// [2]: crate::event::Cucumber
    #[must_use]
    pub fn raw(output: Out, verbosity: impl Into<Verbosity>) -> Self {
        Self {
            output,
            report: Report::new(),
            suit: None,
            scenario_started_at: None,
            events: Vec::new(),
            verbosity: verbosity.into(),
        }
    }

    /// Applies the given [`Cli`] options to this [`JUnit`] [`Writer`].
    pub fn apply_cli(&mut self, cli: Cli) {
        match cli.verbose {
            None => {}
            Some(0) => self.verbosity = Verbosity::Default,
            _ => self.verbosity = Verbosity::ShowWorld,
        };
    }

    /// Handles the given [`parser::Error`].
    fn handle_error(&mut self, err: &parser::Error) {
        let (name, ty) = match err {
            parser::Error::Parsing(err) => {
                let path = match err.as_ref() {
                    gherkin::ParseFileError::Reading { path, .. }
                    | gherkin::ParseFileError::Parsing { path, .. } => path,
                };
                (
                    format!(
                        "Feature{}",
                        path.to_str()
                            .map(|p| format!(": {}", trim_path(p)))
                            .unwrap_or_default(),
                    ),
                    "Parser Error",
                )
            }
            parser::Error::ExampleExpansion(err) => (
                format!(
                    "Feature: {}{}:{}",
                    err.path
                        .as_deref()
                        .and_then(|p| p.to_str().map(trim_path))
                        .map(|p| format!("{p}:"))
                        .unwrap_or_default(),
                    err.pos.line,
                    err.pos.col,
                ),
                "Example Expansion Error",
            ),
        };

        self.report.add_testsuite(
            TestSuiteBuilder::new("Errors")
                .add_testcase(TestCase::failure(
                    &name,
                    Duration::ZERO,
                    ty,
                    &err.to_string(),
                ))
                .build(),
        );
    }

    /// Handles the given [`event::Scenario`].
    fn handle_scenario_event(
        &mut self,
        feat: &gherkin::Feature,
        rule: Option<&gherkin::Rule>,
        sc: &gherkin::Scenario,
        ev: event::RetryableScenario<W>,
        meta: Event<()>,
    ) {
        use event::Scenario;

        match &ev.event {
            Scenario::Started => {
                self.scenario_started_at = Some(meta.at);
                self.events.push(ev);
            }
            Scenario::Hook(..)
            | Scenario::Background(..)
            | Scenario::Step(..) => {
                self.events.push(ev);
            }
            Scenario::Finished => {
                let dur = self.scenario_duration(meta.at, sc);
                let events = mem::take(&mut self.events);
                let case = self.test_case(feat, rule, sc, &events, dur);

                self.suit
                    .as_mut()
                    .unwrap_or_else(|| {
                        panic!(
                            "No `TestSuit` for `Scenario` \"{}\"\n\
                             {WRAP_ADVICE}",
                            sc.name,
                        )
                    })
                    .add_testcase(case);
            }
        }
    }

    /// Forms a [`TestCase`] on [`event::Scenario::Finished`].
    fn test_case(
        &self,
        feat: &gherkin::Feature,
        rule: Option<&gherkin::Rule>,
        sc: &gherkin::Scenario,
        events: &[event::RetryableScenario<W>],
        duration: Duration,
    ) -> TestCase {
        use event::{Hook, HookType, Scenario, Step};

        let last_event = events
            .iter()
            .rev()
            .find(|ev| {
                !matches!(
                    ev.event,
                    Scenario::Hook(
                        HookType::After,
                        Hook::Passed | Hook::Started,
                    ),
                )
            })
            .unwrap_or_else(|| {
                panic!(
                    "No events for `Scenario` \"{}\"\n{WRAP_ADVICE}",
                    sc.name,
                )
            });

        let case_name = format!(
            "{}Scenario: {}: {}{}:{}",
            rule.map(|r| format!("Rule: {}: ", r.name))
                .unwrap_or_default(),
            sc.name,
            feat.path
                .as_ref()
                .and_then(|p| p.to_str().map(trim_path))
                .map(|path| format!("{path}:"))
                .unwrap_or_default(),
            sc.position.line,
            sc.position.col,
        );

        let mut case = match &last_event.event {
            Scenario::Started
            | Scenario::Hook(_, Hook::Started | Hook::Passed)
            | Scenario::Background(_, Step::Started | Step::Passed(_, _))
            | Scenario::Step(_, Step::Started | Step::Passed(_, _)) => {
                TestCaseBuilder::success(&case_name, duration).build()
            }
            Scenario::Background(_, Step::Skipped)
            | Scenario::Step(_, Step::Skipped) => {
                TestCaseBuilder::skipped(&case_name).build()
            }
            Scenario::Hook(_, Hook::Failed(_, e)) => TestCaseBuilder::failure(
                &case_name,
                duration,
                "Hook Panicked",
                coerce_error(e).as_ref(),
            )
            .build(),
            Scenario::Background(_, Step::Failed(_, _, _, e))
            | Scenario::Step(_, Step::Failed(_, _, _, e)) => {
                TestCaseBuilder::failure(
                    &case_name,
                    duration,
                    "Step Panicked",
                    &e.to_string(),
                )
                .build()
            }
            Scenario::Finished => {
                panic!(
                    "Duplicated `Finished` event for `Scenario`: \"{}\"\n\
                     {WRAP_ADVICE}",
                    sc.name,
                );
            }
        };

        // We should be passing normalized events here,
        // so using `writer::Basic::raw()` is OK.
        let mut basic_wr = writer::Basic::raw(
            WritableString(String::new()),
            Coloring::Never,
            self.verbosity,
        );
        let output = events
            .iter()
            .map(|ev| {
                basic_wr.scenario(feat, sc, ev)?;
                Ok(mem::take(&mut **basic_wr))
            })
            .collect::<io::Result<String>>()
            .unwrap_or_else(|e| {
                panic!("Failed to write with `writer::Basic`: {e}")
            });

        case.set_system_out(&output);

        case
    }

    /// Returns [`Scenario`]'s [`Duration`] on [`event::Scenario::Finished`].
    ///
    /// [`Scenario`]: gherkin::Scenario
    fn scenario_duration(
        &mut self,
        ended: SystemTime,
        sc: &gherkin::Scenario,
    ) -> Duration {
        let started_at = self.scenario_started_at.take().unwrap_or_else(|| {
            panic!(
                "No `Started` event for `Scenario` \"{}\"\n{WRAP_ADVICE}",
                sc.name,
            )
        });
        Duration::try_from(ended.duration_since(started_at).unwrap_or_else(
            |e| {
                panic!(
                    "Failed to compute duration between {ended:?} and \
                     {started_at:?}: {e}",
                )
            },
        ))
        .unwrap_or_else(|e| {
            panic!(
                "Cannot covert `std::time::Duration` to `time::Duration`: {e}",
            )
        })
    }
}