From ab430cc98b5a49906f5471b6fe9f2fe944d50cc3 Mon Sep 17 00:00:00 2001 From: ilslv <47687266+ilslv@users.noreply.github.com> Date: Mon, 15 Nov 2021 21:01:06 +0300 Subject: [PATCH] Implement outputting in Cucumber JSON format (#159, #127) - impl `writer::Json` behind `output-json` Cargo feature --- .github/workflows/ci.yml | 7 +- CHANGELOG.md | 7 +- Cargo.toml | 14 +- README.md | 2 + book/src/Features.md | 44 ++ book/tests/Cargo.toml | 2 +- src/writer/json.rs | 663 +++++++++++++++++++++ src/writer/mod.rs | 5 + tests/features/wait/nested/rule.feature | 1 + tests/features/wait/outline.feature | 2 +- tests/features/wait/rule.feature | 1 + tests/json.rs | 81 +++ tests/json/correct.json | 752 ++++++++++++++++++++++++ 13 files changed, 1575 insertions(+), 6 deletions(-) create mode 100644 src/writer/json.rs create mode 100644 tests/json.rs create mode 100644 tests/json/correct.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ac17e8b..f7c49926 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -61,7 +61,12 @@ jobs: strategy: fail-fast: false matrix: - feature: ["", "macros", "timestamps", "output-junit"] + feature: + - + - macros + - timestamps + - output-json + - output-junit runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3406678d..0a123f08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,12 +14,15 @@ All user visible changes to `cucumber` crate will be documented in this file. Th ### Added - Ability for step functions to return `Result`. ([#151]) -- Arbitrary output for `writer::Basic` ([#147]) -- `writer::JUnit` ([JUnit XML report][0110-1]) behind the `output-junit` feature flag ([#147]) +- Arbitrary output for `writer::Basic`. ([#147]) +- `writer::JUnit` ([JUnit XML report][0110-1]) behind the `output-junit` feature flag. ([#147]) +- `writer::Json` ([Cucumber JSON format][0110-2]) behind the `output-json` feature flag. ([#159]) [#147]: /../../pull/147 [#151]: /../../pull/151 +[#159]: /../../pull/159 [0110-1]: https://llg.cubic.org/docs/junit +[0110-2]: https://github.com/cucumber/cucumber-json-schema diff --git a/Cargo.toml b/Cargo.toml index 8e719c13..e3257964 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ repository = "https://github.com/cucumber-rs/cucumber" readme = "README.md" categories = ["asynchronous", "development-tools::testing"] keywords = ["cucumber", "testing", "bdd", "atdd", "async"] -include = ["/src/", "/tests/junit.rs", "/tests/wait.rs", "/LICENSE-*", "/README.md", "/CHANGELOG.md"] +include = ["/src/", "/tests/json.rs", "/tests/junit.rs", "/tests/wait.rs", "/LICENSE-*", "/README.md", "/CHANGELOG.md"] [package.metadata.docs.rs] all-features = true @@ -29,6 +29,8 @@ rustdoc-args = ["--cfg", "docsrs"] default = ["macros"] # Enables step attributes and auto-wiring. macros = ["cucumber-codegen", "inventory"] +# Enables support for outputting in Cucumber JSON format. +output-json = ["Inflector", "serde", "serde_json", "timestamps"] # Enables support for outputting JUnit XML report. output-junit = ["junit-report", "timestamps"] # Enables timestamps collecting for all events. @@ -54,6 +56,11 @@ structopt = "0.3.25" cucumber-codegen = { version = "0.11.0-dev", path = "./codegen", optional = true } inventory = { version = "0.1.10", optional = true } +# "output-json" feature dependencies +serde = { version = "1.0.103", features = ["derive"], optional = true } +serde_json = { version = "1.0.18", optional = true } +Inflector = { version = "0.11", default-features = false, optional = true } + # "output-junit" feature dependencies junit-report = { version = "0.7", optional = true } @@ -62,6 +69,11 @@ humantime = "2.1" tempfile = "3.2" tokio = { version = "1.12", features = ["macros", "rt-multi-thread", "time"] } +[[test]] +name = "json" +required-features = ["output-json"] +harness = false + [[test]] name = "junit" required-features = ["output-junit"] diff --git a/README.md b/README.md index 95a214e9..e8174e04 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ For more examples check out the Book ([current][1] | [edge][2]). - `macros` (default): Enables step attributes and auto-wiring. - `timestamps`: Enables timestamps collecting for all [Cucumber] events. +- `output-json` (implies `timestamps`): Enables support for outputting in [Cucumber JSON format]. - `output-junit` (implies `timestamps`): Enables support for outputting [JUnit XML report]. @@ -132,6 +133,7 @@ at your option. [Cucumber]: https://cucumber.io +[Cucumber JSON format]: https://github.com/cucumber/cucumber-json-schema [Gherkin]: https://cucumber.io/docs/gherkin/reference [JUnit XML report]: https://llg.cubic.org/docs/junit diff --git a/book/src/Features.md b/book/src/Features.md index a1013a4b..713c22cc 100644 --- a/book/src/Features.md +++ b/book/src/Features.md @@ -474,6 +474,50 @@ World::cucumber() +## Cucumber JSON format output + +Library provides an ability to output tests result in a [Cucumber JSON format]. + +Just enable `output-json` library feature in your `Cargo.toml`: +```toml +cucumber = { version = "0.11", features = ["output-json"] } +``` + +And configure [Cucumber]'s output to `writer::Json`: +```rust +# use std::{convert::Infallible, fs, io}; +# +# use async_trait::async_trait; +# use cucumber::WorldInit; +use cucumber::writer; + +# #[derive(Debug, WorldInit)] +# struct World; +# +# #[async_trait(?Send)] +# impl cucumber::World for World { +# type Error = Infallible; +# +# async fn new() -> Result { +# Ok(World) +# } +# } +# +# #[tokio::main] +# async fn main() -> io::Result<()> { +let file = fs::File::create(dbg!(format!("{}/target/schema.json", env!("CARGO_MANIFEST_DIR"))))?; +World::cucumber() + .with_writer(writer::Json::new(file)) + .run("tests/features/book") + .await; +# Ok(()) +# } +``` + + + + [Cucumber]: https://cucumber.io +[Cucumber JSON format]: https://github.com/cucumber/cucumber-json-schema [Gherkin]: https://cucumber.io/docs/gherkin [JUnit XML report]: https://llg.cubic.org/docs/junit diff --git a/book/tests/Cargo.toml b/book/tests/Cargo.toml index 8cf290a6..25283caa 100644 --- a/book/tests/Cargo.toml +++ b/book/tests/Cargo.toml @@ -11,7 +11,7 @@ publish = false [dependencies] async-trait = "0.1" -cucumber = { version = "0.11.0-dev", path = "../..", features = ["output-junit"] } +cucumber = { version = "0.11.0-dev", path = "../..", features = ["output-json", "output-junit"] } futures = "0.3" skeptic = "0.13" tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/src/writer/json.rs b/src/writer/json.rs new file mode 100644 index 00000000..9d756993 --- /dev/null +++ b/src/writer/json.rs @@ -0,0 +1,663 @@ +//! [Cucumber JSON format][1] [`Writer`] implementation. +//! +//! [1]: https://github.com/cucumber/cucumber-json-schema + +use std::{fmt::Debug, io, time::SystemTime}; + +use async_trait::async_trait; +use inflector::Inflector as _; +use serde::Serialize; + +use crate::{ + cli, event, + feature::ExpandExamplesError, + parser, + writer::{self, basic::coerce_error}, + Event, World, Writer, WriterExt as _, +}; + +/// [Cucumber JSON format][1] [`Writer`] implementation outputting JSON to an +/// [`io::Write`] implementor. +/// +/// Should be wrapped into [`writer::Normalized`] to work correctly, otherwise +/// will panic in runtime as won't be able to form [correct JSON][1]. +/// +/// [1]: https://github.com/cucumber/cucumber-json-schema +#[derive(Clone, Debug)] +pub struct Json { + /// [`io::Write`] implementor to output [JSON][1] into. + /// + /// [1]: https://github.com/cucumber/cucumber-json-schema + output: Out, + + /// Collection of [`Feature`]s to output [JSON][1] into. + /// + /// [1]: https://github.com/cucumber/cucumber-json-schema + features: Vec, + + /// [`SystemTime`] when the current [`Hook`]/[`Step`] has started. + /// + /// [`Scenario`]: gherkin::Scenario + /// [`Hook`]: event::Hook + started: Option, +} + +#[async_trait(?Send)] +impl Writer for Json { + type Cli = cli::Empty; + + async fn handle_event( + &mut self, + event: parser::Result>>, + _: &Self::Cli, + ) { + self.handle_event(event); + } +} + +impl Json { + /// Creates a new normalized [`Json`] [`Writer`] outputting [JSON][1] into + /// the given `output`. + /// + /// [1]: https://github.com/cucumber/cucumber-json-schema + #[must_use] + pub fn new(output: Out) -> writer::Normalized { + Self::raw(output).normalized() + } + + /// Creates a new raw and unnormalized [`Json`] [`Writer`] outputting + /// [JSON][1] into the given `output`. + /// + /// # Warning + /// + /// It may panic in runtime as won't be able to form [correct JSON][1] from + /// unordered [`Cucumber` events][2]. + /// + /// Use it only if you know what you're doing. Otherwise, consider using + /// [`Json::new()`] which creates an already [`Normalized`] version of + /// [`Json`] [`Writer`]. + /// + /// [`Normalized`]: writer::Normalized + /// [1]: https://github.com/cucumber/cucumber-json-schema + /// [2]: crate::event::Cucumber + #[must_use] + pub fn raw(output: Out) -> Self { + Self { + output, + features: Vec::new(), + started: None, + } + } + + /// Handles the given [`event::Cucumber`]. + fn handle_event( + &mut self, + event: parser::Result>>, + ) { + use event::{Cucumber, Rule}; + + match event.map(event::Event::split) { + Err(parser::Error::Parsing(e)) => { + let feature = Feature::parsing_err(&e); + self.features.push(feature); + } + Err(parser::Error::ExampleExpansion(e)) => { + let feature = Feature::example_expansion_err(&e); + self.features.push(feature); + } + Ok(( + Cucumber::Feature(f, event::Feature::Scenario(sc, ev)), + meta, + )) => { + self.handle_scenario_event(&f, None, &sc, ev, meta); + } + Ok(( + Cucumber::Feature( + f, + event::Feature::Rule(r, Rule::Scenario(sc, ev)), + ), + meta, + )) => { + self.handle_scenario_event(&f, Some(&r), &sc, ev, meta); + } + Ok((Cucumber::Finished, _)) => { + self.output + .write_all( + serde_json::to_string(&self.features) + .unwrap_or_else(|e| { + panic!("Failed to serialize JSON: {}", e) + }) + .as_bytes(), + ) + .unwrap_or_else(|e| panic!("Failed to write JSON: {}", e)); + } + _ => {} + } + } + + /// Handles the given [`event::Scenario`]. + fn handle_scenario_event( + &mut self, + feature: &gherkin::Feature, + rule: Option<&gherkin::Rule>, + scenario: &gherkin::Scenario, + ev: event::Scenario, + meta: event::Metadata, + ) { + use event::Scenario; + + match ev { + Scenario::Hook(ty, ev) => { + self.handle_hook_event(feature, rule, scenario, ty, ev, meta); + } + Scenario::Background(st, ev) => { + self.handle_step_event( + feature, + rule, + scenario, + "background", + &st, + ev, + meta, + ); + } + Scenario::Step(st, ev) => { + self.handle_step_event( + feature, rule, scenario, "scenario", &st, ev, meta, + ); + } + Scenario::Started | Scenario::Finished => {} + } + } + + /// Handles the given [`event::Hook`]. + fn handle_hook_event( + &mut self, + feature: &gherkin::Feature, + rule: Option<&gherkin::Rule>, + scenario: &gherkin::Scenario, + hook_ty: event::HookType, + event: event::Hook, + meta: event::Metadata, + ) { + use event::{Hook, HookType}; + + let mut duration = || { + let started = self.started.take().unwrap_or_else(|| { + panic!("No `Started` event for `{} Hook`", hook_ty) + }); + meta.at + .duration_since(started) + .unwrap_or_else(|e| { + panic!( + "Failed to compute duration between {:?} and {:?}: {}", + meta.at, started, e, + ); + }) + .as_nanos() + }; + + let res = match event { + Hook::Started => { + self.started = Some(meta.at); + return; + } + Hook::Passed => HookResult { + result: RunResult { + status: Status::Passed, + duration: duration(), + error_message: None, + }, + }, + Hook::Failed(_, info) => HookResult { + result: RunResult { + status: Status::Failed, + duration: duration(), + error_message: Some(coerce_error(&info).into_owned()), + }, + }, + }; + + let el = + self.mut_or_insert_element(feature, rule, scenario, "scenario"); + match hook_ty { + HookType::Before => el.before.push(res), + HookType::After => el.after.push(res), + } + } + + /// Handles the given [`event::Step`]. + #[allow(clippy::too_many_arguments)] + fn handle_step_event( + &mut self, + feature: &gherkin::Feature, + rule: Option<&gherkin::Rule>, + scenario: &gherkin::Scenario, + ty: &'static str, + step: &gherkin::Step, + event: event::Step, + meta: event::Metadata, + ) { + let mut duration = || { + let started = self.started.take().unwrap_or_else(|| { + panic!("No `Started` event for `Step` '{}'", step.value) + }); + meta.at + .duration_since(started) + .unwrap_or_else(|e| { + panic!( + "Failed to compute duration between {:?} and {:?}: {}", + meta.at, started, e, + ); + }) + .as_nanos() + }; + + let result = match event { + event::Step::Started => { + self.started = Some(meta.at); + let _ = self.mut_or_insert_element(feature, rule, scenario, ty); + return; + } + event::Step::Passed(..) => RunResult { + status: Status::Passed, + duration: duration(), + error_message: None, + }, + event::Step::Failed(_, _, err) => match err { + event::StepError::AmbiguousMatch(err) => RunResult { + status: Status::Ambiguous, + duration: duration(), + error_message: Some(err.to_string()), + }, + event::StepError::Panic(info) => RunResult { + status: Status::Failed, + duration: duration(), + error_message: Some(coerce_error(&info).into_owned()), + }, + }, + event::Step::Skipped => RunResult { + status: Status::Skipped, + duration: duration(), + error_message: None, + }, + }; + + let el = self.mut_or_insert_element(feature, rule, scenario, ty); + el.steps.push(Step { + keyword: step.keyword.clone(), + line: step.position.line, + name: step.value.clone(), + hidden: false, + result, + }); + } + + /// Inserts the given `scenario`, if not present, and then returns a mutable + /// reference to the contained value. + fn mut_or_insert_element( + &mut self, + feature: &gherkin::Feature, + rule: Option<&gherkin::Rule>, + scenario: &gherkin::Scenario, + ty: &'static str, + ) -> &mut Element { + let f_pos = self + .features + .iter() + .position(|f| f == feature) + .unwrap_or_else(|| { + self.features.push(Feature::new(feature)); + self.features.len() - 1 + }); + let f = self + .features + .get_mut(f_pos) + .unwrap_or_else(|| unreachable!()); + + let el_pos = f + .elements + .iter() + .position(|el| { + el.name + == format!( + "{}{}", + rule.map(|r| format!("{} ", r.name)) + .unwrap_or_default(), + scenario.name, + ) + && el.line == scenario.position.line + && el.r#type == ty + }) + .unwrap_or_else(|| { + f.elements.push(Element::new(feature, rule, scenario, ty)); + f.elements.len() - 1 + }); + f.elements.get_mut(el_pos).unwrap_or_else(|| unreachable!()) + } +} + +/// [`Serialize`]able tag of a [`gherkin::Feature`] or a [`gherkin::Scenario`]. +#[derive(Clone, Debug, Serialize)] +pub struct Tag { + /// Name of this [`Tag`]. + pub name: String, + + /// Line number of this [`Tag`] in a `.feature` file. + /// + /// As [`gherkin`] parser omits this info, line number is taken from + /// [`gherkin::Feature`] or [`gherkin::Scenario`]. + pub line: usize, +} + +/// Possible statuses of running [`gherkin::Step`]. +#[derive(Clone, Copy, Debug, Serialize)] +pub enum Status { + /// [`event::Step::Passed`]. + Passed, + + /// [`event::Step::Failed`] with an [`event::StepError::Panic`]. + Failed, + + /// [`event::Step::Skipped`]. + Skipped, + + /// [`event::Step::Failed`] with an [`event::StepError::AmbiguousMatch`]. + Ambiguous, + + /// Never constructed and is here only to fully describe [JSON schema][1]. + /// + /// [1]: https://github.com/cucumber/cucumber-json-schema + Undefined, + + /// Never constructed and is here only to fully describe [JSON schema][1]. + /// + /// [1]: https://github.com/cucumber/cucumber-json-schema + Pending, +} + +/// [`Serialize`]able result of running something. +#[derive(Clone, Debug, Serialize)] +pub struct RunResult { + /// [`Status`] of this running result. + pub status: Status, + + /// Execution time. + /// + /// While nowhere being documented, [`cucumber-jvm` uses nanoseconds][1]. + /// + /// [1]: https://tinyurl.com/34wry46u#L325 + pub duration: u128, + + /// Error message of [`Status::Failed`] or [`Status::Ambiguous`] (if any). + #[serde(skip_serializing_if = "Option::is_none")] + pub error_message: Option, +} + +/// [`Serialize`]able [`gherkin::Step`]. +#[derive(Clone, Debug, Serialize)] +pub struct Step { + /// [`gherkin::Step::keyword`]. + pub keyword: String, + + /// [`gherkin::Step`] line number in a `.feature` file. + pub line: usize, + + /// [`gherkin::Step::value`]. + pub name: String, + + /// Never [`true`] and is here only to fully describe a [JSON schema][1]. + /// + /// [1]: https://github.com/cucumber/cucumber-json-schema + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub hidden: bool, + + /// [`RunResult`] of this [`Step`]. + pub result: RunResult, +} + +/// [`Serialize`]able result of running a [`Before`] or [`After`] hook. +/// +/// [`Before`]: event::HookType::Before +/// [`After`]: event::HookType::After +#[derive(Clone, Debug, Serialize)] +pub struct HookResult { + /// [`RunResult`] of the hook. + pub result: RunResult, +} + +/// [`Serialize`]able [`gherkin::Background`] or [`gherkin::Scenario`]. +#[derive(Clone, Debug, Serialize)] +pub struct Element { + /// Doesn't appear in the [JSON schema][1], but present in + /// [its generated test cases][2]. + /// + /// [1]: https://github.com/cucumber/cucumber-json-schema + /// [2]: https://github.com/cucumber/cucumber-json-testdata-generator + #[serde(skip_serializing_if = "Vec::is_empty")] + pub after: Vec, + + /// Doesn't appear in the [JSON schema][1], but present in + /// [its generated test cases][2]. + /// + /// [1]: https://github.com/cucumber/cucumber-json-schema + /// [2]: https://github.com/cucumber/cucumber-json-testdata-generator + #[serde(skip_serializing_if = "Vec::is_empty")] + pub before: Vec, + + /// [`gherkin::Scenario::keyword`]. + pub keyword: String, + + /// Type of this [`Element`]. + /// + /// Only set to `background` or `scenario`, but [JSON schema][1] doesn't + /// constraint only to those values, so maybe a subject to change. + /// + /// [1]: https://github.com/cucumber/cucumber-json-schema + pub r#type: &'static str, + + /// Identifier of this [`Element`]. Doesn't have to be unique. + pub id: String, + + /// [`gherkin::Scenario`] line number inside a `.feature` file. + pub line: usize, + + /// [`gherkin::Scenario::name`], optionally prepended with a + /// [`gherkin::Rule::name`]. + /// + /// This is done because [JSON schema][1] doesn't support [`gherkin::Rule`]s + /// at the moment. + /// + /// [1]: https://github.com/cucumber/cucumber-json-schema + pub name: String, + + /// [`gherkin::Scenario::tags`]. + pub tags: Vec, + + /// [`gherkin::Scenario`]'s [`Step`]s. + pub steps: Vec, +} + +impl Element { + /// Creates a new [`Element`] out of the given values. + fn new( + feature: &gherkin::Feature, + rule: Option<&gherkin::Rule>, + scenario: &gherkin::Scenario, + ty: &'static str, + ) -> Self { + Self { + after: Vec::new(), + before: Vec::new(), + keyword: (ty == "background") + .then(|| feature.background.as_ref().map(|bg| &bg.keyword)) + .flatten() + .unwrap_or(&scenario.keyword) + .clone(), + r#type: ty, + id: format!( + "{}{}/{}", + feature.name.to_kebab_case(), + rule.map(|r| format!("/{}", r.name.to_kebab_case())) + .unwrap_or_default(), + scenario.name.to_kebab_case(), + ), + line: scenario.position.line, + name: format!( + "{}{}", + rule.map(|r| format!("{} ", r.name)).unwrap_or_default(), + scenario.name.clone() + ), + tags: scenario + .tags + .iter() + .map(|t| Tag { + name: t.clone(), + line: scenario.position.line, + }) + .collect(), + steps: Vec::new(), + } + } +} + +/// [`Serialize`]able [`gherkin::Feature`]. +#[derive(Clone, Debug, Serialize)] +pub struct Feature { + /// [`gherkin::Feature::path`]. + pub uri: Option, + + /// [`gherkin::Feature::keyword`]. + pub keyword: String, + + /// [`gherkin::Feature::name`]. + pub name: String, + + /// [`gherkin::Feature::tags`]. + pub tags: Vec, + + /// [`gherkin::Feature`]'s [`Element`]s. + pub elements: Vec, +} + +impl Feature { + /// Creates a new [`Feature`] out of the given [`gherkin::Feature`]. + fn new(feature: &gherkin::Feature) -> Self { + Self { + uri: feature + .path + .as_ref() + .and_then(|p| p.to_str()) + .map(str::to_owned), + keyword: feature.keyword.clone(), + name: feature.name.clone(), + tags: feature + .tags + .iter() + .map(|tag| Tag { + name: tag.clone(), + line: feature.position.line, + }) + .collect(), + elements: Vec::new(), + } + } + + /// Creates a new [`Feature`] from the given [`ExpandExamplesError`]. + fn example_expansion_err(err: &ExpandExamplesError) -> Self { + Self { + uri: err + .path + .as_ref() + .and_then(|p| p.to_str()) + .map(str::to_owned), + keyword: String::new(), + name: String::new(), + tags: Vec::new(), + elements: vec![Element { + after: Vec::new(), + before: Vec::new(), + keyword: String::new(), + r#type: "scenario", + id: format!( + "failed-to-expand-examples{}", + err.path + .as_ref() + .and_then(|p| p.to_str()) + .unwrap_or_default(), + ), + line: 0, + name: String::new(), + tags: Vec::new(), + steps: vec![Step { + keyword: String::new(), + line: err.pos.line, + name: "scenario".into(), + hidden: false, + result: RunResult { + status: Status::Failed, + duration: 0, + error_message: Some(err.to_string()), + }, + }], + }], + } + } + + /// Creates a new [`Feature`] from the given [`gherkin::ParseFileError`]. + fn parsing_err(err: &gherkin::ParseFileError) -> Self { + let path = match err { + gherkin::ParseFileError::Reading { path, .. } + | gherkin::ParseFileError::Parsing { path, .. } => path, + } + .to_str() + .map(str::to_owned); + + Self { + uri: path.clone(), + keyword: String::new(), + name: String::new(), + tags: vec![], + elements: vec![Element { + after: Vec::new(), + before: Vec::new(), + keyword: String::new(), + r#type: "scenario", + id: format!( + "failed-to-parse{}", + path.as_deref().unwrap_or_default(), + ), + line: 0, + name: String::new(), + tags: Vec::new(), + steps: vec![Step { + keyword: String::new(), + line: 0, + name: "scenario".into(), + hidden: false, + result: RunResult { + status: Status::Failed, + duration: 0, + error_message: Some(err.to_string()), + }, + }], + }], + } + } +} + +impl PartialEq for Feature { + fn eq(&self, feature: &gherkin::Feature) -> bool { + self.uri + .as_ref() + .and_then(|uri| { + feature + .path + .as_ref() + .and_then(|p| p.to_str()) + .map(|path| uri == path) + }) + .unwrap_or_default() + && self.name == feature.name + } +} diff --git a/src/writer/mod.rs b/src/writer/mod.rs index 6e75ac5a..5e867ae9 100644 --- a/src/writer/mod.rs +++ b/src/writer/mod.rs @@ -14,6 +14,8 @@ pub mod basic; pub mod fail_on_skipped; +#[cfg(feature = "output-json")] +pub mod json; #[cfg(feature = "output-junit")] pub mod junit; pub mod normalized; @@ -27,6 +29,9 @@ use structopt::StructOptInternal; use crate::{event, parser, Event, World}; +#[cfg(feature = "output-json")] +#[doc(inline)] +pub use self::json::Json; #[cfg(feature = "output-junit")] #[doc(inline)] pub use self::junit::JUnit; diff --git a/tests/features/wait/nested/rule.feature b/tests/features/wait/nested/rule.feature index cd864088..c7ed6513 100644 --- a/tests/features/wait/nested/rule.feature +++ b/tests/features/wait/nested/rule.feature @@ -10,6 +10,7 @@ Feature: Basic Then 1 sec Rule: rule + @fail_before Scenario: 2 secs Given 2 secs When 2 secs diff --git a/tests/features/wait/outline.feature b/tests/features/wait/outline.feature index 67e43564..e3a82682 100644 --- a/tests/features/wait/outline.feature +++ b/tests/features/wait/outline.feature @@ -1,6 +1,6 @@ Feature: Outline - @tag + @tag @fail_after Scenario Outline: wait Given secs When secs diff --git a/tests/features/wait/rule.feature b/tests/features/wait/rule.feature index cd864088..c7ed6513 100644 --- a/tests/features/wait/rule.feature +++ b/tests/features/wait/rule.feature @@ -10,6 +10,7 @@ Feature: Basic Then 1 sec Rule: rule + @fail_before Scenario: 2 secs Given 2 secs When 2 secs diff --git a/tests/json.rs b/tests/json.rs new file mode 100644 index 00000000..6a7eef77 --- /dev/null +++ b/tests/json.rs @@ -0,0 +1,81 @@ +use std::{convert::Infallible, fs, io::Read as _}; + +use async_trait::async_trait; +use cucumber::{given, then, when, writer, WorldInit, WriterExt as _}; +use futures::FutureExt as _; +use regex::{Regex, RegexBuilder}; +use tempfile::NamedTempFile; + +#[given(regex = r"(\d+) secs?")] +#[when(regex = r"(\d+) secs?")] +#[then(regex = r"(\d+) secs?")] +fn step(world: &mut World) { + world.0 += 1; + if world.0 > 3 { + panic!("Too much!"); + } +} + +#[tokio::main] +async fn main() { + let mut file = NamedTempFile::new().unwrap(); + drop( + World::cucumber() + .before(|_, _, sc, _| { + async { + if sc.tags.iter().any(|t| t == "fail_before") { + panic!("Tag!"); + } + } + .boxed_local() + }) + .after(|_, _, sc, _| { + async { + if sc.tags.iter().any(|t| t == "fail_after") { + panic!("Tag!"); + } + } + .boxed_local() + }) + .with_writer(writer::Json::new(file.reopen().unwrap())) + .run("tests/features/wait") + .await, + ); + + let mut buffer = String::new(); + file.read_to_string(&mut buffer).unwrap(); + + // Required to strip out non-deterministic parts of output, so we could + // compare them well. + let non_deterministic = RegexBuilder::new( + "\"uri\":\\s?\"[^\"]*\"\ + |\"duration\":\\s?\\d+\ + |\"id\":\\s?\"failed[^\"]*\"\ + |\"error_message\":\\s?\"Could[^\"]*\"\ + |\n\ + |\\s", + ) + .multi_line(true) + .build() + .unwrap(); + + assert_eq!( + non_deterministic.replace_all(&buffer, ""), + non_deterministic.replace_all( + &fs::read_to_string("tests/json/correct.json").unwrap(), + "", + ), + ); +} + +#[derive(Clone, Copy, Debug, WorldInit)] +struct World(usize); + +#[async_trait(?Send)] +impl cucumber::World for World { + type Error = Infallible; + + async fn new() -> Result { + Ok(World(0)) + } +} diff --git a/tests/json/correct.json b/tests/json/correct.json new file mode 100644 index 00000000..c71f3c8b --- /dev/null +++ b/tests/json/correct.json @@ -0,0 +1,752 @@ +[ + { + "uri": "/Users/work/Work/cucumber/tests/features/wait/invalid.feature", + "keyword": "", + "name": "", + "tags": [], + "elements": [ + { + "keyword": "", + "type": "scenario", + "id": "failed-to-parse/Users/work/Work/cucumber/tests/features/wait/invalid.feature", + "line": 0, + "name": "", + "tags": [], + "steps": [ + { + "keyword": "", + "line": 0, + "name": "scenario", + "result": { + "status": "Failed", + "duration": 0, + "error_message": "Could not parse feature file: /Users/work/Work/cucumber/tests/features/wait/invalid.feature" + } + } + ] + } + ] + }, + { + "uri": "/Users/work/Work/cucumber/tests/features/wait/rule.feature", + "keyword": "Feature", + "name": "Basic", + "tags": [], + "elements": [ + { + "after": [ + { + "result": { + "status": "Passed", + "duration": 4000 + } + } + ], + "before": [ + { + "result": { + "status": "Passed", + "duration": 15000 + } + } + ], + "keyword": "Scenario", + "type": "scenario", + "id": "basic/1-sec", + "line": 6, + "name": "1 sec", + "tags": [ + { + "name": "serial", + "line": 6 + } + ], + "steps": [ + { + "keyword": "Given", + "line": 7, + "name": "1 sec", + "result": { + "status": "Passed", + "duration": 27000 + } + }, + { + "keyword": "When", + "line": 8, + "name": "1 sec", + "result": { + "status": "Passed", + "duration": 564000 + } + }, + { + "keyword": "Then", + "line": 9, + "name": "unknown", + "result": { + "status": "Skipped", + "duration": 253000 + } + } + ] + }, + { + "keyword": "Background", + "type": "background", + "id": "basic/1-sec", + "line": 6, + "name": "1 sec", + "tags": [ + { + "name": "serial", + "line": 6 + } + ], + "steps": [ + { + "keyword": "Given", + "line": 3, + "name": "1 sec", + "result": { + "status": "Passed", + "duration": 622000 + } + } + ] + }, + { + "after": [ + { + "result": { + "status": "Passed", + "duration": 3000 + } + } + ], + "before": [ + { + "result": { + "status": "Failed", + "duration": 110000, + "error_message": "Tag!" + } + } + ], + "keyword": "Scenario", + "type": "scenario", + "id": "basic/rule/2-secs", + "line": 14, + "name": "rule 2 secs", + "tags": [ + { + "name": "fail_before", + "line": 14 + } + ], + "steps": [] + } + ] + }, + { + "uri": "/Users/work/Work/cucumber/tests/features/wait/nested/rule.feature", + "keyword": "Feature", + "name": "Basic", + "tags": [], + "elements": [ + { + "after": [ + { + "result": { + "status": "Passed", + "duration": 2000 + } + } + ], + "before": [ + { + "result": { + "status": "Passed", + "duration": 10000 + } + } + ], + "keyword": "Scenario", + "type": "scenario", + "id": "basic/1-sec", + "line": 6, + "name": "1 sec", + "tags": [ + { + "name": "serial", + "line": 6 + } + ], + "steps": [ + { + "keyword": "Given", + "line": 7, + "name": "1 sec", + "result": { + "status": "Passed", + "duration": 24000 + } + }, + { + "keyword": "When", + "line": 8, + "name": "1 sec", + "result": { + "status": "Passed", + "duration": 22000 + } + }, + { + "keyword": "Then", + "line": 9, + "name": "unknown", + "result": { + "status": "Skipped", + "duration": 8000 + } + } + ] + }, + { + "keyword": "Background", + "type": "background", + "id": "basic/1-sec", + "line": 6, + "name": "1 sec", + "tags": [ + { + "name": "serial", + "line": 6 + } + ], + "steps": [ + { + "keyword": "Given", + "line": 3, + "name": "1 sec", + "result": { + "status": "Passed", + "duration": 31000 + } + } + ] + }, + { + "after": [ + { + "result": { + "status": "Passed", + "duration": 2000 + } + } + ], + "before": [ + { + "result": { + "status": "Failed", + "duration": 16000, + "error_message": "Tag!" + } + } + ], + "keyword": "Scenario", + "type": "scenario", + "id": "basic/rule/2-secs", + "line": 14, + "name": "rule 2 secs", + "tags": [ + { + "name": "fail_before", + "line": 14 + } + ], + "steps": [] + } + ] + }, + { + "uri": "/Users/work/Work/cucumber/tests/features/wait/outline.feature", + "keyword": "Feature", + "name": "Outline", + "tags": [], + "elements": [ + { + "after": [ + { + "result": { + "status": "Failed", + "duration": 14000, + "error_message": "Tag!" + } + } + ], + "before": [ + { + "result": { + "status": "Passed", + "duration": 5000 + } + } + ], + "keyword": "Scenario Outline", + "type": "scenario", + "id": "outline/wait", + "line": 12, + "name": "wait", + "tags": [ + { + "name": "tag", + "line": 12 + }, + { + "name": "fail_after", + "line": 12 + } + ], + "steps": [ + { + "keyword": "Given", + "line": 6, + "name": "2 secs", + "result": { + "status": "Passed", + "duration": 82000 + } + }, + { + "keyword": "When", + "line": 7, + "name": "2 secs", + "result": { + "status": "Passed", + "duration": 70000 + } + }, + { + "keyword": "Then", + "line": 8, + "name": "2 secs", + "result": { + "status": "Passed", + "duration": 523000 + } + } + ] + }, + { + "after": [ + { + "result": { + "status": "Failed", + "duration": 12000, + "error_message": "Tag!" + } + } + ], + "before": [ + { + "result": { + "status": "Passed", + "duration": 5000 + } + } + ], + "keyword": "Scenario Outline", + "type": "scenario", + "id": "outline/wait", + "line": 13, + "name": "wait", + "tags": [ + { + "name": "tag", + "line": 13 + }, + { + "name": "fail_after", + "line": 13 + } + ], + "steps": [ + { + "keyword": "Given", + "line": 6, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 29000 + } + }, + { + "keyword": "When", + "line": 7, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 22000 + } + }, + { + "keyword": "Then", + "line": 8, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 21000 + } + } + ] + }, + { + "after": [ + { + "result": { + "status": "Failed", + "duration": 12000, + "error_message": "Tag!" + } + } + ], + "before": [ + { + "result": { + "status": "Passed", + "duration": 6000 + } + } + ], + "keyword": "Scenario Outline", + "type": "scenario", + "id": "outline/wait", + "line": 14, + "name": "wait", + "tags": [ + { + "name": "tag", + "line": 14 + }, + { + "name": "fail_after", + "line": 14 + } + ], + "steps": [ + { + "keyword": "Given", + "line": 6, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 32000 + } + }, + { + "keyword": "When", + "line": 7, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 22000 + } + }, + { + "keyword": "Then", + "line": 8, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 22000 + } + } + ] + }, + { + "after": [ + { + "result": { + "status": "Failed", + "duration": 12000, + "error_message": "Tag!" + } + } + ], + "before": [ + { + "result": { + "status": "Passed", + "duration": 5000 + } + } + ], + "keyword": "Scenario Outline", + "type": "scenario", + "id": "outline/wait", + "line": 15, + "name": "wait", + "tags": [ + { + "name": "tag", + "line": 15 + }, + { + "name": "fail_after", + "line": 15 + } + ], + "steps": [ + { + "keyword": "Given", + "line": 6, + "name": "5 secs", + "result": { + "status": "Passed", + "duration": 29000 + } + }, + { + "keyword": "When", + "line": 7, + "name": "5 secs", + "result": { + "status": "Passed", + "duration": 22000 + } + }, + { + "keyword": "Then", + "line": 8, + "name": "5 secs", + "result": { + "status": "Passed", + "duration": 26000 + } + } + ] + } + ] + }, + { + "uri": "/Users/work/Work/cucumber/tests/features/wait/rule_outline.feature", + "keyword": "Feature", + "name": "Rule Outline", + "tags": [], + "elements": [ + { + "after": [ + { + "result": { + "status": "Passed", + "duration": 2000 + } + } + ], + "before": [ + { + "result": { + "status": "Passed", + "duration": 6000 + } + } + ], + "keyword": "Scenario Outline", + "type": "scenario", + "id": "rule-outline/to-them-all/wait", + "line": 10, + "name": "To them all wait", + "tags": [], + "steps": [ + { + "keyword": "Given", + "line": 5, + "name": "2 secs", + "result": { + "status": "Passed", + "duration": 29000 + } + }, + { + "keyword": "When", + "line": 6, + "name": "2 secs", + "result": { + "status": "Passed", + "duration": 22000 + } + }, + { + "keyword": "Then", + "line": 7, + "name": "2 secs", + "result": { + "status": "Passed", + "duration": 21000 + } + } + ] + }, + { + "after": [ + { + "result": { + "status": "Passed", + "duration": 2000 + } + } + ], + "before": [ + { + "result": { + "status": "Passed", + "duration": 9000 + } + } + ], + "keyword": "Scenario Outline", + "type": "scenario", + "id": "rule-outline/to-them-all/wait", + "line": 11, + "name": "To them all wait", + "tags": [], + "steps": [ + { + "keyword": "Given", + "line": 5, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 29000 + } + }, + { + "keyword": "When", + "line": 6, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 22000 + } + }, + { + "keyword": "Then", + "line": 7, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 21000 + } + } + ] + }, + { + "after": [ + { + "result": { + "status": "Passed", + "duration": 2000 + } + } + ], + "before": [ + { + "result": { + "status": "Passed", + "duration": 6000 + } + } + ], + "keyword": "Scenario Outline", + "type": "scenario", + "id": "rule-outline/to-them-all/wait", + "line": 12, + "name": "To them all wait", + "tags": [], + "steps": [ + { + "keyword": "Given", + "line": 5, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 29000 + } + }, + { + "keyword": "When", + "line": 6, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 25000 + } + }, + { + "keyword": "Then", + "line": 7, + "name": "1 secs", + "result": { + "status": "Passed", + "duration": 22000 + } + } + ] + }, + { + "after": [ + { + "result": { + "status": "Passed", + "duration": 2000 + } + } + ], + "before": [ + { + "result": { + "status": "Passed", + "duration": 6000 + } + } + ], + "keyword": "Scenario Outline", + "type": "scenario", + "id": "rule-outline/to-them-all/wait", + "line": 13, + "name": "To them all wait", + "tags": [], + "steps": [ + { + "keyword": "Given", + "line": 5, + "name": "5 secs", + "result": { + "status": "Passed", + "duration": 29000 + } + }, + { + "keyword": "When", + "line": 6, + "name": "5 secs", + "result": { + "status": "Passed", + "duration": 23000 + } + }, + { + "keyword": "Then", + "line": 7, + "name": "5 secs", + "result": { + "status": "Passed", + "duration": 21000 + } + } + ] + } + ] + } +]