diff --git a/cli/tools/test.rs b/cli/tools/test/mod.rs similarity index 64% rename from cli/tools/test.rs rename to cli/tools/test/mod.rs index 902d76585a..a2abc2a32e 100644 --- a/cli/tools/test.rs +++ b/cli/tools/test/mod.rs @@ -80,6 +80,12 @@ use tokio::sync::mpsc::unbounded_channel; use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::WeakUnboundedSender; +mod reporters; +use reporters::CompoundTestReporter; +use reporters::JunitTestReporter; +use reporters::PrettyTestReporter; +use reporters::TestReporter; + /// The test mode is used to determine how a specifier is to be tested. #[derive(Debug, Clone, Eq, PartialEq)] pub enum TestMode { @@ -381,48 +387,6 @@ impl TestSummary { } } -trait TestReporter { - fn report_register(&mut self, description: &TestDescription); - fn report_plan(&mut self, plan: &TestPlan); - fn report_wait(&mut self, description: &TestDescription); - fn report_output(&mut self, output: &[u8]); - fn report_result( - &mut self, - description: &TestDescription, - result: &TestResult, - elapsed: u64, - ); - fn report_uncaught_error(&mut self, origin: &str, error: Box); - fn report_step_register(&mut self, description: &TestStepDescription); - fn report_step_wait(&mut self, description: &TestStepDescription); - fn report_step_result( - &mut self, - desc: &TestStepDescription, - result: &TestStepResult, - elapsed: u64, - tests: &IndexMap, - test_steps: &IndexMap, - ); - fn report_summary( - &mut self, - elapsed: &Duration, - tests: &IndexMap, - test_steps: &IndexMap, - ); - fn report_sigint( - &mut self, - tests_pending: &HashSet, - tests: &IndexMap, - test_steps: &IndexMap, - ); - fn flush_report( - &mut self, - elapsed: &Duration, - tests: &IndexMap, - test_steps: &IndexMap, - ) -> anyhow::Result<()>; -} - fn get_test_reporter(options: &TestSpecifiersOptions) -> Box { let pretty = Box::new(PrettyTestReporter::new( options.concurrent_jobs.get() > 1, @@ -441,881 +405,6 @@ fn get_test_reporter(options: &TestSpecifiersOptions) -> Box { } } -struct CompoundTestReporter { - test_reporters: Vec>, -} - -impl CompoundTestReporter { - fn new(test_reporters: Vec>) -> Self { - Self { test_reporters } - } -} - -impl TestReporter for CompoundTestReporter { - fn report_register(&mut self, description: &TestDescription) { - for reporter in &mut self.test_reporters { - reporter.report_register(description); - } - } - - fn report_plan(&mut self, plan: &TestPlan) { - for reporter in &mut self.test_reporters { - reporter.report_plan(plan); - } - } - - fn report_wait(&mut self, description: &TestDescription) { - for reporter in &mut self.test_reporters { - reporter.report_wait(description); - } - } - - fn report_output(&mut self, output: &[u8]) { - for reporter in &mut self.test_reporters { - reporter.report_output(output); - } - } - - fn report_result( - &mut self, - description: &TestDescription, - result: &TestResult, - elapsed: u64, - ) { - for reporter in &mut self.test_reporters { - reporter.report_result(description, result, elapsed); - } - } - - fn report_uncaught_error(&mut self, origin: &str, error: Box) { - for reporter in &mut self.test_reporters { - reporter.report_uncaught_error(origin, error.clone()); - } - } - - fn report_step_register(&mut self, description: &TestStepDescription) { - for reporter in &mut self.test_reporters { - reporter.report_step_register(description) - } - } - - fn report_step_wait(&mut self, description: &TestStepDescription) { - for reporter in &mut self.test_reporters { - reporter.report_step_wait(description) - } - } - - fn report_step_result( - &mut self, - desc: &TestStepDescription, - result: &TestStepResult, - elapsed: u64, - tests: &IndexMap, - test_steps: &IndexMap, - ) { - for reporter in &mut self.test_reporters { - reporter.report_step_result(desc, result, elapsed, tests, test_steps); - } - } - - fn report_summary( - &mut self, - elapsed: &Duration, - tests: &IndexMap, - test_steps: &IndexMap, - ) { - for reporter in &mut self.test_reporters { - reporter.report_summary(elapsed, tests, test_steps); - } - } - - fn report_sigint( - &mut self, - tests_pending: &HashSet, - tests: &IndexMap, - test_steps: &IndexMap, - ) { - for reporter in &mut self.test_reporters { - reporter.report_sigint(tests_pending, tests, test_steps); - } - } - - fn flush_report( - &mut self, - elapsed: &Duration, - tests: &IndexMap, - test_steps: &IndexMap, - ) -> anyhow::Result<()> { - let mut errors = vec![]; - for reporter in &mut self.test_reporters { - if let Err(err) = reporter.flush_report(elapsed, tests, test_steps) { - errors.push(err) - } - } - - if errors.is_empty() { - Ok(()) - } else { - bail!( - "error in one or more wrapped reporters:\n{}", - errors - .iter() - .enumerate() - .fold(String::new(), |acc, (i, err)| { - format!("{}Error #{}: {:?}\n", acc, i + 1, err) - }) - ) - } - } -} - -struct PrettyTestReporter { - parallel: bool, - echo_output: bool, - in_new_line: bool, - scope_test_id: Option, - cwd: Url, - did_have_user_output: bool, - started_tests: bool, - child_results_buffer: - HashMap>, - summary: TestSummary, -} - -impl PrettyTestReporter { - fn new(parallel: bool, echo_output: bool) -> PrettyTestReporter { - PrettyTestReporter { - parallel, - echo_output, - in_new_line: true, - scope_test_id: None, - cwd: Url::from_directory_path(std::env::current_dir().unwrap()).unwrap(), - did_have_user_output: false, - started_tests: false, - child_results_buffer: Default::default(), - summary: TestSummary::new(), - } - } - - fn force_report_wait(&mut self, description: &TestDescription) { - if !self.in_new_line { - println!(); - } - if self.parallel { - print!( - "{}", - colors::gray(format!( - "{} => ", - self.to_relative_path_or_remote_url(&description.origin) - )) - ); - } - print!("{} ...", description.name); - self.in_new_line = false; - // flush for faster feedback when line buffered - std::io::stdout().flush().unwrap(); - self.scope_test_id = Some(description.id); - } - - fn to_relative_path_or_remote_url(&self, path_or_url: &str) -> String { - let url = Url::parse(path_or_url).unwrap(); - if url.scheme() == "file" { - if let Some(mut r) = self.cwd.make_relative(&url) { - if !r.starts_with("../") { - r = format!("./{r}"); - } - return r; - } - } - path_or_url.to_string() - } - - fn force_report_step_wait(&mut self, description: &TestStepDescription) { - self.write_output_end(); - if !self.in_new_line { - println!(); - } - print!("{}{} ...", " ".repeat(description.level), description.name); - self.in_new_line = false; - // flush for faster feedback when line buffered - std::io::stdout().flush().unwrap(); - self.scope_test_id = Some(description.id); - } - - fn force_report_step_result( - &mut self, - description: &TestStepDescription, - result: &TestStepResult, - elapsed: u64, - ) { - self.write_output_end(); - if self.in_new_line || self.scope_test_id != Some(description.id) { - self.force_report_step_wait(description); - } - - if !self.parallel { - let child_results = self - .child_results_buffer - .remove(&description.id) - .unwrap_or_default(); - for (desc, result, elapsed) in child_results.values() { - self.force_report_step_result(desc, result, *elapsed); - } - if !child_results.is_empty() { - self.force_report_step_wait(description); - } - } - - let status = match &result { - TestStepResult::Ok => colors::green("ok").to_string(), - TestStepResult::Ignored => colors::yellow("ignored").to_string(), - TestStepResult::Failed(failure) => failure.format_label(), - }; - print!(" {}", status); - if let TestStepResult::Failed(failure) = result { - if let Some(inline_summary) = failure.format_inline_summary() { - print!(" ({})", inline_summary) - } - } - if !matches!(result, TestStepResult::Failed(TestFailure::Incomplete)) { - print!( - " {}", - colors::gray(format!("({})", display::human_elapsed(elapsed.into()))) - ); - } - println!(); - self.in_new_line = true; - if self.parallel { - self.scope_test_id = None; - } else { - self.scope_test_id = Some(description.parent_id); - } - self - .child_results_buffer - .entry(description.parent_id) - .or_default() - .remove(&description.id); - } - - fn write_output_end(&mut self) { - if self.did_have_user_output { - println!("{}", colors::gray("----- output end -----")); - self.in_new_line = true; - self.did_have_user_output = false; - } - } - - fn format_test_step_ancestry( - &self, - desc: &TestStepDescription, - tests: &IndexMap, - test_steps: &IndexMap, - ) -> String { - let root; - let mut ancestor_names = vec![]; - let mut current_desc = desc; - loop { - if let Some(step_desc) = test_steps.get(¤t_desc.parent_id) { - ancestor_names.push(&step_desc.name); - current_desc = step_desc; - } else { - root = tests.get(¤t_desc.parent_id).unwrap(); - break; - } - } - ancestor_names.reverse(); - let mut result = String::new(); - result.push_str(&root.name); - result.push_str(" ... "); - for name in ancestor_names { - result.push_str(name); - result.push_str(" ... "); - } - result.push_str(&desc.name); - result - } - - fn format_test_for_summary(&self, desc: &TestDescription) -> String { - format!( - "{} {}", - &desc.name, - colors::gray(format!( - "=> {}:{}:{}", - self.to_relative_path_or_remote_url(&desc.location.file_name), - desc.location.line_number, - desc.location.column_number - )) - ) - } - - fn format_test_step_for_summary( - &self, - desc: &TestStepDescription, - tests: &IndexMap, - test_steps: &IndexMap, - ) -> String { - let long_name = self.format_test_step_ancestry(desc, tests, test_steps); - format!( - "{} {}", - long_name, - colors::gray(format!( - "=> {}:{}:{}", - self.to_relative_path_or_remote_url(&desc.location.file_name), - desc.location.line_number, - desc.location.column_number - )) - ) - } -} - -impl TestReporter for PrettyTestReporter { - fn report_register(&mut self, _description: &TestDescription) {} - fn report_plan(&mut self, plan: &TestPlan) { - self.summary.total += plan.total; - self.summary.filtered_out += plan.filtered_out; - if self.parallel { - return; - } - let inflection = if plan.total == 1 { "test" } else { "tests" }; - println!( - "{}", - colors::gray(format!( - "running {} {} from {}", - plan.total, - inflection, - self.to_relative_path_or_remote_url(&plan.origin) - )) - ); - self.in_new_line = true; - } - - fn report_wait(&mut self, description: &TestDescription) { - if !self.parallel { - self.force_report_wait(description); - } - self.started_tests = true; - } - - fn report_output(&mut self, output: &[u8]) { - if !self.echo_output { - return; - } - - if !self.did_have_user_output && self.started_tests { - self.did_have_user_output = true; - if !self.in_new_line { - println!(); - } - println!("{}", colors::gray("------- output -------")); - self.in_new_line = true; - } - - // output everything to stdout in order to prevent - // stdout and stderr racing - std::io::stdout().write_all(output).unwrap(); - } - - fn report_result( - &mut self, - description: &TestDescription, - result: &TestResult, - elapsed: u64, - ) { - match &result { - TestResult::Ok => { - self.summary.passed += 1; - } - TestResult::Ignored => { - self.summary.ignored += 1; - } - TestResult::Failed(failure) => { - self.summary.failed += 1; - self - .summary - .failures - .push((description.clone(), failure.clone())); - } - TestResult::Cancelled => { - self.summary.failed += 1; - } - } - - if self.parallel { - self.force_report_wait(description); - } - - self.write_output_end(); - if self.in_new_line || self.scope_test_id != Some(description.id) { - self.force_report_wait(description); - } - - let status = match result { - TestResult::Ok => colors::green("ok").to_string(), - TestResult::Ignored => colors::yellow("ignored").to_string(), - TestResult::Failed(failure) => failure.format_label(), - TestResult::Cancelled => colors::gray("cancelled").to_string(), - }; - print!(" {}", status); - if let TestResult::Failed(failure) = result { - if let Some(inline_summary) = failure.format_inline_summary() { - print!(" ({})", inline_summary) - } - } - println!( - " {}", - colors::gray(format!("({})", display::human_elapsed(elapsed.into()))) - ); - self.in_new_line = true; - self.scope_test_id = None; - } - - fn report_uncaught_error(&mut self, origin: &str, error: Box) { - self.summary.failed += 1; - self - .summary - .uncaught_errors - .push((origin.to_string(), error)); - - if !self.in_new_line { - println!(); - } - println!( - "Uncaught error from {} {}", - self.to_relative_path_or_remote_url(origin), - colors::red("FAILED") - ); - self.in_new_line = true; - self.did_have_user_output = false; - } - - fn report_step_register(&mut self, _description: &TestStepDescription) {} - - fn report_step_wait(&mut self, description: &TestStepDescription) { - if !self.parallel && self.scope_test_id == Some(description.parent_id) { - self.force_report_step_wait(description); - } - } - - fn report_step_result( - &mut self, - desc: &TestStepDescription, - result: &TestStepResult, - elapsed: u64, - tests: &IndexMap, - test_steps: &IndexMap, - ) { - match &result { - TestStepResult::Ok => { - self.summary.passed_steps += 1; - } - TestStepResult::Ignored => { - self.summary.ignored_steps += 1; - } - TestStepResult::Failed(failure) => { - self.summary.failed_steps += 1; - self.summary.failures.push(( - TestDescription { - id: desc.id, - name: self.format_test_step_ancestry(desc, tests, test_steps), - ignore: false, - only: false, - origin: desc.origin.clone(), - location: desc.location.clone(), - }, - failure.clone(), - )) - } - } - - if self.parallel { - self.write_output_end(); - print!( - "{} {} ...", - colors::gray(format!( - "{} =>", - self.to_relative_path_or_remote_url(&desc.origin) - )), - self.format_test_step_ancestry(desc, tests, test_steps) - ); - self.in_new_line = false; - self.scope_test_id = Some(desc.id); - self.force_report_step_result(desc, result, elapsed); - } else { - let sibling_results = - self.child_results_buffer.entry(desc.parent_id).or_default(); - if self.scope_test_id == Some(desc.id) - || self.scope_test_id == Some(desc.parent_id) - { - let sibling_results = std::mem::take(sibling_results); - self.force_report_step_result(desc, result, elapsed); - // Flush buffered sibling results. - for (desc, result, elapsed) in sibling_results.values() { - self.force_report_step_result(desc, result, *elapsed); - } - } else { - sibling_results - .insert(desc.id, (desc.clone(), result.clone(), elapsed)); - } - } - } - - fn report_summary( - &mut self, - elapsed: &Duration, - _tests: &IndexMap, - _test_steps: &IndexMap, - ) { - if !self.summary.failures.is_empty() - || !self.summary.uncaught_errors.is_empty() - { - #[allow(clippy::type_complexity)] // Type alias doesn't look better here - let mut failures_by_origin: BTreeMap< - String, - (Vec<(&TestDescription, &TestFailure)>, Option<&JsError>), - > = BTreeMap::default(); - let mut failure_titles = vec![]; - for (description, failure) in &self.summary.failures { - let (failures, _) = failures_by_origin - .entry(description.origin.clone()) - .or_default(); - failures.push((description, failure)); - } - - for (origin, js_error) in &self.summary.uncaught_errors { - let (_, uncaught_error) = - failures_by_origin.entry(origin.clone()).or_default(); - let _ = uncaught_error.insert(js_error.as_ref()); - } - // note: the trailing whitespace is intentional to get a red background - println!("\n{}\n", colors::white_bold_on_red(" ERRORS ")); - for (origin, (failures, uncaught_error)) in failures_by_origin { - for (description, failure) in failures { - if !failure.hide_in_summary() { - let failure_title = self.format_test_for_summary(description); - println!("{}", &failure_title); - println!("{}: {}", colors::red_bold("error"), failure.to_string()); - println!(); - failure_titles.push(failure_title); - } - } - if let Some(js_error) = uncaught_error { - let failure_title = format!( - "{} (uncaught error)", - self.to_relative_path_or_remote_url(&origin) - ); - println!("{}", &failure_title); - println!( - "{}: {}", - colors::red_bold("error"), - format_test_error(js_error) - ); - println!("This error was not caught from a test and caused the test runner to fail on the referenced module."); - println!("It most likely originated from a dangling promise, event/timeout handler or top-level code."); - println!(); - failure_titles.push(failure_title); - } - } - // note: the trailing whitespace is intentional to get a red background - println!("{}\n", colors::white_bold_on_red(" FAILURES ")); - for failure_title in failure_titles { - println!("{failure_title}"); - } - } - - let status = if self.summary.has_failed() { - colors::red("FAILED").to_string() - } else { - colors::green("ok").to_string() - }; - - let get_steps_text = |count: usize| -> String { - if count == 0 { - String::new() - } else if count == 1 { - " (1 step)".to_string() - } else { - format!(" ({count} steps)") - } - }; - - let mut summary_result = String::new(); - - write!( - summary_result, - "{} passed{} | {} failed{}", - self.summary.passed, - get_steps_text(self.summary.passed_steps), - self.summary.failed, - get_steps_text(self.summary.failed_steps), - ) - .unwrap(); - - let ignored_steps = get_steps_text(self.summary.ignored_steps); - if self.summary.ignored > 0 || !ignored_steps.is_empty() { - write!( - summary_result, - " | {} ignored{}", - self.summary.ignored, ignored_steps - ) - .unwrap() - } - - if self.summary.measured > 0 { - write!(summary_result, " | {} measured", self.summary.measured,).unwrap(); - } - - if self.summary.filtered_out > 0 { - write!( - summary_result, - " | {} filtered out", - self.summary.filtered_out - ) - .unwrap() - }; - - println!( - "\n{} | {} {}\n", - status, - summary_result, - colors::gray(format!( - "({})", - display::human_elapsed(elapsed.as_millis()) - )), - ); - self.in_new_line = true; - } - - fn report_sigint( - &mut self, - tests_pending: &HashSet, - tests: &IndexMap, - test_steps: &IndexMap, - ) { - if tests_pending.is_empty() { - return; - } - let mut formatted_pending = BTreeSet::new(); - for id in tests_pending { - if let Some(desc) = tests.get(id) { - formatted_pending.insert(self.format_test_for_summary(desc)); - } - if let Some(desc) = test_steps.get(id) { - formatted_pending - .insert(self.format_test_step_for_summary(desc, tests, test_steps)); - } - } - println!( - "\n{} The following tests were pending:\n", - colors::intense_blue("SIGINT") - ); - for entry in formatted_pending { - println!("{}", entry); - } - println!(); - self.in_new_line = true; - } - - fn flush_report( - &mut self, - _elapsed: &Duration, - _tests: &IndexMap, - _test_steps: &IndexMap, - ) -> anyhow::Result<()> { - Ok(()) - } -} - -struct JunitTestReporter { - path: String, - // Stores TestCases (i.e. Tests) by the Test ID - cases: IndexMap, -} - -impl JunitTestReporter { - fn new(path: String) -> Self { - Self { - path, - cases: IndexMap::new(), - } - } - - fn convert_status(status: &TestResult) -> quick_junit::TestCaseStatus { - match status { - TestResult::Ok => quick_junit::TestCaseStatus::success(), - TestResult::Ignored => quick_junit::TestCaseStatus::skipped(), - TestResult::Failed(failure) => quick_junit::TestCaseStatus::NonSuccess { - kind: quick_junit::NonSuccessKind::Failure, - message: Some(failure.to_string()), - ty: None, - description: None, - reruns: vec![], - }, - TestResult::Cancelled => quick_junit::TestCaseStatus::NonSuccess { - kind: quick_junit::NonSuccessKind::Error, - message: Some("Cancelled".to_string()), - ty: None, - description: None, - reruns: vec![], - }, - } - } -} - -impl TestReporter for JunitTestReporter { - fn report_register(&mut self, description: &TestDescription) { - self.cases.insert( - description.id, - quick_junit::TestCase::new( - description.name.clone(), - quick_junit::TestCaseStatus::skipped(), - ), - ); - } - - fn report_plan(&mut self, _plan: &TestPlan) {} - - fn report_wait(&mut self, _description: &TestDescription) {} - - fn report_output(&mut self, _output: &[u8]) { - /* - TODO(skycoop): Right now I can't include stdout/stderr in the report because - we have a global pair of output streams that don't differentiate between the - output of different tests. This is a nice to have feature, so we can come - back to it later - */ - } - - fn report_result( - &mut self, - description: &TestDescription, - result: &TestResult, - elapsed: u64, - ) { - if let Some(case) = self.cases.get_mut(&description.id) { - case.status = Self::convert_status(result); - case.set_time(Duration::from_millis(elapsed)); - } - } - - fn report_uncaught_error(&mut self, _origin: &str, _error: Box) {} - - fn report_step_register(&mut self, _description: &TestStepDescription) {} - - fn report_step_wait(&mut self, _description: &TestStepDescription) {} - - fn report_step_result( - &mut self, - description: &TestStepDescription, - result: &TestStepResult, - _elapsed: u64, - _tests: &IndexMap, - test_steps: &IndexMap, - ) { - let status = match result { - TestStepResult::Ok => "passed", - TestStepResult::Ignored => "skipped", - TestStepResult::Failed(_) => "failure", - }; - - let root_id: usize; - let mut name = String::new(); - { - let mut ancestors = vec![]; - let mut current_desc = description; - loop { - if let Some(d) = test_steps.get(¤t_desc.parent_id) { - ancestors.push(&d.name); - current_desc = d; - } else { - root_id = current_desc.parent_id; - break; - } - } - ancestors.reverse(); - for n in ancestors { - name.push_str(n); - name.push_str(" ... "); - } - name.push_str(&description.name); - } - - if let Some(case) = self.cases.get_mut(&root_id) { - case.add_property(quick_junit::Property::new( - format!("step[{}]", status), - name, - )); - } - } - - fn report_summary( - &mut self, - _elapsed: &Duration, - _tests: &IndexMap, - _test_steps: &IndexMap, - ) { - } - - fn report_sigint( - &mut self, - tests_pending: &HashSet, - tests: &IndexMap, - _test_steps: &IndexMap, - ) { - for id in tests_pending { - if let Some(description) = tests.get(id) { - self.report_result(description, &TestResult::Cancelled, 0) - } - } - } - - fn flush_report( - &mut self, - elapsed: &Duration, - tests: &IndexMap, - _test_steps: &IndexMap, - ) -> anyhow::Result<()> { - let mut suites: IndexMap = IndexMap::new(); - for (id, case) in &self.cases { - if let Some(test) = tests.get(id) { - suites - .entry(test.location.file_name.clone()) - .and_modify(|s| { - s.add_test_case(case.clone()); - }) - .or_insert_with(|| { - quick_junit::TestSuite::new(test.location.file_name.clone()) - .add_test_case(case.clone()) - .to_owned() - }); - } - } - - let mut report = quick_junit::Report::new("deno test"); - report.set_time(*elapsed).add_test_suites( - suites - .values() - .cloned() - .collect::>(), - ); - - if self.path == "-" { - report - .serialize(std::io::stdout()) - .with_context(|| "Failed to write JUnit report to stdout")?; - } else { - let file = - std::fs::File::create(self.path.clone()).with_context(|| { - format!("Failed to open JUnit report file {}", self.path) - })?; - report.serialize(file).with_context(|| { - format!("Failed to write JUnit report to {}", self.path) - })?; - } - - Ok(()) - } -} - fn abbreviate_test_error(js_error: &JsError) -> JsError { let mut js_error = js_error.clone(); let frames = std::mem::take(&mut js_error.frames); diff --git a/cli/tools/test/reporters/compound.rs b/cli/tools/test/reporters/compound.rs new file mode 100644 index 0000000000..5d03af6ef1 --- /dev/null +++ b/cli/tools/test/reporters/compound.rs @@ -0,0 +1,131 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +use super::*; + +pub struct CompoundTestReporter { + test_reporters: Vec>, +} + +impl CompoundTestReporter { + pub fn new(test_reporters: Vec>) -> Self { + Self { test_reporters } + } +} + +impl TestReporter for CompoundTestReporter { + fn report_register(&mut self, description: &TestDescription) { + for reporter in &mut self.test_reporters { + reporter.report_register(description); + } + } + + fn report_plan(&mut self, plan: &TestPlan) { + for reporter in &mut self.test_reporters { + reporter.report_plan(plan); + } + } + + fn report_wait(&mut self, description: &TestDescription) { + for reporter in &mut self.test_reporters { + reporter.report_wait(description); + } + } + + fn report_output(&mut self, output: &[u8]) { + for reporter in &mut self.test_reporters { + reporter.report_output(output); + } + } + + fn report_result( + &mut self, + description: &TestDescription, + result: &TestResult, + elapsed: u64, + ) { + for reporter in &mut self.test_reporters { + reporter.report_result(description, result, elapsed); + } + } + + fn report_uncaught_error(&mut self, origin: &str, error: Box) { + for reporter in &mut self.test_reporters { + reporter.report_uncaught_error(origin, error.clone()); + } + } + + fn report_step_register(&mut self, description: &TestStepDescription) { + for reporter in &mut self.test_reporters { + reporter.report_step_register(description) + } + } + + fn report_step_wait(&mut self, description: &TestStepDescription) { + for reporter in &mut self.test_reporters { + reporter.report_step_wait(description) + } + } + + fn report_step_result( + &mut self, + desc: &TestStepDescription, + result: &TestStepResult, + elapsed: u64, + tests: &IndexMap, + test_steps: &IndexMap, + ) { + for reporter in &mut self.test_reporters { + reporter.report_step_result(desc, result, elapsed, tests, test_steps); + } + } + + fn report_summary( + &mut self, + elapsed: &Duration, + tests: &IndexMap, + test_steps: &IndexMap, + ) { + for reporter in &mut self.test_reporters { + reporter.report_summary(elapsed, tests, test_steps); + } + } + + fn report_sigint( + &mut self, + tests_pending: &HashSet, + tests: &IndexMap, + test_steps: &IndexMap, + ) { + for reporter in &mut self.test_reporters { + reporter.report_sigint(tests_pending, tests, test_steps); + } + } + + fn flush_report( + &mut self, + elapsed: &Duration, + tests: &IndexMap, + test_steps: &IndexMap, + ) -> anyhow::Result<()> { + let mut errors = vec![]; + for reporter in &mut self.test_reporters { + if let Err(err) = reporter.flush_report(elapsed, tests, test_steps) { + errors.push(err) + } + } + + if errors.is_empty() { + Ok(()) + } else { + bail!( + "error in one or more wrapped reporters:\n{}", + errors + .iter() + .enumerate() + .fold(String::new(), |acc, (i, err)| { + format!("{}Error #{}: {:?}\n", acc, i + 1, err) + }) + ) + } + } +} diff --git a/cli/tools/test/reporters/junit.rs b/cli/tools/test/reporters/junit.rs new file mode 100644 index 0000000000..eb6479a59f --- /dev/null +++ b/cli/tools/test/reporters/junit.rs @@ -0,0 +1,194 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +use super::*; + +pub struct JunitTestReporter { + path: String, + // Stores TestCases (i.e. Tests) by the Test ID + cases: IndexMap, +} + +impl JunitTestReporter { + pub fn new(path: String) -> Self { + Self { + path, + cases: IndexMap::new(), + } + } + + fn convert_status(status: &TestResult) -> quick_junit::TestCaseStatus { + match status { + TestResult::Ok => quick_junit::TestCaseStatus::success(), + TestResult::Ignored => quick_junit::TestCaseStatus::skipped(), + TestResult::Failed(failure) => quick_junit::TestCaseStatus::NonSuccess { + kind: quick_junit::NonSuccessKind::Failure, + message: Some(failure.to_string()), + ty: None, + description: None, + reruns: vec![], + }, + TestResult::Cancelled => quick_junit::TestCaseStatus::NonSuccess { + kind: quick_junit::NonSuccessKind::Error, + message: Some("Cancelled".to_string()), + ty: None, + description: None, + reruns: vec![], + }, + } + } +} + +impl TestReporter for JunitTestReporter { + fn report_register(&mut self, description: &TestDescription) { + self.cases.insert( + description.id, + quick_junit::TestCase::new( + description.name.clone(), + quick_junit::TestCaseStatus::skipped(), + ), + ); + } + + fn report_plan(&mut self, _plan: &TestPlan) {} + + fn report_wait(&mut self, _description: &TestDescription) {} + + fn report_output(&mut self, _output: &[u8]) { + /* + TODO(skycoop): Right now I can't include stdout/stderr in the report because + we have a global pair of output streams that don't differentiate between the + output of different tests. This is a nice to have feature, so we can come + back to it later + */ + } + + fn report_result( + &mut self, + description: &TestDescription, + result: &TestResult, + elapsed: u64, + ) { + if let Some(case) = self.cases.get_mut(&description.id) { + case.status = Self::convert_status(result); + case.set_time(Duration::from_millis(elapsed)); + } + } + + fn report_uncaught_error(&mut self, _origin: &str, _error: Box) {} + + fn report_step_register(&mut self, _description: &TestStepDescription) {} + + fn report_step_wait(&mut self, _description: &TestStepDescription) {} + + fn report_step_result( + &mut self, + description: &TestStepDescription, + result: &TestStepResult, + _elapsed: u64, + _tests: &IndexMap, + test_steps: &IndexMap, + ) { + let status = match result { + TestStepResult::Ok => "passed", + TestStepResult::Ignored => "skipped", + TestStepResult::Failed(_) => "failure", + }; + + let root_id: usize; + let mut name = String::new(); + { + let mut ancestors = vec![]; + let mut current_desc = description; + loop { + if let Some(d) = test_steps.get(¤t_desc.parent_id) { + ancestors.push(&d.name); + current_desc = d; + } else { + root_id = current_desc.parent_id; + break; + } + } + ancestors.reverse(); + for n in ancestors { + name.push_str(n); + name.push_str(" ... "); + } + name.push_str(&description.name); + } + + if let Some(case) = self.cases.get_mut(&root_id) { + case.add_property(quick_junit::Property::new( + format!("step[{}]", status), + name, + )); + } + } + + fn report_summary( + &mut self, + _elapsed: &Duration, + _tests: &IndexMap, + _test_steps: &IndexMap, + ) { + } + + fn report_sigint( + &mut self, + tests_pending: &HashSet, + tests: &IndexMap, + _test_steps: &IndexMap, + ) { + for id in tests_pending { + if let Some(description) = tests.get(id) { + self.report_result(description, &TestResult::Cancelled, 0) + } + } + } + + fn flush_report( + &mut self, + elapsed: &Duration, + tests: &IndexMap, + _test_steps: &IndexMap, + ) -> anyhow::Result<()> { + let mut suites: IndexMap = IndexMap::new(); + for (id, case) in &self.cases { + if let Some(test) = tests.get(id) { + suites + .entry(test.location.file_name.clone()) + .and_modify(|s| { + s.add_test_case(case.clone()); + }) + .or_insert_with(|| { + quick_junit::TestSuite::new(test.location.file_name.clone()) + .add_test_case(case.clone()) + .to_owned() + }); + } + } + + let mut report = quick_junit::Report::new("deno test"); + report.set_time(*elapsed).add_test_suites( + suites + .values() + .cloned() + .collect::>(), + ); + + if self.path == "-" { + report + .serialize(std::io::stdout()) + .with_context(|| "Failed to write JUnit report to stdout")?; + } else { + let file = + std::fs::File::create(self.path.clone()).with_context(|| { + format!("Failed to open JUnit report file {}", self.path) + })?; + report.serialize(file).with_context(|| { + format!("Failed to write JUnit report to {}", self.path) + })?; + } + + Ok(()) + } +} diff --git a/cli/tools/test/reporters/mod.rs b/cli/tools/test/reporters/mod.rs new file mode 100644 index 0000000000..a3270ad3e6 --- /dev/null +++ b/cli/tools/test/reporters/mod.rs @@ -0,0 +1,53 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +use super::*; + +mod compound; +mod junit; +mod pretty; + +pub use compound::CompoundTestReporter; +pub use junit::JunitTestReporter; +pub use pretty::PrettyTestReporter; + +pub trait TestReporter { + fn report_register(&mut self, description: &TestDescription); + fn report_plan(&mut self, plan: &TestPlan); + fn report_wait(&mut self, description: &TestDescription); + fn report_output(&mut self, output: &[u8]); + fn report_result( + &mut self, + description: &TestDescription, + result: &TestResult, + elapsed: u64, + ); + fn report_uncaught_error(&mut self, origin: &str, error: Box); + fn report_step_register(&mut self, description: &TestStepDescription); + fn report_step_wait(&mut self, description: &TestStepDescription); + fn report_step_result( + &mut self, + desc: &TestStepDescription, + result: &TestStepResult, + elapsed: u64, + tests: &IndexMap, + test_steps: &IndexMap, + ); + fn report_summary( + &mut self, + elapsed: &Duration, + tests: &IndexMap, + test_steps: &IndexMap, + ); + fn report_sigint( + &mut self, + tests_pending: &HashSet, + tests: &IndexMap, + test_steps: &IndexMap, + ); + fn flush_report( + &mut self, + elapsed: &Duration, + tests: &IndexMap, + test_steps: &IndexMap, + ) -> anyhow::Result<()>; +} diff --git a/cli/tools/test/reporters/pretty.rs b/cli/tools/test/reporters/pretty.rs new file mode 100644 index 0000000000..e184d870c0 --- /dev/null +++ b/cli/tools/test/reporters/pretty.rs @@ -0,0 +1,559 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +use super::*; + +pub struct PrettyTestReporter { + parallel: bool, + echo_output: bool, + in_new_line: bool, + scope_test_id: Option, + cwd: Url, + did_have_user_output: bool, + started_tests: bool, + child_results_buffer: + HashMap>, + summary: TestSummary, +} + +impl PrettyTestReporter { + pub fn new(parallel: bool, echo_output: bool) -> PrettyTestReporter { + PrettyTestReporter { + parallel, + echo_output, + in_new_line: true, + scope_test_id: None, + cwd: Url::from_directory_path(std::env::current_dir().unwrap()).unwrap(), + did_have_user_output: false, + started_tests: false, + child_results_buffer: Default::default(), + summary: TestSummary::new(), + } + } + + fn force_report_wait(&mut self, description: &TestDescription) { + if !self.in_new_line { + println!(); + } + if self.parallel { + print!( + "{}", + colors::gray(format!( + "{} => ", + self.to_relative_path_or_remote_url(&description.origin) + )) + ); + } + print!("{} ...", description.name); + self.in_new_line = false; + // flush for faster feedback when line buffered + std::io::stdout().flush().unwrap(); + self.scope_test_id = Some(description.id); + } + + fn to_relative_path_or_remote_url(&self, path_or_url: &str) -> String { + let url = Url::parse(path_or_url).unwrap(); + if url.scheme() == "file" { + if let Some(mut r) = self.cwd.make_relative(&url) { + if !r.starts_with("../") { + r = format!("./{r}"); + } + return r; + } + } + path_or_url.to_string() + } + + fn force_report_step_wait(&mut self, description: &TestStepDescription) { + self.write_output_end(); + if !self.in_new_line { + println!(); + } + print!("{}{} ...", " ".repeat(description.level), description.name); + self.in_new_line = false; + // flush for faster feedback when line buffered + std::io::stdout().flush().unwrap(); + self.scope_test_id = Some(description.id); + } + + fn force_report_step_result( + &mut self, + description: &TestStepDescription, + result: &TestStepResult, + elapsed: u64, + ) { + self.write_output_end(); + if self.in_new_line || self.scope_test_id != Some(description.id) { + self.force_report_step_wait(description); + } + + if !self.parallel { + let child_results = self + .child_results_buffer + .remove(&description.id) + .unwrap_or_default(); + for (desc, result, elapsed) in child_results.values() { + self.force_report_step_result(desc, result, *elapsed); + } + if !child_results.is_empty() { + self.force_report_step_wait(description); + } + } + + let status = match &result { + TestStepResult::Ok => colors::green("ok").to_string(), + TestStepResult::Ignored => colors::yellow("ignored").to_string(), + TestStepResult::Failed(failure) => failure.format_label(), + }; + print!(" {}", status); + if let TestStepResult::Failed(failure) = result { + if let Some(inline_summary) = failure.format_inline_summary() { + print!(" ({})", inline_summary) + } + } + if !matches!(result, TestStepResult::Failed(TestFailure::Incomplete)) { + print!( + " {}", + colors::gray(format!("({})", display::human_elapsed(elapsed.into()))) + ); + } + println!(); + self.in_new_line = true; + if self.parallel { + self.scope_test_id = None; + } else { + self.scope_test_id = Some(description.parent_id); + } + self + .child_results_buffer + .entry(description.parent_id) + .or_default() + .remove(&description.id); + } + + fn write_output_end(&mut self) { + if self.did_have_user_output { + println!("{}", colors::gray("----- output end -----")); + self.in_new_line = true; + self.did_have_user_output = false; + } + } + + fn format_test_step_ancestry( + &self, + desc: &TestStepDescription, + tests: &IndexMap, + test_steps: &IndexMap, + ) -> String { + let root; + let mut ancestor_names = vec![]; + let mut current_desc = desc; + loop { + if let Some(step_desc) = test_steps.get(¤t_desc.parent_id) { + ancestor_names.push(&step_desc.name); + current_desc = step_desc; + } else { + root = tests.get(¤t_desc.parent_id).unwrap(); + break; + } + } + ancestor_names.reverse(); + let mut result = String::new(); + result.push_str(&root.name); + result.push_str(" ... "); + for name in ancestor_names { + result.push_str(name); + result.push_str(" ... "); + } + result.push_str(&desc.name); + result + } + + fn format_test_for_summary(&self, desc: &TestDescription) -> String { + format!( + "{} {}", + &desc.name, + colors::gray(format!( + "=> {}:{}:{}", + self.to_relative_path_or_remote_url(&desc.location.file_name), + desc.location.line_number, + desc.location.column_number + )) + ) + } + + fn format_test_step_for_summary( + &self, + desc: &TestStepDescription, + tests: &IndexMap, + test_steps: &IndexMap, + ) -> String { + let long_name = self.format_test_step_ancestry(desc, tests, test_steps); + format!( + "{} {}", + long_name, + colors::gray(format!( + "=> {}:{}:{}", + self.to_relative_path_or_remote_url(&desc.location.file_name), + desc.location.line_number, + desc.location.column_number + )) + ) + } +} + +impl TestReporter for PrettyTestReporter { + fn report_register(&mut self, _description: &TestDescription) {} + fn report_plan(&mut self, plan: &TestPlan) { + self.summary.total += plan.total; + self.summary.filtered_out += plan.filtered_out; + if self.parallel { + return; + } + let inflection = if plan.total == 1 { "test" } else { "tests" }; + println!( + "{}", + colors::gray(format!( + "running {} {} from {}", + plan.total, + inflection, + self.to_relative_path_or_remote_url(&plan.origin) + )) + ); + self.in_new_line = true; + } + + fn report_wait(&mut self, description: &TestDescription) { + if !self.parallel { + self.force_report_wait(description); + } + self.started_tests = true; + } + + fn report_output(&mut self, output: &[u8]) { + if !self.echo_output { + return; + } + + if !self.did_have_user_output && self.started_tests { + self.did_have_user_output = true; + if !self.in_new_line { + println!(); + } + println!("{}", colors::gray("------- output -------")); + self.in_new_line = true; + } + + // output everything to stdout in order to prevent + // stdout and stderr racing + std::io::stdout().write_all(output).unwrap(); + } + + fn report_result( + &mut self, + description: &TestDescription, + result: &TestResult, + elapsed: u64, + ) { + match &result { + TestResult::Ok => { + self.summary.passed += 1; + } + TestResult::Ignored => { + self.summary.ignored += 1; + } + TestResult::Failed(failure) => { + self.summary.failed += 1; + self + .summary + .failures + .push((description.clone(), failure.clone())); + } + TestResult::Cancelled => { + self.summary.failed += 1; + } + } + + if self.parallel { + self.force_report_wait(description); + } + + self.write_output_end(); + if self.in_new_line || self.scope_test_id != Some(description.id) { + self.force_report_wait(description); + } + + let status = match result { + TestResult::Ok => colors::green("ok").to_string(), + TestResult::Ignored => colors::yellow("ignored").to_string(), + TestResult::Failed(failure) => failure.format_label(), + TestResult::Cancelled => colors::gray("cancelled").to_string(), + }; + print!(" {}", status); + if let TestResult::Failed(failure) = result { + if let Some(inline_summary) = failure.format_inline_summary() { + print!(" ({})", inline_summary) + } + } + println!( + " {}", + colors::gray(format!("({})", display::human_elapsed(elapsed.into()))) + ); + self.in_new_line = true; + self.scope_test_id = None; + } + + fn report_uncaught_error(&mut self, origin: &str, error: Box) { + self.summary.failed += 1; + self + .summary + .uncaught_errors + .push((origin.to_string(), error)); + + if !self.in_new_line { + println!(); + } + println!( + "Uncaught error from {} {}", + self.to_relative_path_or_remote_url(origin), + colors::red("FAILED") + ); + self.in_new_line = true; + self.did_have_user_output = false; + } + + fn report_step_register(&mut self, _description: &TestStepDescription) {} + + fn report_step_wait(&mut self, description: &TestStepDescription) { + if !self.parallel && self.scope_test_id == Some(description.parent_id) { + self.force_report_step_wait(description); + } + } + + fn report_step_result( + &mut self, + desc: &TestStepDescription, + result: &TestStepResult, + elapsed: u64, + tests: &IndexMap, + test_steps: &IndexMap, + ) { + match &result { + TestStepResult::Ok => { + self.summary.passed_steps += 1; + } + TestStepResult::Ignored => { + self.summary.ignored_steps += 1; + } + TestStepResult::Failed(failure) => { + self.summary.failed_steps += 1; + self.summary.failures.push(( + TestDescription { + id: desc.id, + name: self.format_test_step_ancestry(desc, tests, test_steps), + ignore: false, + only: false, + origin: desc.origin.clone(), + location: desc.location.clone(), + }, + failure.clone(), + )) + } + } + + if self.parallel { + self.write_output_end(); + print!( + "{} {} ...", + colors::gray(format!( + "{} =>", + self.to_relative_path_or_remote_url(&desc.origin) + )), + self.format_test_step_ancestry(desc, tests, test_steps) + ); + self.in_new_line = false; + self.scope_test_id = Some(desc.id); + self.force_report_step_result(desc, result, elapsed); + } else { + let sibling_results = + self.child_results_buffer.entry(desc.parent_id).or_default(); + if self.scope_test_id == Some(desc.id) + || self.scope_test_id == Some(desc.parent_id) + { + let sibling_results = std::mem::take(sibling_results); + self.force_report_step_result(desc, result, elapsed); + // Flush buffered sibling results. + for (desc, result, elapsed) in sibling_results.values() { + self.force_report_step_result(desc, result, *elapsed); + } + } else { + sibling_results + .insert(desc.id, (desc.clone(), result.clone(), elapsed)); + } + } + } + + fn report_summary( + &mut self, + elapsed: &Duration, + _tests: &IndexMap, + _test_steps: &IndexMap, + ) { + if !self.summary.failures.is_empty() + || !self.summary.uncaught_errors.is_empty() + { + #[allow(clippy::type_complexity)] // Type alias doesn't look better here + let mut failures_by_origin: BTreeMap< + String, + (Vec<(&TestDescription, &TestFailure)>, Option<&JsError>), + > = BTreeMap::default(); + let mut failure_titles = vec![]; + for (description, failure) in &self.summary.failures { + let (failures, _) = failures_by_origin + .entry(description.origin.clone()) + .or_default(); + failures.push((description, failure)); + } + + for (origin, js_error) in &self.summary.uncaught_errors { + let (_, uncaught_error) = + failures_by_origin.entry(origin.clone()).or_default(); + let _ = uncaught_error.insert(js_error.as_ref()); + } + // note: the trailing whitespace is intentional to get a red background + println!("\n{}\n", colors::white_bold_on_red(" ERRORS ")); + for (origin, (failures, uncaught_error)) in failures_by_origin { + for (description, failure) in failures { + if !failure.hide_in_summary() { + let failure_title = self.format_test_for_summary(description); + println!("{}", &failure_title); + println!("{}: {}", colors::red_bold("error"), failure.to_string()); + println!(); + failure_titles.push(failure_title); + } + } + if let Some(js_error) = uncaught_error { + let failure_title = format!( + "{} (uncaught error)", + self.to_relative_path_or_remote_url(&origin) + ); + println!("{}", &failure_title); + println!( + "{}: {}", + colors::red_bold("error"), + format_test_error(js_error) + ); + println!("This error was not caught from a test and caused the test runner to fail on the referenced module."); + println!("It most likely originated from a dangling promise, event/timeout handler or top-level code."); + println!(); + failure_titles.push(failure_title); + } + } + // note: the trailing whitespace is intentional to get a red background + println!("{}\n", colors::white_bold_on_red(" FAILURES ")); + for failure_title in failure_titles { + println!("{failure_title}"); + } + } + + let status = if self.summary.has_failed() { + colors::red("FAILED").to_string() + } else { + colors::green("ok").to_string() + }; + + let get_steps_text = |count: usize| -> String { + if count == 0 { + String::new() + } else if count == 1 { + " (1 step)".to_string() + } else { + format!(" ({count} steps)") + } + }; + + let mut summary_result = String::new(); + + write!( + summary_result, + "{} passed{} | {} failed{}", + self.summary.passed, + get_steps_text(self.summary.passed_steps), + self.summary.failed, + get_steps_text(self.summary.failed_steps), + ) + .unwrap(); + + let ignored_steps = get_steps_text(self.summary.ignored_steps); + if self.summary.ignored > 0 || !ignored_steps.is_empty() { + write!( + summary_result, + " | {} ignored{}", + self.summary.ignored, ignored_steps + ) + .unwrap() + } + + if self.summary.measured > 0 { + write!(summary_result, " | {} measured", self.summary.measured,).unwrap(); + } + + if self.summary.filtered_out > 0 { + write!( + summary_result, + " | {} filtered out", + self.summary.filtered_out + ) + .unwrap() + }; + + println!( + "\n{} | {} {}\n", + status, + summary_result, + colors::gray(format!( + "({})", + display::human_elapsed(elapsed.as_millis()) + )), + ); + self.in_new_line = true; + } + + fn report_sigint( + &mut self, + tests_pending: &HashSet, + tests: &IndexMap, + test_steps: &IndexMap, + ) { + if tests_pending.is_empty() { + return; + } + let mut formatted_pending = BTreeSet::new(); + for id in tests_pending { + if let Some(desc) = tests.get(id) { + formatted_pending.insert(self.format_test_for_summary(desc)); + } + if let Some(desc) = test_steps.get(id) { + formatted_pending + .insert(self.format_test_step_for_summary(desc, tests, test_steps)); + } + } + println!( + "\n{} The following tests were pending:\n", + colors::intense_blue("SIGINT") + ); + for entry in formatted_pending { + println!("{}", entry); + } + println!(); + self.in_new_line = true; + } + + fn flush_report( + &mut self, + _elapsed: &Duration, + _tests: &IndexMap, + _test_steps: &IndexMap, + ) -> anyhow::Result<()> { + Ok(()) + } +}