diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c50d4c..6e2506db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ All user visible changes to `cucumber` crate will be documented in this file. Th - Made test callbacks first argument `&mut World` instead of `World`. ([#128]) - Made `#[step]` argument of step functions `Step` instead of `StepContext` again, while test callbacks still receive `StepContext` as a second parameter. ([#128]) - Deprecated `--nocapture` and `--debug` CLI options to be completely redesigned in `0.11` release. ([#137]) -- [Hooks](https://cucumber.io/docs/cucumber/api/#hooks) were removed, but are planned to be re-implemented with some changes in `0.11` release. ([#128]) +- [Hooks](https://cucumber.io/docs/cucumber/api/#hooks) now accept optional `&mut World` as their last parameter. ([#142]) ### Added @@ -32,6 +32,7 @@ All user visible changes to `cucumber` crate will be documented in this file. Th [#128]: /../../pull/128 [#136]: /../../pull/136 [#137]: /../../pull/137 +[#142]: /../../pull/142 diff --git a/book/src/Features.md b/book/src/Features.md index 8f6f5c9e..84092ce2 100644 --- a/book/src/Features.md +++ b/book/src/Features.md @@ -41,6 +41,8 @@ Occasionally you’ll find yourself repeating the same `Given` steps in all the Since it's repeated in every scenario, this is an indication that those steps are not essential to describe the scenarios, so they are _incidental details_. You can literally move such `Given` steps to background, by grouping them under a `Background` section. +`Background` allows you to add some context to the `Scenario`s following it. It can contain one or more steps, which are run before each scenario (but after any [`Before` hooks](#before-hook)). + ```gherkin Feature: Animal feature @@ -304,5 +306,81 @@ In case most of your `.feature` files aren't written in English and you want to +## Scenario hooks + + +### `Before` hook + +`Before` hook runs before the first step of each scenario, even before [`Background` ones](#background-keyword). + +```rust +# use std::{convert::Infallible, time::Duration}; +# +# use async_trait::async_trait; +# use cucumber::WorldInit; +# use futures::FutureExt as _; +# use tokio::time; +# +# #[derive(Debug, WorldInit)] +# struct World; +# +# #[async_trait(?Send)] +# impl cucumber::World for World { +# type Error = Infallible; +# +# async fn new() -> Result { +# Ok(World) +# } +# } +# +# fn main() { +World::cucumber() + .before(|_feature, _rule, _scenario, _world| { + time::sleep(Duration::from_millis(10)).boxed_local() + }) + .run_and_exit("tests/features/book"); +# } +``` + +> ⚠️ __Think twice before using `Before` hook!__ +> Whatever happens in a `Before` hook is invisible to people reading `.feature`s. You should consider using a [`Background`](#background-keyword) as a more explicit alternative, especially if the setup should be readable by non-technical people. Only use a `Before` hook for low-level logic such as starting a browser or deleting data from a database. + + +### `After` hook + +`After` hook runs after the last step of each `Scenario`, even when that step fails or is skipped. + +```rust +# use std::{convert::Infallible, time::Duration}; +# +# use async_trait::async_trait; +# use cucumber::WorldInit; +# use futures::FutureExt as _; +# use tokio::time; +# +# #[derive(Debug, WorldInit)] +# struct World; +# +# #[async_trait(?Send)] +# impl cucumber::World for World { +# type Error = Infallible; +# +# async fn new() -> Result { +# Ok(World) +# } +# } +# +# fn main() { +World::cucumber() + .after(|_feature, _rule, _scenario, _world| { + time::sleep(Duration::from_millis(10)).boxed_local() + }) + .run_and_exit("tests/features/book"); +# } +``` + + + + [Cucumber]: https://cucumber.io [Gherkin]: https://cucumber.io/docs/gherkin diff --git a/book/tests/Cargo.toml b/book/tests/Cargo.toml index e01a6c3c..cc74553e 100644 --- a/book/tests/Cargo.toml +++ b/book/tests/Cargo.toml @@ -12,7 +12,7 @@ publish = false [dependencies] async-trait = "0.1" -cucumber = { path = "../.." } +cucumber = { version = "0.10", path = "../.." } futures = "0.3" skeptic = "0.13" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/src/cucumber.rs b/src/cucumber.rs index db5cfe53..f0451049 100644 --- a/src/cucumber.rs +++ b/src/cucumber.rs @@ -21,7 +21,7 @@ use std::{ }; use clap::Parser as _; -use futures::StreamExt as _; +use futures::{future::LocalBoxFuture, StreamExt as _}; use regex::Regex; use crate::{ @@ -774,22 +774,9 @@ where I: AsRef, { fn default() -> Self { - let which: runner::basic::WhichScenarioFn = |_, _, scenario| { - scenario - .tags - .iter() - .any(|tag| tag == "serial") - .then(|| ScenarioType::Serial) - .unwrap_or(ScenarioType::Concurrent) - }; - Cucumber::custom() .with_parser(parser::Basic::new()) - .with_runner( - runner::Basic::custom() - .which_scenario(which) - .max_concurrent_scenarios(64), - ) + .with_runner(runner::Basic::default()) .with_writer(writer::Basic::new().normalized().summarized()) } } @@ -840,7 +827,7 @@ impl Cucumber { } } -impl Cucumber, Wr> { +impl Cucumber, Wr> { /// If `max` is [`Some`] number of concurrently executed [`Scenario`]s will /// be limited. /// @@ -864,7 +851,7 @@ impl Cucumber, Wr> { pub fn which_scenario( self, func: Which, - ) -> Cucumber, Wr> + ) -> Cucumber, Wr> where Which: Fn( &gherkin::Feature, @@ -888,6 +875,84 @@ impl Cucumber, Wr> { } } + /// Sets a hook, executed on each [`Scenario`] before running all its + /// [`Step`]s, including [`Background`] ones. + /// + /// [`Background`]: gherkin::Background + /// [`Scenario`]: gherkin::Scenario + /// [`Step`]: gherkin::Step + #[must_use] + pub fn before( + self, + func: Before, + ) -> Cucumber, Wr> + where + Before: for<'a> Fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + &'a mut W, + ) -> LocalBoxFuture<'a, ()>, + { + let Self { + parser, + runner, + writer, + .. + } = self; + Cucumber { + parser, + runner: runner.before(func), + writer, + _world: PhantomData, + _parser_input: PhantomData, + } + } + + /// Sets a hook, executed on each [`Scenario`] after running all its + /// [`Step`]s, even after [`Skipped`] of [`Failed`] [`Step`]s. + /// + /// Last `World` argument is supplied to the function, in case it was + /// initialized before by running [`before`] hook or any non-failed + /// [`Step`]. In case the last [`Scenario`]'s [`Step`] failed, we want to + /// return event with an exact `World` state. Also, we don't want to impose + /// additional [`Clone`] bounds on `World`, so the only option left is to + /// pass [`None`] to the function. + /// + /// + /// [`before`]: Self::before() + /// [`Failed`]: event::Step::Failed + /// [`Scenario`]: gherkin::Scenario + /// [`Skipped`]: event::Step::Skipped + /// [`Step`]: gherkin::Step + #[must_use] + pub fn after( + self, + func: After, + ) -> Cucumber, Wr> + where + After: for<'a> Fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + Option<&'a mut W>, + ) -> LocalBoxFuture<'a, ()>, + { + let Self { + parser, + runner, + writer, + .. + } = self; + Cucumber { + parser, + runner: runner.after(func), + writer, + _world: PhantomData, + _parser_input: PhantomData, + } + } + /// Replaces [`Collection`] of [`Step`]s. /// /// [`Collection`]: step::Collection @@ -1027,15 +1092,36 @@ where { let writer = self.filter_run(input, filter).await; if writer.execution_has_failed() { + let mut msg = Vec::with_capacity(3); + let failed_steps = writer.failed_steps(); + if failed_steps > 0 { + msg.push(format!( + "{} step{} failed", + failed_steps, + (failed_steps > 1).then(|| "s").unwrap_or_default(), + )); + } + let parsing_errors = writer.parsing_errors(); - panic!( - "{} step{} failed, {} parsing error{}", - failed_steps, - (failed_steps != 1).then(|| "s").unwrap_or_default(), - parsing_errors, - (parsing_errors != 1).then(|| "s").unwrap_or_default(), - ); + if parsing_errors > 0 { + msg.push(format!( + "{} parsing error{}", + parsing_errors, + (parsing_errors > 1).then(|| "s").unwrap_or_default(), + )); + } + + let hook_errors = writer.hook_errors(); + if hook_errors > 0 { + msg.push(format!( + "{} hook error{}", + hook_errors, + (hook_errors > 1).then(|| "s").unwrap_or_default(), + )); + } + + panic!("{}", msg.join(", ")); } } } diff --git a/src/event.rs b/src/event.rs index 4c32e0e0..23d57439 100644 --- a/src/event.rs +++ b/src/event.rs @@ -19,7 +19,7 @@ //! [`Runner`]: crate::Runner //! [Cucumber]: https://cucumber.io -use std::{any::Any, sync::Arc}; +use std::{any::Any, fmt, sync::Arc}; /// Alias for a [`catch_unwind()`] error. /// @@ -224,6 +224,59 @@ impl Clone for Step { } } +/// Type of a hook executed before or after all [`Scenario`]'s [`Step`]s. +/// +/// [`Scenario`]: gherkin::Scenario +/// [`Step`]: gherkin::Step +#[derive(Clone, Copy, Debug)] +pub enum HookType { + /// Executing on each [`Scenario`] before running all [`Step`]s. + /// + /// [`Scenario`]: gherkin::Scenario + /// [`Step`]: gherkin::Step + Before, + + /// Executing on each [`Scenario`] after running all [`Step`]s. + /// + /// [`Scenario`]: gherkin::Scenario + /// [`Step`]: gherkin::Step + After, +} + +impl fmt::Display for HookType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}", self) + } +} + +/// Event of running [`Before`] or [`After`] hook. +/// +/// [`After`]: HookType::After +/// [`Before`]: HookType::Before +#[derive(Debug)] +pub enum Hook { + /// Hook execution being started. + Started, + + /// Hook passed. + Passed, + + /// Hook failed. + Failed(Option>, Info), +} + +// Manual implementation is required to omit the redundant `World: Clone` trait +// bound imposed by `#[derive(Clone)]`. +impl Clone for Hook { + fn clone(&self) -> Self { + match self { + Self::Started => Self::Started, + Self::Passed => Self::Passed, + Self::Failed(w, i) => Self::Failed(w.clone(), i.clone()), + } + } +} + /// Event specific to a particular [Scenario]. /// /// [Scenario]: https://cucumber.io/docs/gherkin/reference/#example @@ -234,6 +287,9 @@ pub enum Scenario { /// [`Scenario`]: gherkin::Scenario Started, + /// [`Hook`] event. + Hook(HookType, Hook), + /// [`Background`] [`Step`] event. /// /// [`Background`]: gherkin::Background @@ -254,6 +310,7 @@ impl Clone for Scenario { fn clone(&self) -> Self { match self { Self::Started => Self::Started, + Self::Hook(ty, ev) => Self::Hook(*ty, ev.clone()), Self::Background(bg, ev) => { Self::Background(bg.clone(), ev.clone()) } @@ -264,6 +321,34 @@ impl Clone for Scenario { } impl Scenario { + /// Constructs an event of a [`Scenario`] hook being started. + /// + /// [`Scenario`]: gherkin::Scenario + #[must_use] + pub fn hook_started(which: HookType) -> Self { + Self::Hook(which, Hook::Started) + } + + /// Constructs an event of a passed [`Scenario`] hook. + /// + /// [`Scenario`]: gherkin::Scenario + #[must_use] + pub fn hook_passed(which: HookType) -> Self { + Self::Hook(which, Hook::Passed) + } + + /// Constructs an event of a failed [`Scenario`] hook. + /// + /// [`Scenario`]: gherkin::Scenario + #[must_use] + pub fn hook_failed( + which: HookType, + world: Option>, + info: Info, + ) -> Self { + Self::Hook(which, Hook::Failed(world, info)) + } + /// Constructs an event of a [`Step`] being started. /// /// [`Step`]: gherkin::Step diff --git a/src/runner/basic.rs b/src/runner/basic.rs index 12bb1725..ef119ddb 100644 --- a/src/runner/basic.rs +++ b/src/runner/basic.rs @@ -13,6 +13,7 @@ use std::{ cmp, collections::HashMap, + convert::identity, fmt, mem, panic::{self, AssertUnwindSafe}, path::PathBuf, @@ -24,7 +25,7 @@ use std::{ use futures::{ channel::mpsc, - future::{self, Either}, + future::{self, Either, LocalBoxFuture}, lock::Mutex, pin_mut, stream::{self, LocalBoxStream}, @@ -35,7 +36,7 @@ use itertools::Itertools as _; use regex::{CaptureLocations, Regex}; use crate::{ - event::{self, Info}, + event::{self, HookType, Info}, feature::Ext as _, parser, step, Runner, Step, World, }; @@ -57,6 +58,40 @@ pub enum ScenarioType { Concurrent, } +/// Alias for [`fn`] used to determine whether a [`Scenario`] is [`Concurrent`] +/// or a [`Serial`] one. +/// +/// [`Concurrent`]: ScenarioType::Concurrent +/// [`Serial`]: ScenarioType::Serial +/// [`Scenario`]: gherkin::Scenario +pub type WhichScenarioFn = fn( + &gherkin::Feature, + Option<&gherkin::Rule>, + &gherkin::Scenario, +) -> ScenarioType; + +/// Alias for [`fn`] executed on each [`Scenario`] before running all [`Step`]s. +/// +/// [`Scenario`]: gherkin::Scenario +/// [`Step`]: gherkin::Step +pub type BeforeHookFn = for<'a> fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + &'a mut World, +) -> LocalBoxFuture<'a, ()>; + +/// Alias for [`fn`] executed on each [`Scenario`] after running all [`Step`]s. +/// +/// [`Scenario`]: gherkin::Scenario +/// [`Step`]: gherkin::Step +pub type AfterHookFn = for<'a> fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + Option<&'a mut World>, +) -> LocalBoxFuture<'a, ()>; + /// Default [`Runner`] implementation which follows [_order guarantees_][1] from /// the [`Runner`] trait docs. /// @@ -66,7 +101,12 @@ pub enum ScenarioType { /// /// [1]: Runner#order-guarantees /// [`Scenario`]: gherkin::Scenario -pub struct Basic { +pub struct Basic< + World, + F = WhichScenarioFn, + Before = BeforeHookFn, + After = AfterHookFn, +> { /// Optional number of concurrently executed [`Scenario`]s. /// /// [`Scenario`]: gherkin::Scenario @@ -84,23 +124,26 @@ pub struct Basic { /// [`Serial`]: ScenarioType::Serial /// [`Scenario`]: gherkin::Scenario which_scenario: F, -} -/// Alias for [`fn`] used to determine whether a [`Scenario`] is [`Concurrent`] -/// or a [`Serial`] one. -/// -/// [`Concurrent`]: ScenarioType::Concurrent -/// [`Serial`]: ScenarioType::Serial -/// [`Scenario`]: gherkin::Scenario -pub type WhichScenarioFn = fn( - &gherkin::Feature, - Option<&gherkin::Rule>, - &gherkin::Scenario, -) -> ScenarioType; + /// Function, executed on each [`Scenario`] before running all [`Step`]s, + /// including [`Background`] ones. + /// + /// [`Background`]: gherkin::Background + /// [`Scenario`]: gherkin::Scenario + /// [`Step`]: gherkin::Step + before_hook: Option, + + /// Function, executed on each [`Scenario`] after running all [`Step`]s. + /// + /// [`Background`]: gherkin::Background + /// [`Scenario`]: gherkin::Scenario + /// [`Step`]: gherkin::Step + after_hook: Option, +} // Implemented manually to omit redundant trait bounds on `World` and to omit // outputting `F`. -impl fmt::Debug for Basic { +impl fmt::Debug for Basic { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Basic") .field("max_concurrent_scenarios", &self.max_concurrent_scenarios) @@ -117,11 +160,34 @@ impl Basic { max_concurrent_scenarios: None, steps: step::Collection::new(), which_scenario: (), + before_hook: None, + after_hook: None, } } } -impl Basic { +impl Default for Basic { + fn default() -> Self { + let which_scenario: WhichScenarioFn = |_, _, scenario| { + scenario + .tags + .iter() + .any(|tag| tag == "serial") + .then(|| ScenarioType::Serial) + .unwrap_or(ScenarioType::Concurrent) + }; + + Self { + max_concurrent_scenarios: Some(64), + steps: step::Collection::new(), + which_scenario, + before_hook: None, + after_hook: None, + } + } +} + +impl Basic { /// If `max` is [`Some`], then number of concurrently executed [`Scenario`]s /// will be limited. /// @@ -142,9 +208,9 @@ impl Basic { /// [`Serial`]: ScenarioType::Serial /// [`Scenario`]: gherkin::Scenario #[must_use] - pub fn which_scenario(self, func: Which) -> Basic + pub fn which_scenario(self, func: F) -> Basic where - Which: Fn( + F: Fn( &gherkin::Feature, Option<&gherkin::Rule>, &gherkin::Scenario, @@ -154,12 +220,89 @@ impl Basic { let Self { max_concurrent_scenarios, steps, + before_hook, + after_hook, .. } = self; Basic { max_concurrent_scenarios, steps, which_scenario: func, + before_hook, + after_hook, + } + } + + /// Sets a hook, executed on each [`Scenario`] before running all its + /// [`Step`]s, including [`Background`] ones. + /// + /// [`Background`]: gherkin::Background + /// [`Scenario`]: gherkin::Scenario + /// [`Step`]: gherkin::Step + #[must_use] + pub fn before(self, func: Func) -> Basic + where + Func: for<'a> Fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + &'a mut World, + ) -> LocalBoxFuture<'a, ()>, + { + let Self { + max_concurrent_scenarios, + steps, + which_scenario, + after_hook, + .. + } = self; + Basic { + max_concurrent_scenarios, + steps, + which_scenario, + before_hook: Some(func), + after_hook, + } + } + + /// Sets hook, executed on each [`Scenario`] after running all its + /// [`Step`]s, even after [`Skipped`] of [`Failed`] ones. + /// + /// Last `World` argument is supplied to the function, in case it was + /// initialized before by running [`before`] hook or any non-failed + /// [`Step`]. In case the last [`Scenario`]'s [`Step`] failed, we want to + /// return event with an exact `World` state. Also, we don't want to impose + /// additional [`Clone`] bounds on `World`, so the only option left is to + /// pass [`None`] to the function. + /// + /// [`before`]: Self::before() + /// [`Failed`]: event::Step::Failed + /// [`Scenario`]: gherkin::Scenario + /// [`Skipped`]: event::Step::Skipped + /// [`Step`]: gherkin::Step + #[must_use] + pub fn after(self, func: Func) -> Basic + where + Func: for<'a> Fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + Option<&'a mut World>, + ) -> LocalBoxFuture<'a, ()>, + { + let Self { + max_concurrent_scenarios, + steps, + which_scenario, + before_hook, + .. + } = self; + Basic { + max_concurrent_scenarios, + steps, + which_scenario, + before_hook, + after_hook: Some(func), } } @@ -200,7 +343,7 @@ impl Basic { } } -impl Runner for Basic +impl Runner for Basic where W: World, Which: Fn( @@ -209,6 +352,20 @@ where &gherkin::Scenario, ) -> ScenarioType + 'static, + Before: for<'a> Fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + &'a mut W, + ) -> LocalBoxFuture<'a, ()> + + 'static, + After: for<'a> Fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + Option<&'a mut W>, + ) -> LocalBoxFuture<'a, ()> + + 'static, { type EventStream = LocalBoxStream<'static, parser::Result>>; @@ -221,6 +378,8 @@ where max_concurrent_scenarios, steps, which_scenario, + before_hook, + after_hook, } = self; let buffer = Features::default(); @@ -232,7 +391,14 @@ where which_scenario, sender.clone(), ); - let execute = execute(buffer, max_concurrent_scenarios, steps, sender); + let execute = execute( + buffer, + max_concurrent_scenarios, + steps, + sender, + before_hook, + after_hook, + ); stream::select( receiver.map(Either::Left), @@ -281,12 +447,30 @@ async fn insert_features( /// Retrieves [`Feature`]s and executes them. /// /// [`Feature`]: gherkin::Feature -async fn execute( +async fn execute( features: Features, max_concurrent_scenarios: Option, collection: step::Collection, sender: mpsc::UnboundedSender>>, -) { + before_hook: Option, + after_hook: Option, +) where + W: World, + Before: 'static + + for<'a> Fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + &'a mut W, + ) -> LocalBoxFuture<'a, ()>, + After: 'static + + for<'a> Fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + Option<&'a mut W>, + ) -> LocalBoxFuture<'a, ()>, +{ // Those panic hook shenanigans are done to avoid console messages like // "thread 'main' panicked at ..." // @@ -298,7 +482,8 @@ async fn execute( let hook = panic::take_hook(); panic::set_hook(Box::new(|_| {})); - let mut executor = Executor::new(collection, sender); + let mut executor = + Executor::new(collection, before_hook, after_hook, sender); executor.send(event::Cucumber::Started); @@ -334,7 +519,7 @@ async fn execute( /// completion. /// /// [`Feature`]: gherkin::Feature. -struct Executor { +struct Executor { /// Number of finished [`Scenario`]s of [`Feature`]. /// /// [`Feature`]: gherkin::Feature @@ -357,22 +542,56 @@ struct Executor { /// [`Step`]: step::Step collection: step::Collection, + /// Function, executed on each [`Scenario`] before running all [`Step`]s, + /// including [`Background`] ones. + /// + /// [`Background`]: gherkin::Background + /// [`Scenario`]: gherkin::Scenario + /// [`Step`]: gherkin::Step + before_hook: Option, + + /// Function, executed on each [`Scenario`] after running all [`Step`]s. + /// + /// [`Scenario`]: gherkin::Scenario + /// [`Step`]: gherkin::Step + after_hook: Option, + /// Sender for notifying state of [`Feature`]s completion. /// /// [`Feature`]: gherkin::Feature sender: mpsc::UnboundedSender>>, } -impl Executor { +impl Executor +where + Before: 'static + + for<'a> Fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + &'a mut W, + ) -> LocalBoxFuture<'a, ()>, + After: 'static + + for<'a> Fn( + &'a gherkin::Feature, + Option<&'a gherkin::Rule>, + &'a gherkin::Scenario, + Option<&'a mut W>, + ) -> LocalBoxFuture<'a, ()>, +{ /// Creates a new [`Executor`]. fn new( collection: step::Collection, + before_hook: Option, + after_hook: Option, sender: mpsc::UnboundedSender>>, ) -> Self { Self { features_scenarios_count: HashMap::new(), rule_scenarios_count: HashMap::new(), collection, + before_hook, + after_hook, sender, } } @@ -388,6 +607,7 @@ impl Executor { /// [`Feature`]: gherkin::Feature /// [`Rule`]: gherkin::Rule /// [`Scenario`]: gherkin::Scenario + #[allow(clippy::too_many_lines)] async fn run_scenario( &self, feature: Arc, @@ -439,7 +659,12 @@ impl Executor { event::Scenario::Started, )); - let res = async { + let world = async { + let before_hook = self + .run_before_hook(&feature, rule.as_ref(), &scenario) + .await + .map_err(|_| None)?; + let feature_background = feature .background .as_ref() @@ -449,7 +674,7 @@ impl Executor { let feature_background = stream::iter(feature_background) .map(Ok) - .try_fold(None, |world, bg_step| { + .try_fold(before_hook, |world, bg_step| { self.run_step(world, bg_step, into_bg_step_ev).map_ok(Some) }) .await?; @@ -479,9 +704,13 @@ impl Executor { self.run_step(world, step, into_step_ev).map_ok(Some) }) .await - }; + } + .await + .unwrap_or_else(identity); - drop(res.await); + self.run_after_hook(world, &feature, rule.as_ref(), &scenario) + .await + .map_or((), drop); self.send(event::Cucumber::scenario( feature.clone(), @@ -490,16 +719,138 @@ impl Executor { event::Scenario::Finished, )); - if let Some(rule) = rule { - if let Some(finished) = - self.rule_scenario_finished(feature.clone(), rule) - { - self.send(finished); + if let Some(r) = rule { + if let Some(f) = self.rule_scenario_finished(feature.clone(), r) { + self.send(f); + } + } + + if let Some(f) = self.feature_scenario_finished(feature) { + self.send(f); + } + } + + /// Executes [`HookType::Before`], if present. + async fn run_before_hook( + &self, + feature: &Arc, + rule: Option<&Arc>, + scenario: &Arc, + ) -> Result, ()> { + let init_world = async { + let world_fut = + async { W::new().await.expect("failed to initialize World") }; + AssertUnwindSafe(world_fut) + .catch_unwind() + .await + .map_err(|info| (info, None)) + }; + + if let Some(hook) = self.before_hook.as_ref() { + self.send(event::Cucumber::scenario( + feature.clone(), + rule.map(Arc::clone), + scenario.clone(), + event::Scenario::hook_started(HookType::Before), + )); + + let fut = init_world.and_then(|mut world| async { + let fut = (hook)( + feature.as_ref(), + rule.as_ref().map(AsRef::as_ref), + scenario.as_ref(), + &mut world, + ); + match AssertUnwindSafe(fut).catch_unwind().await { + Ok(()) => Ok(world), + Err(info) => Err((info, Some(world))), + } + }); + + match fut.await { + Ok(world) => { + self.send(event::Cucumber::scenario( + feature.clone(), + rule.map(Arc::clone), + scenario.clone(), + event::Scenario::hook_passed(HookType::Before), + )); + Ok(Some(world)) + } + Err((info, world)) => { + self.send(event::Cucumber::scenario( + feature.clone(), + rule.map(Arc::clone), + scenario.clone(), + event::Scenario::hook_failed( + HookType::Before, + world.map(Arc::new), + info.into(), + ), + )); + Err(()) + } } + } else { + Ok(None) } + } + + /// Executes [`HookType::After`], if present. + async fn run_after_hook( + &self, + mut world: Option, + feature: &Arc, + rule: Option<&Arc>, + scenario: &Arc, + ) -> Result, ()> { + if let Some(hook) = self.after_hook.as_ref() { + self.send(event::Cucumber::scenario( + feature.clone(), + rule.map(Arc::clone), + scenario.clone(), + event::Scenario::hook_started(HookType::After), + )); + + let fut = async { + let fut = (hook)( + feature.as_ref(), + rule.as_ref().map(AsRef::as_ref), + scenario.as_ref(), + world.as_mut(), + ); + match AssertUnwindSafe(fut).catch_unwind().await { + Ok(()) => Ok(world), + Err(info) => Err((info, world)), + } + }; - if let Some(finished) = self.feature_scenario_finished(feature) { - self.send(finished); + match fut.await { + Ok(world) => { + self.send(event::Cucumber::scenario( + feature.clone(), + rule.map(Arc::clone), + scenario.clone(), + event::Scenario::hook_passed(HookType::After), + )); + Ok(world) + } + Err((info, world)) => { + self.send(event::Cucumber::scenario( + feature.clone(), + rule.map(Arc::clone), + scenario.clone(), + event::Scenario::hook_failed( + HookType::After, + world.map(Arc::new), + info.into(), + ), + )); + Err(()) + } + } + } else { + Ok(None) } } @@ -515,7 +866,7 @@ impl Executor { mut world: Option, step: Arc, (started, passed, skipped, failed): (St, Ps, Sk, F), - ) -> Result + ) -> Result> where St: FnOnce(Arc) -> event::Cucumber, Ps: FnOnce(Arc, CaptureLocations) -> event::Cucumber, @@ -561,7 +912,7 @@ impl Executor { } Ok(None) => { self.send(skipped(step)); - Err(()) + Err(world) } Err((err, captures)) => { self.send(failed( @@ -570,7 +921,7 @@ impl Executor { world.map(Arc::new), Arc::from(err), )); - Err(()) + Err(None) } } } diff --git a/src/writer/basic.rs b/src/writer/basic.rs index 1fd52150..6246c89b 100644 --- a/src/writer/basic.rs +++ b/src/writer/basic.rs @@ -129,7 +129,7 @@ impl Basic { /// Outputs [started] [`Feature`] to STDOUT. /// /// [started]: event::Feature::Started - /// [`Feature`]: [`gherkin::Feature`] + /// [`Feature`]: gherkin::Feature fn feature_started(&mut self, feature: &gherkin::Feature) { self.lines_to_clear = 1; self.write_line( @@ -169,7 +169,7 @@ impl Basic { /// Outputs [started] [`Rule`] to STDOUT. /// /// [started]: event::Rule::Started - /// [`Rule`]: [`gherkin::Rule`] + /// [`Rule`]: gherkin::Rule fn rule_started(&mut self, rule: &gherkin::Rule) { self.lines_to_clear = 1; self.indent += 2; @@ -187,18 +187,29 @@ impl Basic { /// [background]: event::Background /// [started]: event::Scenario::Started /// [step]: event::Step + /// [`Scenario`]: gherkin::Scenario fn scenario( &mut self, feat: &gherkin::Feature, scenario: &gherkin::Scenario, ev: &event::Scenario, ) { - use event::Scenario; + use event::{Hook, Scenario}; match ev { Scenario::Started => { self.scenario_started(scenario); } + Scenario::Hook(_, Hook::Started) => { + self.indent += 4; + } + Scenario::Hook(which, Hook::Failed(world, info)) => { + self.hook_failed(feat, scenario, *which, world.as_ref(), info); + self.indent = self.indent.saturating_sub(4); + } + Scenario::Hook(_, Hook::Passed) => { + self.indent = self.indent.saturating_sub(4); + } Scenario::Background(bg, ev) => { self.background(feat, bg, ev); } @@ -209,10 +220,41 @@ impl Basic { } } + /// Outputs [failed] [`Scenario`]'s hook to STDOUT. + /// + /// [failed]: event::Hook::Failed + /// [`Scenario`]: gherkin::Scenario + fn hook_failed( + &mut self, + feat: &gherkin::Feature, + sc: &gherkin::Scenario, + which: event::HookType, + world: Option<&W>, + info: &Info, + ) { + self.clear_last_lines_if_term_present(); + + self.write_line(&self.styles.err(format!( + "{indent}\u{2718} Scenario's {} hook failed {}:{}:{}\n\ + {indent} Captured output: {}{}", + which, + feat.path + .as_ref() + .and_then(|p| p.to_str()) + .unwrap_or(&feat.name), + sc.position.line, + sc.position.col, + coerce_error(info), + format_world(world, self.indent.saturating_sub(3) + 3), + indent = " ".repeat(self.indent.saturating_sub(3)), + ))) + .unwrap(); + } + /// Outputs [started] [`Scenario`] to STDOUT. /// /// [started]: event::Scenario::Started - /// [`Scenario`]: [`gherkin::Scenario`] + /// [`Scenario`]: gherkin::Scenario fn scenario_started(&mut self, scenario: &gherkin::Scenario) { self.lines_to_clear = 1; self.indent += 2; @@ -231,7 +273,7 @@ impl Basic { /// [passed]: event::Step::Passed /// [skipped]: event::Step::Skipped /// [started]: event::Step::Started - /// [`Step`]: [`gherkin::Step`] + /// [`Step`]: gherkin::Step fn step( &mut self, feat: &gherkin::Feature, @@ -268,7 +310,7 @@ impl Basic { /// [passed]: event::Step::Passed /// [skipped]: event::Step::Skipped /// [started]: event::Step::Started - /// [`Step`]: [`gherkin::Step`] + /// [`Step`]: gherkin::Step fn step_started(&mut self, step: &gherkin::Step) { self.indent += 4; if self.styles.is_present { @@ -290,7 +332,7 @@ impl Basic { /// Outputs [passed] [`Step`] to STDOUT. /// /// [passed]: event::Step::Passed - /// [`Step`]: [`gherkin::Step`] + /// [`Step`]: gherkin::Step fn step_passed( &mut self, step: &gherkin::Step, @@ -325,7 +367,7 @@ impl Basic { /// Outputs [skipped] [`Step`] to STDOUT. /// /// [skipped]: event::Step::Skipped - /// [`Step`]: [`gherkin::Step`] + /// [`Step`]: gherkin::Step fn step_skipped(&mut self, feat: &gherkin::Feature, step: &gherkin::Step) { self.clear_last_lines_if_term_present(); self.write_line(&self.styles.skipped(format!( @@ -351,7 +393,7 @@ impl Basic { /// Outputs [failed] [`Step`] to STDOUT. /// /// [failed]: event::Step::Failed - /// [`Step`]: [`gherkin::Step`] + /// [`Step`]: gherkin::Step fn step_failed( &mut self, feat: &gherkin::Feature, @@ -413,8 +455,8 @@ impl Basic { /// [passed]: event::Step::Passed /// [skipped]: event::Step::Skipped /// [started]: event::Step::Started - /// [`Background`]: [`gherkin::Background`] - /// [`Step`]: [`gherkin::Step`] + /// [`Background`]: gherkin::Background + /// [`Step`]: gherkin::Step fn background( &mut self, feat: &gherkin::Feature, @@ -451,8 +493,8 @@ impl Basic { /// [passed]: event::Step::Passed /// [skipped]: event::Step::Skipped /// [started]: event::Step::Started - /// [`Background`]: [`gherkin::Background`] - /// [`Step`]: [`gherkin::Step`] + /// [`Background`]: gherkin::Background + /// [`Step`]: gherkin::Step fn bg_step_started(&mut self, step: &gherkin::Step) { self.indent += 4; if self.styles.is_present { @@ -474,8 +516,8 @@ impl Basic { /// Outputs [passed] [`Background`] [`Step`] to STDOUT. /// /// [passed]: event::Step::Passed - /// [`Background`]: [`gherkin::Background`] - /// [`Step`]: [`gherkin::Step`] + /// [`Background`]: gherkin::Background + /// [`Step`]: gherkin::Step fn bg_step_passed( &mut self, step: &gherkin::Step, @@ -510,8 +552,8 @@ impl Basic { /// Outputs [skipped] [`Background`] [`Step`] to STDOUT. /// /// [skipped]: event::Step::Skipped - /// [`Background`]: [`gherkin::Background`] - /// [`Step`]: [`gherkin::Step`] + /// [`Background`]: gherkin::Background + /// [`Step`]: gherkin::Step fn bg_step_skipped( &mut self, feat: &gherkin::Feature, @@ -541,8 +583,8 @@ impl Basic { /// Outputs [failed] [`Background`] [`Step`] to STDOUT. /// /// [failed]: event::Step::Failed - /// [`Background`]: [`gherkin::Background`] - /// [`Step`]: [`gherkin::Step`] + /// [`Background`]: gherkin::Background + /// [`Step`]: gherkin::Step fn bg_step_failed( &mut self, feat: &gherkin::Feature, diff --git a/src/writer/fail_on_skipped.rs b/src/writer/fail_on_skipped.rs index 6bbcf402..3cbae312 100644 --- a/src/writer/fail_on_skipped.rs +++ b/src/writer/fail_on_skipped.rs @@ -120,6 +120,10 @@ where fn parsing_errors(&self) -> usize { self.writer.parsing_errors() } + + fn hook_errors(&self) -> usize { + self.writer.hook_errors() + } } impl From for FailOnSkipped { diff --git a/src/writer/mod.rs b/src/writer/mod.rs index 4626506a..1b708cef 100644 --- a/src/writer/mod.rs +++ b/src/writer/mod.rs @@ -83,6 +83,12 @@ pub trait Failure: Writer { /// Returns number of parsing errors. #[must_use] fn parsing_errors(&self) -> usize; + + /// Returns number of failed [`Scenario`] hooks. + /// + /// [`Scenario`]: gherkin::Scenario + #[must_use] + fn hook_errors(&self) -> usize; } /// Extension of [`Writer`] allowing its normalization and summarization. diff --git a/src/writer/normalized.rs b/src/writer/normalized.rs index 1a403245..a7434281 100644 --- a/src/writer/normalized.rs +++ b/src/writer/normalized.rs @@ -134,6 +134,10 @@ where fn parsing_errors(&self) -> usize { self.writer.parsing_errors() } + + fn hook_errors(&self) -> usize { + self.writer.hook_errors() + } } /// Normalization queue for incoming events. diff --git a/src/writer/repeat.rs b/src/writer/repeat.rs index 15e8d850..23b4916a 100644 --- a/src/writer/repeat.rs +++ b/src/writer/repeat.rs @@ -94,6 +94,10 @@ where fn parsing_errors(&self) -> usize { self.writer.parsing_errors() } + + fn hook_errors(&self) -> usize { + self.writer.hook_errors() + } } impl Repeat { @@ -151,7 +155,7 @@ impl Repeat { /// [`Parser`]: crate::Parser #[must_use] pub fn failed(writer: Wr) -> Self { - use event::{Cucumber, Feature, Rule, Scenario, Step}; + use event::{Cucumber, Feature, Hook, Rule, Scenario, Step}; Self { writer, @@ -166,11 +170,13 @@ impl Repeat { _, Scenario::Step(_, Step::Failed(..)) | Scenario::Background(_, Step::Failed(..)) + | Scenario::Hook(_, Hook::Failed(..)) ) ) | Feature::Scenario( _, Scenario::Step(_, Step::Failed(..)) | Scenario::Background(_, Step::Failed(..)) + | Scenario::Hook(_, Hook::Failed(..)) ) )) | Err(_) ) diff --git a/src/writer/summarized.rs b/src/writer/summarized.rs index 6f383610..d886f7b2 100644 --- a/src/writer/summarized.rs +++ b/src/writer/summarized.rs @@ -10,7 +10,7 @@ //! [`Writer`]-wrapper for collecting a summary of execution. -use std::{array, borrow::Cow, collections::HashSet, sync::Arc}; +use std::{array, borrow::Cow, collections::HashMap, sync::Arc}; use async_trait::async_trait; use derive_more::Deref; @@ -57,6 +57,34 @@ impl Stats { } } +/// Alias for [`fn`] used to determine should [`Skipped`] test considered as +/// [`Failed`] or not. +/// +/// [`Failed`]: event::Step::Failed +/// [`Skipped`]: event::Step::Skipped +pub type SkipFn = + fn(&gherkin::Feature, Option<&gherkin::Rule>, &gherkin::Scenario) -> bool; + +/// Indicator of a [`Failed`] or [`Skipped`] [`Scenario`]. +/// +/// [`Failed`]: event::Step::Failed +/// [`Scenario`]: gherkin::Scenario +/// [`Skipped`]: event::Step::Skipped +#[derive(Clone, Copy, Debug)] +enum Indicator { + /// [`Failed`] [`Scenario`]. + /// + /// [`Failed`]: event::Step::Failed + /// [`Scenario`]: gherkin::Scenario + Failed, + + /// [`Skipped`] [`Scenario`]. + /// + /// [`Scenario`]: gherkin::Scenario + /// [`Skipped`]: event::Step::Skipped + Skipped, +} + /// Wrapper for a [`Writer`] for outputting an execution summary (number of /// executed features, scenarios, steps and parsing errors). /// @@ -96,20 +124,17 @@ pub struct Summarized { /// [`Parser`]: crate::Parser pub parsing_errors: usize, + /// Number of failed [`Scenario`] hooks. + /// + /// [`Scenario`]: gherkin::Scenario + pub failed_hooks: usize, + /// Handled [`Scenario`]s to collect [`Stats`]. /// /// [`Scenario`]: gherkin::Scenario - handled_scenarios: HashSet>, + handled_scenarios: HashMap, Indicator>, } -/// Alias for [`fn`] used to determine should [`Skipped`] test considered as -/// [`Failed`] or not. -/// -/// [`Failed`]: event::Step::Failed -/// [`Skipped`]: event::Step::Skipped -pub type SkipFn = - fn(&gherkin::Feature, Option<&gherkin::Rule>, &gherkin::Scenario) -> bool; - #[async_trait(?Send)] impl Writer for Summarized where @@ -173,6 +198,10 @@ where fn parsing_errors(&self) -> usize { self.parsing_errors } + + fn hook_errors(&self) -> usize { + self.failed_hooks + } } impl From for Summarized { @@ -192,7 +221,8 @@ impl From for Summarized { failed: 0, }, parsing_errors: 0, - handled_scenarios: HashSet::new(), + failed_hooks: 0, + handled_scenarios: HashMap::new(), } } } @@ -206,7 +236,10 @@ impl Summarized { scenario: &Arc, ev: &event::Step, ) { - use event::Step; + use self::{ + event::Step, + Indicator::{Failed, Skipped}, + }; match ev { Step::Started => {} @@ -214,12 +247,13 @@ impl Summarized { Step::Skipped => { self.steps.skipped += 1; self.scenarios.skipped += 1; - let _ = self.handled_scenarios.insert(scenario.clone()); + let _ = + self.handled_scenarios.insert(scenario.clone(), Skipped); } Step::Failed(..) => { self.steps.failed += 1; self.scenarios.failed += 1; - let _ = self.handled_scenarios.insert(scenario.clone()); + let _ = self.handled_scenarios.insert(scenario.clone(), Failed); } } } @@ -232,15 +266,38 @@ impl Summarized { scenario: &Arc, ev: &event::Scenario, ) { - use event::Scenario; + use event::{Hook, Scenario}; match ev { - Scenario::Started => {} + Scenario::Started + | Scenario::Hook(_, Hook::Passed | Hook::Started) => {} + Scenario::Hook(_, Hook::Failed(..)) => { + // - If Scenario's last Step failed and then After Hook failed + // too, we don't need to track second failure; + // - If Scenario's last Step was skipped and then After Hook + // failed, we need to override skipped Scenario with failed; + // - If Scenario executed no Steps and then Hook failed, we + // track Scenario as failed. + match self.handled_scenarios.get(scenario) { + Some(Indicator::Failed) => {} + Some(Indicator::Skipped) => { + self.scenarios.skipped -= 1; + self.scenarios.failed += 1; + } + None => { + self.scenarios.failed += 1; + let _ = self + .handled_scenarios + .insert(scenario.clone(), Indicator::Failed); + } + } + self.failed_hooks += 1; + } Scenario::Background(_, ev) | Scenario::Step(_, ev) => { self.handle_step(scenario, ev); } Scenario::Finished => { - if !self.handled_scenarios.remove(scenario) { + if self.handled_scenarios.remove(scenario).is_none() { self.scenarios.passed += 1; } } @@ -281,8 +338,18 @@ impl Styles { }) .unwrap_or_default(); + let hook_errors = (summary.failed_hooks > 0) + .then(|| { + self.err(self.maybe_plural("hook error", summary.failed_hooks)) + }) + .unwrap_or_default(); + + let comma = (!parsing_errors.is_empty() && !hook_errors.is_empty()) + .then(|| self.err(", ")) + .unwrap_or_default(); + format!( - "{}\n{}\n{}{}{}\n{}{}\n{}", + "{}\n{}\n{}{}{}\n{}{}\n{}{}{}", self.bold(self.header("[Summary]")), features, rules, @@ -291,6 +358,8 @@ impl Styles { steps, steps_stats, parsing_errors, + comma, + hook_errors ) .trim_end_matches('\n') .to_string() diff --git a/tests/wait.rs b/tests/wait.rs index c947d7a0..ec87e380 100644 --- a/tests/wait.rs +++ b/tests/wait.rs @@ -7,7 +7,18 @@ use tokio::time; #[tokio::main] async fn main() { - let res = World::run("tests/features/wait"); + let res = World::cucumber() + .before(|_, _, _, w| { + async move { + w.0 = 0; + time::sleep(Duration::from_millis(10)).await; + } + .boxed_local() + }) + .after(|_, _, _, _| { + time::sleep(Duration::from_millis(10)).boxed_local() + }) + .run_and_exit("tests/features/wait"); let err = AssertUnwindSafe(res) .catch_unwind()