From d68d1e202285df30893968c8ba71b4a0a769b357 Mon Sep 17 00:00:00 2001 From: Yoshiya Hinosawa Date: Fri, 8 Dec 2023 16:54:52 +0900 Subject: 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/tools/coverage/mod.rs | 388 ++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 379 insertions(+), 9 deletions(-) (limited to 'cli/tools/coverage/mod.rs') diff --git a/cli/tools/coverage/mod.rs b/cli/tools/coverage/mod.rs index cd4afe360..79899ddd8 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(()) } +} + +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(); - fn done(&mut self) {} + 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(()) } -- cgit v1.2.3