use std::{fmt::Debug, io, mem, time::SystemTime};
use junit_report::{
Duration, Report, TestCase, TestCaseBuilder, TestSuite, TestSuiteBuilder,
};
use crate::{
Event, World, Writer, event, parser,
writer::{
self, Ext as _, Verbosity,
basic::{Coloring, coerce_error, trim_path},
discard,
out::WritableString,
},
};
const WRAP_ADVICE: &str = "Consider wrapping `Writer` into `writer::Normalize`";
#[derive(Clone, Copy, Debug, Default, clap::Args)]
#[group(skip)]
pub struct Cli {
#[arg(id = "junit-v", long = "junit-v", value_name = "0|1", global = true)]
pub verbose: Option<u8>,
}
#[derive(Debug)]
pub struct JUnit<W, Out: io::Write> {
output: Out,
report: Report,
suit: Option<TestSuite>,
scenario_started_at: Option<SystemTime>,
events: Vec<event::RetryableScenario<W>>,
verbosity: Verbosity,
}
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,
}
}
}
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,
event: parser::Result<Event<event::Cucumber<W>>>,
cli: &Self::Cli,
) {
use event::{Cucumber, Feature, Rule};
self.apply_cli(*cli);
match event.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> {
#[must_use]
pub fn new(
output: Out,
verbosity: impl Into<Verbosity>,
) -> writer::Normalize<W, Self> {
Self::raw(output, verbosity).normalized()
}
#[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()
}
#[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![],
verbosity: verbosity.into(),
}
}
pub const fn apply_cli(&mut self, cli: Cli) {
match cli.verbose {
None => {}
Some(0) => self.verbosity = Verbosity::Default,
_ => self.verbosity = Verbosity::ShowWorld,
}
}
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(),
);
}
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::Log(_)
| 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);
}
}
}
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::Log(_)
| 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::Log(_)
| 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,
);
}
};
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
}
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}",
)
})
}
}