From d68d1e202285df30893968c8ba71b4a0a769b357 Mon Sep 17 00:00:00 2001 From: Yoshiya Hinosawa Date: Fri, 8 Dec 2023 16:54:52 +0900 Subject: [PATCH] feat(coverage): add html reporter (#21495) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Bartek IwaƄczuk --- cli/args/flags.rs | 31 +- cli/tests/integration/coverage_tests.rs | 69 ++++ .../testdata/coverage/multisource/bar.ts | 7 + .../testdata/coverage/multisource/baz/quux.ts | 7 + .../testdata/coverage/multisource/baz/qux.ts | 7 + .../testdata/coverage/multisource/foo.ts | 7 + .../testdata/coverage/multisource/test.ts | 20 + cli/tools/coverage/mod.rs | 388 +++++++++++++++++- cli/tools/coverage/style.css | 371 +++++++++++++++++ cli/tools/coverage/util.rs | 103 +++++ 10 files changed, 996 insertions(+), 14 deletions(-) create mode 100644 cli/tests/testdata/coverage/multisource/bar.ts create mode 100644 cli/tests/testdata/coverage/multisource/baz/quux.ts create mode 100644 cli/tests/testdata/coverage/multisource/baz/qux.ts create mode 100644 cli/tests/testdata/coverage/multisource/foo.ts create mode 100644 cli/tests/testdata/coverage/multisource/test.ts create mode 100644 cli/tools/coverage/style.css create mode 100644 cli/tools/coverage/util.rs diff --git a/cli/args/flags.rs b/cli/args/flags.rs index 1acef2058a..2eed2a183c 100644 --- a/cli/args/flags.rs +++ b/cli/args/flags.rs @@ -84,13 +84,20 @@ pub struct CompletionsFlags { pub buf: Box<[u8]>, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CoverageType { + Pretty, + Lcov, + Html, +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct CoverageFlags { pub files: FileFlags, pub output: Option, pub include: Vec, pub exclude: Vec, - pub lcov: bool, + pub r#type: CoverageType, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -1397,6 +1404,14 @@ Generate html reports from lcov: .require_equals(true) .value_hint(ValueHint::FilePath), ) + .arg( + Arg::new("html") + .long("html") + .help( + "Output coverage report in HTML format in the given directory", + ) + .action(ArgAction::SetTrue), + ) .arg( Arg::new("files") .num_args(1..) @@ -3298,7 +3313,13 @@ fn coverage_parse(flags: &mut Flags, matches: &mut ArgMatches) { Some(f) => f.collect(), None => vec![], }; - let lcov = matches.get_flag("lcov"); + let r#type = if matches.get_flag("lcov") { + CoverageType::Lcov + } else if matches.get_flag("html") { + CoverageType::Html + } else { + CoverageType::Pretty + }; let output = matches.remove_one::("output"); flags.subcommand = DenoSubcommand::Coverage(CoverageFlags { files: FileFlags { @@ -3308,7 +3329,7 @@ fn coverage_parse(flags: &mut Flags, matches: &mut ArgMatches) { output, include, exclude, - lcov, + r#type, }); } @@ -7867,7 +7888,7 @@ mod tests { output: None, include: vec![r"^file:".to_string()], exclude: vec![r"test\.(js|mjs|ts|jsx|tsx)$".to_string()], - lcov: false, + r#type: CoverageType::Pretty }), ..Flags::default() } @@ -7893,7 +7914,7 @@ mod tests { }, include: vec![r"^file:".to_string()], exclude: vec![r"test\.(js|mjs|ts|jsx|tsx)$".to_string()], - lcov: true, + r#type: CoverageType::Lcov, output: Some(PathBuf::from("foo.lcov")), }), ..Flags::default() diff --git a/cli/tests/integration/coverage_tests.rs b/cli/tests/integration/coverage_tests.rs index d8a11a6240..3317020fb3 100644 --- a/cli/tests/integration/coverage_tests.rs +++ b/cli/tests/integration/coverage_tests.rs @@ -504,3 +504,72 @@ fn no_internal_node_code() { assert_starts_with!(url, "file:"); } } + +#[test] +fn test_html_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(), + "--html".to_string(), + format!("{}/", tempdir), + ]) + .run(); + + output.assert_exit_code(0); + output.assert_matches_text("HTML coverage report has been generated at [WILDCARD]/cov/html/index.html\n"); + + let index_html = + fs::read_to_string(tempdir.join("html").join("index.html")).unwrap(); + assert!(index_html.contains("

Coverage report for all files

")); + assert!(index_html.contains("baz/")); + assert!(index_html.contains("href='baz/index.html'")); + assert!(index_html.contains("foo.ts")); + assert!(index_html.contains("href='foo.ts.html'")); + assert!(index_html.contains("bar.ts")); + assert!(index_html.contains("href='bar.ts.html'")); + + let foo_ts_html = + fs::read_to_string(tempdir.join("html").join("foo.ts.html")).unwrap(); + assert!(foo_ts_html.contains("

Coverage report for foo.ts

")); + + let bar_ts_html = + fs::read_to_string(tempdir.join("html").join("bar.ts.html")).unwrap(); + assert!(bar_ts_html.contains("

Coverage report for bar.ts

")); + + let baz_index_html = + fs::read_to_string(tempdir.join("html").join("baz").join("index.html")) + .unwrap(); + assert!(baz_index_html.contains("

Coverage report for baz/

")); + assert!(baz_index_html.contains("qux.ts")); + assert!(baz_index_html.contains("href='qux.ts.html'")); + assert!(baz_index_html.contains("quux.ts")); + assert!(baz_index_html.contains("href='quux.ts.html'")); + + let baz_qux_ts_html = + fs::read_to_string(tempdir.join("html").join("baz").join("qux.ts.html")) + .unwrap(); + assert!(baz_qux_ts_html.contains("

Coverage report for baz/qux.ts

")); + + let baz_quux_ts_html = + fs::read_to_string(tempdir.join("html").join("baz").join("quux.ts.html")) + .unwrap(); + assert!(baz_quux_ts_html.contains("

Coverage report for baz/quux.ts

")); +} diff --git a/cli/tests/testdata/coverage/multisource/bar.ts b/cli/tests/testdata/coverage/multisource/bar.ts new file mode 100644 index 0000000000..1bb10a0a62 --- /dev/null +++ b/cli/tests/testdata/coverage/multisource/bar.ts @@ -0,0 +1,7 @@ +export function bar(cond: boolean) { + if (cond) { + return 1; + } else { + return 2; + } +} diff --git a/cli/tests/testdata/coverage/multisource/baz/quux.ts b/cli/tests/testdata/coverage/multisource/baz/quux.ts new file mode 100644 index 0000000000..ab8c62db66 --- /dev/null +++ b/cli/tests/testdata/coverage/multisource/baz/quux.ts @@ -0,0 +1,7 @@ +export function quux(cond: boolean) { + if (cond) { + return 1; + } else { + return 2; + } +} diff --git a/cli/tests/testdata/coverage/multisource/baz/qux.ts b/cli/tests/testdata/coverage/multisource/baz/qux.ts new file mode 100644 index 0000000000..973f48c612 --- /dev/null +++ b/cli/tests/testdata/coverage/multisource/baz/qux.ts @@ -0,0 +1,7 @@ +export function qux(cond: boolean) { + if (cond) { + return 1; + } else { + return 2; + } +} diff --git a/cli/tests/testdata/coverage/multisource/foo.ts b/cli/tests/testdata/coverage/multisource/foo.ts new file mode 100644 index 0000000000..023f825561 --- /dev/null +++ b/cli/tests/testdata/coverage/multisource/foo.ts @@ -0,0 +1,7 @@ +export function foo(cond: boolean) { + if (cond) { + return 1; + } else { + return 2; + } +} diff --git a/cli/tests/testdata/coverage/multisource/test.ts b/cli/tests/testdata/coverage/multisource/test.ts new file mode 100644 index 0000000000..6adf6f52c2 --- /dev/null +++ b/cli/tests/testdata/coverage/multisource/test.ts @@ -0,0 +1,20 @@ +import { foo } from "./foo.ts"; +import { bar } from "./bar.ts"; +import { qux } from "./baz/qux.ts"; +import { quux } from "./baz/quux.ts"; + +Deno.test("foo", () => { + foo(true); +}); + +Deno.test("bar", () => { + bar(false); +}); + +Deno.test("qux", () => { + qux(true); +}); + +Deno.test("quux", () => { + quux(false); +}); diff --git a/cli/tools/coverage/mod.rs b/cli/tools/coverage/mod.rs index cd4afe360a..79899ddd8c 100644 --- a/cli/tools/coverage/mod.rs +++ b/cli/tools/coverage/mod.rs @@ -1,6 +1,7 @@ // Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. use crate::args::CoverageFlags; +use crate::args::CoverageType; use crate::args::FileFlags; use crate::args::Flags; use crate::cdp; @@ -24,6 +25,7 @@ use deno_core::url::Url; use deno_core::LocalInspectorSession; use deno_core::ModuleCode; use regex::Regex; +use std::collections::HashMap; use std::fs; use std::fs::File; use std::io::BufWriter; @@ -37,6 +39,7 @@ use uuid::Uuid; mod merge; mod range_tree; +mod util; use merge::ProcessCoverage; pub struct CoverageCollector { @@ -156,6 +159,7 @@ impl CoverageCollector { } } +#[derive(Debug, Clone)] struct BranchCoverageItem { line_index: usize, block_number: usize, @@ -164,20 +168,36 @@ struct BranchCoverageItem { is_hit: bool, } +#[derive(Debug, Clone)] struct FunctionCoverageItem { name: String, line_index: usize, execution_count: i64, } +#[derive(Debug, Clone)] struct CoverageReport { url: ModuleSpecifier, named_functions: Vec, branches: Vec, + /// (line_index, number_of_hits) found_lines: Vec<(usize, i64)>, output: Option, } +#[derive(Default)] +struct CoverageStats<'a> { + pub line_hit: usize, + pub line_miss: usize, + pub branch_hit: usize, + pub branch_miss: usize, + pub parent: Option, + pub file_text: Option, + pub report: Option<&'a CoverageReport>, +} + +type CoverageSummary<'a> = HashMap>; + fn generate_coverage_report( script_coverage: &cdp::ScriptCoverage, script_source: String, @@ -371,6 +391,7 @@ fn generate_coverage_report( enum CoverageReporterKind { Pretty, Lcov, + Html, } fn create_reporter( @@ -379,6 +400,7 @@ fn create_reporter( match kind { CoverageReporterKind::Lcov => Box::new(LcovCoverageReporter::new()), CoverageReporterKind::Pretty => Box::new(PrettyCoverageReporter::new()), + CoverageReporterKind::Html => Box::new(HtmlCoverageReporter::new()), } } @@ -389,7 +411,7 @@ trait CoverageReporter { file_text: &str, ) -> Result<(), AnyError>; - fn done(&mut self); + fn done(&mut self, _coverage_root: &Path) {} } struct LcovCoverageReporter {} @@ -490,8 +512,6 @@ impl CoverageReporter for LcovCoverageReporter { writeln!(out_writer, "end_of_record")?; Ok(()) } - - fn done(&mut self) {} } struct PrettyCoverageReporter {} @@ -563,8 +583,353 @@ impl CoverageReporter for PrettyCoverageReporter { } Ok(()) } +} - fn done(&mut self) {} +struct HtmlCoverageReporter { + file_reports: Vec<(CoverageReport, String)>, +} + +impl HtmlCoverageReporter { + pub fn new() -> HtmlCoverageReporter { + HtmlCoverageReporter { + file_reports: Vec::new(), + } + } +} + +impl CoverageReporter for HtmlCoverageReporter { + fn report( + &mut self, + report: &CoverageReport, + text: &str, + ) -> Result<(), AnyError> { + self.file_reports.push((report.clone(), text.to_string())); + Ok(()) + } + + fn done(&mut self, coverage_root: &Path) { + let summary = self.collect_summary(); + let now = crate::util::time::utc_now().to_rfc2822(); + + for (node, stats) in &summary { + let report_path = + self.get_report_path(coverage_root, node, stats.file_text.is_none()); + let main_content = if let Some(file_text) = &stats.file_text { + self.create_html_code_table(file_text, stats.report.unwrap()) + } else { + self.create_html_summary_table(node, &summary) + }; + let is_dir = stats.file_text.is_none(); + let html = self.create_html(node, is_dir, stats, &now, &main_content); + fs::create_dir_all(report_path.parent().unwrap()).unwrap(); + fs::write(report_path, html).unwrap(); + } + + let root_report = Url::from_file_path( + coverage_root + .join("html") + .join("index.html") + .canonicalize() + .unwrap(), + ) + .unwrap(); + + println!("HTML coverage report has been generated at {}", root_report); + } +} + +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, + coverage_root: &Path, + node: &str, + is_dir: bool, + ) -> PathBuf { + if is_dir { + // e.g. /path/to/coverage/html/src/index.html + coverage_root.join("html").join(node).join("index.html") + } else { + // e.g. /path/to/coverage/html/src/main.ts.html + Path::new(&format!( + "{}.html", + coverage_root.join("html").join(node).to_str().unwrap() + )) + .to_path_buf() + } + } + + /// Creates single page of html report. + pub fn create_html( + &self, + node: &str, + is_dir: bool, + stats: &CoverageStats, + timestamp: &str, + main_content: &str, + ) -> String { + let title = if node.is_empty() { + "Coverage report for all files".to_string() + } else { + let node = if is_dir { + format!("{}/", node) + } else { + node.to_string() + }; + format!("Coverage report for {node}") + }; + let title = title.replace(std::path::MAIN_SEPARATOR, "/"); + let head = self.create_html_head(&title); + let header = self.create_html_header(&title, stats); + let footer = self.create_html_footer(timestamp); + format!( + " + + {head} + +
+ {header} +
+ {main_content} +
+
+
+ {footer} + + " + ) + } + + /// Creates tag for html report. + pub fn create_html_head(&self, title: &str) -> String { + let style_css = include_str!("style.css"); + format!( + " + + + {title} + + + " + ) + } + + /// Creates header part of the contents for html report. + pub fn create_html_header( + &self, + title: &str, + stats: &CoverageStats, + ) -> String { + let CoverageStats { + line_hit, + line_miss, + branch_hit, + branch_miss, + .. + } = stats; + let (line_total, line_percent, line_class) = + util::calc_coverage_display_info(*line_hit, *line_miss); + let (branch_total, branch_percent, _) = + util::calc_coverage_display_info(*branch_hit, *branch_miss); + + format!( + " +
+

{title}

+
+
+ {branch_percent:.2}% + Branches + {branch_hit}/{branch_total} +
+
+ {line_percent:.2}% + Lines + {line_hit}/{line_total} +
+
+
+
" + ) + } + + /// Creates footer part of the contents for html report. + pub fn create_html_footer(&self, now: &str) -> String { + let version = env!("CARGO_PKG_VERSION"); + format!( + " + " + ) + } + + /// Creates of summary for html report. + pub fn create_html_summary_table( + &self, + node: &String, + summary: &CoverageSummary, + ) -> String { + let mut children = summary + .iter() + .filter(|(_, stats)| stats.parent.as_ref() == Some(node)) + .map(|(k, stats)| (stats.file_text.is_some(), k.clone())) + .collect::>(); + // Sort directories first, then files + children.sort(); + + let table_rows: Vec = children.iter().map(|(is_file, c)| { + let CoverageStats { line_hit, line_miss, branch_hit, branch_miss, .. } = + summary.get(c).unwrap(); + + let (line_total, line_percent, line_class) = + util::calc_coverage_display_info(*line_hit, *line_miss); + let (branch_total, branch_percent, branch_class) = + util::calc_coverage_display_info(*branch_hit, *branch_miss); + + let path = Path::new(c.strip_prefix(&format!("{node}{}", std::path::MAIN_SEPARATOR)).unwrap_or(c)).to_str().unwrap(); + let path = path.replace(std::path::MAIN_SEPARATOR, "/"); + let path_label = if *is_file { path.to_string() } else { format!("{}/", path) }; + let path_link = if *is_file { format!("{}.html", path) } else { format!("{}index.html", path_label) }; + + format!(" + + + + + + + + ")}).collect(); + let table_rows = table_rows.join("\n"); + + format!( + " +
{path_label} +
+
+
+
{branch_percent:.2}%{branch_hit}/{branch_total}{line_percent:.2}%{line_hit}/{line_total}
+ + + + + + + + + + + + {table_rows} + +
FileBranchesLines
" + ) + } + + /// Creates of single file code coverage. + pub fn create_html_code_table( + &self, + file_text: &String, + report: &CoverageReport, + ) -> String { + let line_num = file_text.lines().count(); + let line_count = (1..line_num + 1) + .map(|i| format!("{i}")) + .collect::>() + .join("\n"); + let line_coverage = (0..line_num) + .map(|i| { + if let Some((_, count)) = + report.found_lines.iter().find(|(line, _)| i == *line) + { + if *count == 0 { + " ".to_string() + } else { + format!("x{count}") + } + } else { + " ".to_string() + } + }) + .collect::>() + .join("\n"); + let branch_coverage = (0..line_num) + .map(|i| { + let branch_is_missed = report.branches.iter().any(|b| b.line_index == i && !b.is_hit); + if branch_is_missed { + "I".to_string() + } else { + "".to_string() + } + }) + .collect::>() + .join("\n"); + + // TODO(kt3k): Add syntax highlight to source code + format!( + "
+ + + + + + +
{line_count}
{line_coverage}
{branch_coverage}
{file_text}
" + ) + } } fn collect_coverages( @@ -645,6 +1010,11 @@ pub async fn cover_files( let cli_options = factory.cli_options(); let emitter = factory.emitter()?; + assert!(!coverage_flags.files.include.is_empty()); + + // Use the first include path as the default output path. + let coverage_root = coverage_flags.files.include[0].clone(); + let script_coverages = collect_coverages(coverage_flags.files)?; let script_coverages = filter_coverages( script_coverages, @@ -665,10 +1035,10 @@ pub async fn cover_files( vec![] }; - let reporter_kind = if coverage_flags.lcov { - CoverageReporterKind::Lcov - } else { - CoverageReporterKind::Pretty + let reporter_kind = match coverage_flags.r#type { + CoverageType::Pretty => CoverageReporterKind::Pretty, + CoverageType::Lcov => CoverageReporterKind::Lcov, + CoverageType::Html => CoverageReporterKind::Html, }; let mut reporter = create_reporter(reporter_kind); @@ -748,7 +1118,7 @@ pub async fn cover_files( } } - reporter.done(); + reporter.done(&coverage_root); Ok(()) } diff --git a/cli/tools/coverage/style.css b/cli/tools/coverage/style.css new file mode 100644 index 0000000000..92ffc54059 --- /dev/null +++ b/cli/tools/coverage/style.css @@ -0,0 +1,371 @@ +/* Copyright 2015 the Istanbul contributors. All rights reserved. ISC license. */ +/* Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. */ + +body, +html { + margin: 0; + padding: 0; + height: 100%; +} +body { + font-family: + Helvetica Neue, + Helvetica, + Arial; + font-size: 14px; + color: #333; +} +.small { + font-size: 12px; +} +*, +*:after, +*:before { + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +h1 { + font-size: 20px; + margin: 0; +} +h2 { + font-size: 14px; +} +pre { + font: + 12px/1.4 Consolas, + "Liberation Mono", + Menlo, + Courier, + monospace; + margin: 0; + padding: 0; + -moz-tab-size: 2; + -o-tab-size: 2; + tab-size: 2; +} +a { + color: #0074d9; + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +.strong { + font-weight: bold; +} +.space-top1 { + padding: 10px 0 0 0; +} +.pad2y { + padding: 20px 0; +} +.pad1y { + padding: 10px 0; +} +.pad2x { + padding: 0 20px; +} +.pad2 { + padding: 20px; +} +.pad1 { + padding: 10px; +} +.space-left2 { + padding-left: 55px; +} +.space-right2 { + padding-right: 20px; +} +.center { + text-align: center; +} +.clearfix { + display: block; +} +.clearfix:after { + content: ""; + display: block; + height: 0; + clear: both; + visibility: hidden; +} +.fl { + float: left; +} +@media only screen and (max-width: 640px) { + .col3 { + width: 100%; + max-width: 100%; + } + .hide-mobile { + display: none !important; + } +} + +.quiet { + color: #7f7f7f; + color: rgba(0, 0, 0, 0.5); +} +.quiet a { + opacity: 0.7; +} + +.fraction { + font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; + font-size: 10px; + color: #555; + background: #e8e8e8; + padding: 4px 5px; + border-radius: 3px; + vertical-align: middle; +} + +div.path a:link, +div.path a:visited { + color: #333; +} +table.coverage { + border-collapse: collapse; + margin: 10px 0 0 0; + padding: 0; +} + +table.coverage td { + margin: 0; + padding: 0; + vertical-align: top; +} +table.coverage td.line-count { + text-align: right; + padding: 0 5px 0 20px; +} +table.coverage td.line-coverage { + text-align: right; + padding-right: 4px; + min-width: 20px; +} + +table.coverage td.branch-coverage { + text-align: right; + padding-right: 3px; + min-width: 8px; +} + +table.coverage td span.cline-any { + display: inline-block; + padding: 0 5px; + width: 100%; +} +.missing-if-branch { + display: inline-block; + margin-right: 5px; + border-radius: 3px; + position: relative; + padding: 0 4px; + background: #333; + color: yellow; +} + +.skip-if-branch { + display: none; + margin-right: 10px; + position: relative; + padding: 0 4px; + background: #ccc; + color: white; +} +.missing-if-branch .typ, +.skip-if-branch .typ { + color: inherit !important; +} +.coverage-summary { + border-collapse: collapse; + width: 100%; +} +.coverage-summary tr { + border-bottom: 1px solid #bbb; +} +.keyline-all { + border: 1px solid #ddd; +} +.coverage-summary td, +.coverage-summary th { + padding: 10px; +} +.coverage-summary tbody { + border: 1px solid #bbb; +} +.coverage-summary td { + border-right: 1px solid #bbb; +} +.coverage-summary td:last-child { + border-right: none; +} +.coverage-summary th { + text-align: left; + font-weight: normal; + white-space: nowrap; +} +.coverage-summary th.file { + border-right: none !important; +} +.coverage-summary th.pct { +} +.coverage-summary th.pic, +.coverage-summary th.abs, +.coverage-summary td.pct, +.coverage-summary td.abs { + text-align: right; +} +.coverage-summary td.file { + white-space: nowrap; +} +.coverage-summary td.pic { + min-width: 120px !important; +} +.coverage-summary tfoot td { +} + +.coverage-summary .sorter { + height: 10px; + width: 7px; + display: inline-block; + margin-left: 0.5em; + background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent; +} +.coverage-summary .sorted .sorter { + background-position: 0 -20px; +} +.coverage-summary .sorted-desc .sorter { + background-position: 0 -10px; +} +.status-line { + height: 10px; +} +/* yellow */ +.cbranch-no { + background: yellow !important; + color: #111; +} +/* dark red */ +.red.solid, +.status-line.low, +.low .cover-fill { + background: #c21f39; +} +.low .chart { + border: 1px solid #c21f39; +} +.highlighted, +.highlighted .cstat-no, +.highlighted .fstat-no, +.highlighted .cbranch-no { + background: #c21f39 !important; +} +/* medium red */ +.cstat-no, +.fstat-no, +.cbranch-no, +.cbranch-no { + background: #f6c6ce; +} +/* light red */ +.low, +.cline-no { + background: #fce1e5; +} +/* light green */ +.high, +.cline-yes { + background: rgb(230, 245, 208); +} +/* medium green */ +.cstat-yes { + background: rgb(161, 215, 106); +} +/* dark green */ +.status-line.high, +.high .cover-fill { + background: rgb(77, 146, 33); +} +.high .chart { + border: 1px solid rgb(77, 146, 33); +} +/* dark yellow (gold) */ +.status-line.medium, +.medium .cover-fill { + background: #f9cd0b; +} +.medium .chart { + border: 1px solid #f9cd0b; +} +/* light yellow */ +.medium { + background: #fff4c2; +} + +.cstat-skip { + background: #ddd; + color: #111; +} +.fstat-skip { + background: #ddd; + color: #111 !important; +} +.cbranch-skip { + background: #ddd !important; + color: #111; +} + +span.cline-neutral { + background: #eaeaea; +} + +.coverage-summary td.empty { + opacity: 0.5; + padding-top: 4px; + padding-bottom: 4px; + line-height: 1; + color: #888; +} + +.cover-fill, +.cover-empty { + display: inline-block; + height: 12px; +} +.chart { + line-height: 0; +} +.cover-empty { + background: white; +} +.cover-full { + border-right: none !important; +} +pre.prettyprint { + border: none !important; + padding: 0 !important; + margin: 0 !important; +} +.com { + color: #999 !important; +} +.ignore-none { + color: #999; + font-weight: normal; +} + +.wrapper { + min-height: 100%; + height: auto !important; + height: 100%; + margin: 0 auto -48px; +} +.footer, +.push { + height: 48px; +} diff --git a/cli/tools/coverage/util.rs b/cli/tools/coverage/util.rs new file mode 100644 index 0000000000..af986fb238 --- /dev/null +++ b/cli/tools/coverage/util.rs @@ -0,0 +1,103 @@ +// Copyright 2018-2023 the Deno authors. All rights reserved. MIT license. + +use deno_core::url::Url; + +pub fn find_root(urls: Vec<&Url>) -> Option { + if urls.is_empty() { + return None; + } + + // Gets the common first part of all the urls. + let root = urls[0] + .as_ref() + .chars() + .enumerate() + .take_while(|(i, c)| { + urls.iter().all(|u| u.as_ref().chars().nth(*i) == Some(*c)) + }) + .map(|(_, c)| c) + .collect::(); + + if let Some(index) = root.rfind('/') { + // Removes the basename part if exists. + Url::parse(&root[..index + 1]).ok() + } else { + Url::parse(&root).ok() + } +} + +pub fn percent_to_class(percent: f32) -> &'static str { + match percent { + x if x < 50.0 => "low", + x if x < 80.0 => "medium", + _ => "high", + } +} + +pub fn calc_coverage_display_info( + hit: usize, + miss: usize, +) -> (usize, f32, &'static str) { + let total = hit + miss; + let percent = if total == 0 { + 100.0 + } else { + (hit as f32 / total as f32) * 100.0 + }; + let class = percent_to_class(percent); + (total, percent, class) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_find_root() { + let urls = vec![ + Url::parse("file:///a/b/c/d/e.ts").unwrap(), + Url::parse("file:///a/b/c/d/f.ts").unwrap(), + Url::parse("file:///a/b/c/d/g.ts").unwrap(), + ]; + let urls = urls.iter().collect(); + assert_eq!(find_root(urls), Url::parse("file:///a/b/c/d/").ok()); + } + + #[test] + fn test_find_root_empty() { + let urls = vec![]; + assert_eq!(find_root(urls), None); + } + + #[test] + fn test_find_root_with_similar_filenames() { + let urls = vec![ + Url::parse("file:///a/b/c/d/foo0.ts").unwrap(), + Url::parse("file:///a/b/c/d/foo1.ts").unwrap(), + Url::parse("file:///a/b/c/d/foo2.ts").unwrap(), + ]; + let urls = urls.iter().collect(); + assert_eq!(find_root(urls), Url::parse("file:///a/b/c/d/").ok()); + } + + #[test] + fn test_find_root_with_similar_dirnames() { + let urls = vec![ + Url::parse("file:///a/b/c/foo0/mod.ts").unwrap(), + Url::parse("file:///a/b/c/foo1/mod.ts").unwrap(), + Url::parse("file:///a/b/c/foo2/mod.ts").unwrap(), + ]; + let urls = urls.iter().collect(); + assert_eq!(find_root(urls), Url::parse("file:///a/b/c/").ok()); + } + + #[test] + fn test_percent_to_class() { + assert_eq!(percent_to_class(0.0), "low"); + assert_eq!(percent_to_class(49.9), "low"); + assert_eq!(percent_to_class(50.0), "medium"); + assert_eq!(percent_to_class(79.9), "medium"); + assert_eq!(percent_to_class(80.0), "high"); + assert_eq!(percent_to_class(100.0), "high"); + } +}