From 5ddf8732f089c93667a667155cd923250d723758 Mon Sep 17 00:00:00 2001 From: Yoshiya Hinosawa Date: Tue, 12 Dec 2023 12:42:57 +0900 Subject: [PATCH] feat(coverage): add summary reporter (#21535) --- cli/args/flags.rs | 13 +- cli/tests/integration/coverage_tests.rs | 61 ++++- .../testdata/coverage/multisource/baz/quux.ts | 9 +- .../testdata/coverage/multisource/foo.ts | 7 + .../testdata/coverage/multisource/test.ts | 2 + cli/tools/coverage/reporter.rs | 212 +++++++++++++----- 6 files changed, 246 insertions(+), 58 deletions(-) diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 8b4fa445e2..5d692cafcf 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -86,6 +86,7 @@ pub struct CompletionsFlags { #[derive(Clone, Debug, Eq, PartialEq)] pub enum CoverageType { + Summary, Pretty, Lcov, Html, @@ -1413,6 +1414,12 @@ Generate html reports from lcov: ) .action(ArgAction::SetTrue), ) + .arg( + Arg::new("pretty") + .long("pretty") + .help("Output coverage report in pretty format in the terminal.") + .action(ArgAction::SetTrue), + ) .arg( Arg::new("files") .num_args(1..) @@ -3320,8 +3327,10 @@ fn coverage_parse(flags: &mut Flags, matches: &mut ArgMatches) { CoverageType::Lcov } else if matches.get_flag("html") { CoverageType::Html - } else { + } else if matches.get_flag("pretty") { CoverageType::Pretty + } else { + CoverageType::Summary }; let output = matches.remove_one::("output"); flags.subcommand = DenoSubcommand::Coverage(CoverageFlags { @@ -7908,7 +7917,7 @@ mod tests { output: None, include: vec![r"^file:".to_string()], exclude: vec![r"test\.(js|mjs|ts|jsx|tsx)$".to_string()], - r#type: CoverageType::Pretty + r#type: CoverageType::Summary }), ..Flags::default() } diff --git a/cli/tests/integration/coverage_tests.rs b/cli/tests/integration/coverage_tests.rs index d3affd4dea..4d927a16df 100644 --- a/cli/tests/integration/coverage_tests.rs +++ b/cli/tests/integration/coverage_tests.rs @@ -115,7 +115,11 @@ fn run_coverage_text(test_name: &str, extension: &str) { let output = context .new_command() - .args_vec(vec!["coverage".to_string(), format!("{}/", tempdir)]) + .args_vec(vec![ + "coverage".to_string(), + "--pretty".to_string(), + format!("{}/", tempdir), + ]) .split_output() .run(); @@ -184,7 +188,11 @@ fn multifile_coverage() { let output = context .new_command() - .args_vec(vec!["coverage".to_string(), format!("{}/", tempdir)]) + .args_vec(vec![ + "coverage".to_string(), + "--pretty".to_string(), + format!("{}/", tempdir), + ]) .split_output() .run(); @@ -255,6 +263,7 @@ fn no_snaps_included(test_name: &str, extension: &str) { .args_vec(vec![ "coverage".to_string(), "--include=no_snaps_included.ts".to_string(), + "--pretty".to_string(), format!("{}/", tempdir), ]) .split_output() @@ -303,6 +312,7 @@ fn no_tests_included(test_name: &str, extension: &str) { .args_vec(vec![ "coverage".to_string(), format!("--exclude={}", util::std_path().canonicalize()), + "--pretty".to_string(), format!("{}/", tempdir), ]) .split_output() @@ -350,7 +360,11 @@ fn no_npm_cache_coverage() { let output = context .new_command() - .args_vec(vec!["coverage".to_string(), format!("{}/", tempdir)]) + .args_vec(vec![ + "coverage".to_string(), + "--pretty".to_string(), + format!("{}/", tempdir), + ]) .split_output() .run(); @@ -397,6 +411,7 @@ fn no_transpiled_lines() { .args_vec(vec![ "coverage".to_string(), "--include=no_transpiled_lines/index.ts".to_string(), + "--pretty".to_string(), format!("{}/", tempdir), ]) .run(); @@ -575,3 +590,43 @@ fn test_html_reporter() { .unwrap(); assert!(baz_quux_ts_html.contains("

Coverage report for baz/quux.ts

")); } + +#[test] +fn test_summary_reporter() { + let context = TestContext::default(); + let tempdir = context.temp_dir(); + let tempdir = tempdir.path().join("cov"); + + let output = context + .new_command() + .args_vec(vec![ + "test".to_string(), + "--quiet".to_string(), + format!("--coverage={}", tempdir), + "coverage/multisource".to_string(), + ]) + .run(); + + output.assert_exit_code(0); + output.skip_output_check(); + + let output = context + .new_command() + .args_vec(vec!["coverage".to_string(), format!("{}/", tempdir)]) + .run(); + + output.assert_exit_code(0); + output.assert_matches_text( + "---------------------------------- +File | Branch % | Line % | +---------------------------------- + bar.ts | 0.0 | 57.1 | + baz/quux.ts | 0.0 | 28.6 | + baz/qux.ts | 100.0 | 100.0 | + foo.ts | 50.0 | 76.9 | +---------------------------------- + All files | 40.0 | 61.0 | +---------------------------------- +", + ); +} diff --git a/cli/tests/testdata/coverage/multisource/baz/quux.ts b/cli/tests/testdata/coverage/multisource/baz/quux.ts index ab8c62db66..6032f6f3c5 100644 --- a/cli/tests/testdata/coverage/multisource/baz/quux.ts +++ b/cli/tests/testdata/coverage/multisource/baz/quux.ts @@ -1,6 +1,13 @@ export function quux(cond: boolean) { if (cond) { - return 1; + const a = 1; + const b = a; + const c = b; + const d = c; + const e = d; + const f = e; + const g = f; + return g; } else { return 2; } diff --git a/cli/tests/testdata/coverage/multisource/foo.ts b/cli/tests/testdata/coverage/multisource/foo.ts index 023f825561..0559cadd80 100644 --- a/cli/tests/testdata/coverage/multisource/foo.ts +++ b/cli/tests/testdata/coverage/multisource/foo.ts @@ -1,5 +1,12 @@ export function foo(cond: boolean) { + let a = 0; if (cond) { + a = 1; + } else { + a = 2; + } + + if (a == 4) { return 1; } else { return 2; diff --git a/cli/tests/testdata/coverage/multisource/test.ts b/cli/tests/testdata/coverage/multisource/test.ts index 6adf6f52c2..350421177d 100644 --- a/cli/tests/testdata/coverage/multisource/test.ts +++ b/cli/tests/testdata/coverage/multisource/test.ts @@ -5,6 +5,7 @@ import { quux } from "./baz/quux.ts"; Deno.test("foo", () => { foo(true); + foo(false); }); Deno.test("bar", () => { @@ -13,6 +14,7 @@ Deno.test("bar", () => { Deno.test("qux", () => { qux(true); + qux(false); }); Deno.test("quux", () => { diff --git a/cli/tools/coverage/reporter.rs b/cli/tools/coverage/reporter.rs index e94b542553..25a75dd32e 100644 --- a/cli/tools/coverage/reporter.rs +++ b/cli/tools/coverage/reporter.rs @@ -16,7 +16,7 @@ use std::path::Path; use std::path::PathBuf; #[derive(Default)] -struct CoverageStats<'a> { +pub struct CoverageStats<'a> { pub line_hit: usize, pub line_miss: usize, pub branch_hit: usize, @@ -30,6 +30,7 @@ type CoverageSummary<'a> = HashMap>; pub fn create(kind: CoverageType) -> Box { match kind { + CoverageType::Summary => Box::new(SummaryCoverageReporter::new()), CoverageType::Lcov => Box::new(LcovCoverageReporter::new()), CoverageType::Pretty => Box::new(PrettyCoverageReporter::new()), CoverageType::Html => Box::new(HtmlCoverageReporter::new()), @@ -44,6 +45,163 @@ pub trait CoverageReporter { ) -> Result<(), AnyError>; fn done(&mut self, _coverage_root: &Path) {} + + /// Collects the coverage summary of each file or directory. + fn collect_summary<'a>( + &'a self, + file_reports: &'a Vec<(CoverageReport, String)>, + ) -> CoverageSummary { + let urls = file_reports.iter().map(|rep| &rep.0.url).collect(); + let root = util::find_root(urls).unwrap().to_file_path().unwrap(); + // summary by file or directory + // tuple of (line hit, line miss, branch hit, branch miss, parent) + let mut summary = HashMap::new(); + summary.insert("".to_string(), CoverageStats::default()); // root entry + for (report, file_text) in file_reports { + let path = report.url.to_file_path().unwrap(); + let relative_path = path.strip_prefix(&root).unwrap(); + let mut file_text = Some(file_text.to_string()); + + let mut summary_path = Some(relative_path); + // From leaf to root, adds up the coverage stats + while let Some(path) = summary_path { + let path_str = path.to_str().unwrap().to_string(); + let parent = path + .parent() + .and_then(|p| p.to_str()) + .map(|p| p.to_string()); + let stats = summary.entry(path_str).or_insert(CoverageStats { + parent, + file_text, + report: Some(report), + ..CoverageStats::default() + }); + + stats.line_hit += report + .found_lines + .iter() + .filter(|(_, count)| *count > 0) + .count(); + stats.line_miss += report + .found_lines + .iter() + .filter(|(_, count)| *count == 0) + .count(); + stats.branch_hit += report.branches.iter().filter(|b| b.is_hit).count(); + stats.branch_miss += + report.branches.iter().filter(|b| !b.is_hit).count(); + + file_text = None; + summary_path = path.parent(); + } + } + summary + } +} + +struct SummaryCoverageReporter { + file_reports: Vec<(CoverageReport, String)>, +} + +impl SummaryCoverageReporter { + pub fn new() -> SummaryCoverageReporter { + SummaryCoverageReporter { + file_reports: Vec::new(), + } + } + + fn print_coverage_line( + &self, + node: &str, + node_max: usize, + stats: &CoverageStats, + ) { + let CoverageStats { + line_hit, + line_miss, + branch_hit, + branch_miss, + .. + } = stats; + let (_, line_percent, line_class) = + util::calc_coverage_display_info(*line_hit, *line_miss); + let (_, branch_percent, branch_class) = + util::calc_coverage_display_info(*branch_hit, *branch_miss); + + let file_name = format!( + "{node:node_max$}", + node = node.replace('\\', "/"), + node_max = node_max + ); + let file_name = if line_class == "high" { + format!("{}", colors::green(&file_name)) + } else if line_class == "medium" { + format!("{}", colors::yellow(&file_name)) + } else { + format!("{}", colors::red(&file_name)) + }; + + let branch_percent = if branch_class == "high" { + format!("{}", colors::green(&format!("{:>8.1}", branch_percent))) + } else if branch_class == "medium" { + format!("{}", colors::yellow(&format!("{:>8.1}", branch_percent))) + } else { + format!("{}", colors::red(&format!("{:>8.1}", branch_percent))) + }; + + let line_percent = if line_class == "high" { + format!("{}", colors::green(&format!("{:>6.1}", line_percent))) + } else if line_class == "medium" { + format!("{}", colors::yellow(&format!("{:>6.1}", line_percent))) + } else { + format!("{}", colors::red(&format!("{:>6.1}", line_percent))) + }; + + println!( + " {file_name} | {branch_percent} | {line_percent} |", + file_name = file_name, + branch_percent = branch_percent, + line_percent = line_percent, + ); + } +} + +impl CoverageReporter for SummaryCoverageReporter { + fn report( + &mut self, + coverage_report: &CoverageReport, + file_text: &str, + ) -> Result<(), AnyError> { + self + .file_reports + .push((coverage_report.clone(), file_text.to_string())); + Ok(()) + } + + fn done(&mut self, _coverage_root: &Path) { + let summary = self.collect_summary(&self.file_reports); + let root_stats = summary.get("").unwrap(); + + let mut entries = summary + .iter() + .filter(|(_, stats)| stats.file_text.is_some()) + .collect::>(); + entries.sort_by_key(|(node, _)| node.to_owned()); + let node_max = entries.iter().map(|(node, _)| node.len()).max().unwrap(); + + let header = + format!("{node:node_max$} | Branch % | Line % |", node = "File"); + let separator = "-".repeat(header.len()); + println!("{}", separator); + println!("{}", header); + println!("{}", separator); + entries.iter().for_each(|(node, stats)| { + self.print_coverage_line(node, node_max, stats); + }); + println!("{}", separator); + self.print_coverage_line("All files", node_max, root_stats); + println!("{}", separator); + } } struct LcovCoverageReporter {} @@ -232,7 +390,7 @@ impl CoverageReporter for HtmlCoverageReporter { } fn done(&mut self, coverage_root: &Path) { - let summary = self.collect_summary(); + let summary = self.collect_summary(&self.file_reports); let now = crate::util::time::utc_now().to_rfc2822(); for (node, stats) in &summary { @@ -269,56 +427,6 @@ impl HtmlCoverageReporter { } } - /// Collects the coverage summary of each file or directory. - pub fn collect_summary(&self) -> CoverageSummary { - let urls = self.file_reports.iter().map(|rep| &rep.0.url).collect(); - let root = util::find_root(urls).unwrap().to_file_path().unwrap(); - // summary by file or directory - // tuple of (line hit, line miss, branch hit, branch miss, parent) - let mut summary = HashMap::new(); - summary.insert("".to_string(), CoverageStats::default()); // root entry - for (report, file_text) in &self.file_reports { - let path = report.url.to_file_path().unwrap(); - let relative_path = path.strip_prefix(&root).unwrap(); - let mut file_text = Some(file_text.to_string()); - - let mut summary_path = Some(relative_path); - // From leaf to root, adds up the coverage stats - while let Some(path) = summary_path { - let path_str = path.to_str().unwrap().to_string(); - let parent = path - .parent() - .and_then(|p| p.to_str()) - .map(|p| p.to_string()); - let stats = summary.entry(path_str).or_insert(CoverageStats { - parent, - file_text, - report: Some(report), - ..CoverageStats::default() - }); - - stats.line_hit += report - .found_lines - .iter() - .filter(|(_, count)| *count > 0) - .count(); - stats.line_miss += report - .found_lines - .iter() - .filter(|(_, count)| *count == 0) - .count(); - stats.branch_hit += report.branches.iter().filter(|b| b.is_hit).count(); - stats.branch_miss += - report.branches.iter().filter(|b| !b.is_hit).count(); - - file_text = None; - summary_path = path.parent(); - } - } - - summary - } - /// Gets the report path for a single file pub fn get_report_path( &self,