/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/tools/coverage/reporter.rs
858 строк
26 KB
Bartek Iwańczuk
chore: add deno_print crate with drop_println! macros (#35737)
03 июл 2026, 15:22
Не верифицирован
03 июл 2026, 15:22
c0f2b11
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::collections::HashMap; use std::fs; use std::fs::File; use std::io::Error; use std::io::Write; use std::io::{self}; use std::path::Path; use std::path::PathBuf; use deno_core::error::AnyError; use deno_core::url::Url; use deno_lib::version::DENO_VERSION_INFO; use deno_print::drop_print; use deno_print::drop_println; use super::CoverageReport; use super::util; use crate::args::CoverageType; use crate::colors; use crate::util::fs::canonicalize_path; #[derive(Default, Debug)] pub struct CoverageStats<'a> { pub line_hit: usize, pub line_miss: usize, pub branch_hit: usize, pub branch_miss: usize, pub fn_hit: usize, pub fn_miss: usize, pub parent: Option<String>, pub file_text: Option<String>, pub report: Option<&'a CoverageReport>, } impl CoverageStats<'_> { /// Adds the line, branch, and function hit/miss counts of a single report. /// Shared by the summary reporter and the threshold check so both derive /// percentages from the same definition of a covered line/branch/function. pub fn add_report(&mut self, report: &CoverageReport) { self.line_hit += report .found_lines .iter() .filter(|(_, count)| *count > 0) .count(); self.line_miss += report .found_lines .iter() .filter(|(_, count)| *count == 0) .count(); self.branch_hit += report.branches.iter().filter(|b| b.is_hit).count(); self.branch_miss += report.branches.iter().filter(|b| !b.is_hit).count(); self.fn_hit += report .named_functions .iter() .filter(|f| f.execution_count > 0) .count(); self.fn_miss += report .named_functions .iter() .filter(|f| f.execution_count == 0) .count(); } } type CoverageSummary<'a> = HashMap<String, CoverageStats<'a>>; pub fn create(kind: CoverageType) -> Box<dyn CoverageReporter + Send> { match kind { CoverageType::Summary => Box::new(SummaryCoverageReporter::new()), CoverageType::Lcov => Box::new(LcovCoverageReporter::new()), CoverageType::Detailed => Box::new(DetailedCoverageReporter::new()), CoverageType::Html => Box::new(HtmlCoverageReporter::new()), } } pub trait CoverageReporter { fn done( &self, coverage_root: &Path, file_reports: &[(CoverageReport, String)], ); /// Collects the coverage summary of each file or directory. fn collect_summary<'a>( &'a self, file_reports: &'a [(CoverageReport, String)], ) -> CoverageSummary<'a> { let urls = file_reports.iter().map(|rep| &rep.0.url).collect(); let root = match util::find_root(urls) .and_then(|root_path| root_path.to_file_path().ok()) { Some(path) => path, None => return HashMap::new(), }; // 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.add_report(report); file_text = None; summary_path = path.parent(); } } summary } } pub struct SummaryCoverageReporter {} impl SummaryCoverageReporter { pub fn new() -> SummaryCoverageReporter { SummaryCoverageReporter {} } fn print_coverage_line( &self, node: &str, node_max: usize, stats: &CoverageStats, ) { let CoverageStats { line_hit, line_miss, branch_hit, branch_miss, fn_hit, fn_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 (_, fn_percent, fn_class) = util::calc_coverage_display_info(*fn_hit, *fn_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 fn_percent = if fn_class == "high" { format!("{}", colors::green(&format!("{:>10.1}", fn_percent))) } else if fn_class == "medium" { format!("{}", colors::yellow(&format!("{:>10.1}", fn_percent))) } else { format!("{}", colors::red(&format!("{:>10.1}", fn_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))) }; drop_println!( "| {file_name} | {branch_percent} | {fn_percent} | {line_percent} |", file_name = file_name, branch_percent = branch_percent, fn_percent = fn_percent, line_percent = line_percent, ); } } impl CoverageReporter for SummaryCoverageReporter { fn done( &self, _coverage_root: &Path, file_reports: &[(CoverageReport, String)], ) { let summary = self.collect_summary(file_reports); // When the reports can't be reduced to a common file-system root (e.g. on // Windows when coverage spans multiple drive letters), `collect_summary` // returns an empty map without the "" root entry. There is nothing to // print in that case, so warn and bail out instead of panicking. let Some(root_stats) = summary.get("") else { log::warn!( "Skipping coverage summary: reports span multiple file-system roots" ); return; }; let mut entries = summary .iter() .filter(|(_, stats)| stats.file_text.is_some()) .collect::<Vec<_>>(); entries.sort_by_key(|(node, _)| node.to_owned()); // `entries` is always non-empty here: reaching this point means the summary // has a "" root, which only happens when `collect_summary` resolved a common // root from non-empty `file_reports`, and each report inserts a leaf entry // with `file_text: Some(..)`. `unwrap_or(0)` is just defense-in-depth. let node_max = entries .iter() .map(|(node, _)| node.len()) .max() .unwrap_or(0) .max("All files".len()); let header = format!( "| {node:node_max$} | Branch % | Function % | Line % |", node = "File" ); let separator = format!( "| {} | {} | {} | {} |", "-".repeat(node_max), "-".repeat(8), "-".repeat(10), "-".repeat(6) ); drop_println!("{}", header); drop_println!("{}", separator); entries.iter().for_each(|(node, stats)| { self.print_coverage_line(node, node_max, stats); }); self.print_coverage_line("All files", node_max, root_stats); } } pub struct LcovCoverageReporter {} impl CoverageReporter for LcovCoverageReporter { fn done( &self, _coverage_root: &Path, file_reports: &[(CoverageReport, String)], ) { file_reports.iter().for_each(|(report, file_text)| { self.report(report, file_text).unwrap(); }); if let Some((report, _)) = file_reports.first() && let Some(ref output) = report.output { if let Ok(path) = canonicalize_path(output) { let url = Url::from_file_path(path).unwrap(); log::info!("Lcov coverage report has been generated at {}", url); } else { log::error!( "Failed to resolve the output path of Lcov report: {}", output.display() ); } } } } impl LcovCoverageReporter { pub fn new() -> LcovCoverageReporter { LcovCoverageReporter {} } fn report( &self, coverage_report: &CoverageReport, _file_text: &str, ) -> Result<(), AnyError> { // pipes output to stdout if no file is specified let out_mode: Result<Box<dyn Write>, Error> = match coverage_report.output { // only append to the file as the file should be created already Some(ref path) => File::options() .append(true) .open(path) .map(|f| Box::new(f) as Box<dyn Write>), None => Ok(Box::new(io::stdout())), }; let mut out_writer = out_mode?; let file_path = coverage_report .url .to_file_path() .ok() .and_then(|p| p.to_str().map(|p| p.to_string())) .unwrap_or_else(|| coverage_report.url.to_string()); writeln!(out_writer, "SF:{file_path}")?; for function in &coverage_report.named_functions { writeln!( out_writer, "FN:{},{}", function.line_index + 1, function.name )?; } for function in &coverage_report.named_functions { writeln!( out_writer, "FNDA:{},{}", function.execution_count, function.name )?; } let functions_found = coverage_report.named_functions.len(); writeln!(out_writer, "FNF:{functions_found}")?; let functions_hit = coverage_report .named_functions .iter() .filter(|f| f.execution_count > 0) .count(); writeln!(out_writer, "FNH:{functions_hit}")?; for branch in &coverage_report.branches { let taken = if let Some(taken) = &branch.taken { taken.to_string() } else { "-".to_string() }; writeln!( out_writer, "BRDA:{},{},{},{}", branch.line_index + 1, branch.block_number, branch.branch_number, taken )?; } let branches_found = coverage_report.branches.len(); writeln!(out_writer, "BRF:{branches_found}")?; let branches_hit = coverage_report.branches.iter().filter(|b| b.is_hit).count(); writeln!(out_writer, "BRH:{branches_hit}")?; for (index, count) in &coverage_report.found_lines { writeln!(out_writer, "DA:{},{}", index + 1, count)?; } let lines_hit = coverage_report .found_lines .iter() .filter(|(_, count)| *count != 0) .count(); writeln!(out_writer, "LH:{lines_hit}")?; let lines_found = coverage_report.found_lines.len(); writeln!(out_writer, "LF:{lines_found}")?; writeln!(out_writer, "end_of_record")?; Ok(()) } } struct DetailedCoverageReporter {} impl CoverageReporter for DetailedCoverageReporter { fn done( &self, _coverage_root: &Path, file_reports: &[(CoverageReport, String)], ) { file_reports.iter().for_each(|(report, file_text)| { self.report(report, file_text).unwrap(); }); } } impl DetailedCoverageReporter { pub fn new() -> DetailedCoverageReporter { DetailedCoverageReporter {} } fn report( &self, coverage_report: &CoverageReport, file_text: &str, ) -> Result<(), AnyError> { let lines = file_text.split('\n').collect::<Vec<_>>(); drop_print!("cover {} ... ", coverage_report.url); let hit_lines = coverage_report .found_lines .iter() .filter(|(_, count)| *count > 0) .map(|(index, _)| *index); let missed_lines = coverage_report .found_lines .iter() .filter(|(_, count)| *count == 0) .map(|(index, _)| *index); let lines_found = coverage_report.found_lines.len(); let lines_hit = hit_lines.count(); let line_ratio = lines_hit as f32 / lines_found as f32; let line_coverage = format!("{:.3}% ({}/{})", line_ratio * 100.0, lines_hit, lines_found); if line_ratio >= 0.9 { drop_println!("{}", colors::green(&line_coverage)); } else if line_ratio >= 0.75 { drop_println!("{}", colors::yellow(&line_coverage)); } else { drop_println!("{}", colors::red(&line_coverage)); } let mut last_line = None; for line_index in missed_lines { const WIDTH: usize = 4; const SEPARATOR: &str = "|"; // Put a horizontal separator between disjoint runs of lines if let Some(last_line) = last_line && last_line + 1 != line_index { let dash = colors::gray("-".repeat(WIDTH + 1)); drop_println!("{}{}{}", dash, colors::gray(SEPARATOR), dash); } drop_println!( "{:width$} {} {}", line_index + 1, colors::gray(SEPARATOR), colors::red(&lines[line_index]), width = WIDTH ); last_line = Some(line_index); } Ok(()) } } pub struct HtmlCoverageReporter {} impl CoverageReporter for HtmlCoverageReporter { fn done( &self, coverage_root: &Path, file_reports: &[(CoverageReport, String)], ) { let summary = self.collect_summary(file_reports); let now = chrono::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( canonicalize_path(&coverage_root.join("html").join("index.html")) .unwrap(), ) .unwrap(); log::info!("HTML coverage report has been generated at {}", root_report); } } impl HtmlCoverageReporter { pub fn new() -> HtmlCoverageReporter { HtmlCoverageReporter {} } /// 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 breadcrumbs_parts = node .split(std::path::MAIN_SEPARATOR) .filter(|s| !s.is_empty()) .collect::<Vec<_>>(); let head = self.create_html_head(&title); let breadcrumb_navigation = self.create_breadcrumbs_navigation(&breadcrumbs_parts, is_dir); let header = self.create_html_header(&breadcrumb_navigation, stats); let footer = self.create_html_footer(timestamp); format!( "<!doctype html> <html lang='en-US'> {head} <body> <div class='wrapper'> {header} <main class='pad1 overflow-auto'> {main_content} </main> <div class='push'></div> </div> {footer} </body> </html>" ) } /// Creates <head> tag for html report. pub fn create_html_head(&self, title: &str) -> String { let style_css = include_str!("style.css"); let script = include_str!("script.js"); format!( " <head> <meta charset='utf-8'> <title>{title}</title> <meta name='description' content='Deno HTML coverage report'> <link rel='icon' href='data:,'> <style>{style_css}</style> <script>{script}</script> <meta name='viewport' content='width=device-width, initial-scale=1'> </head>" ) } /// Creates header part of the contents for html report. pub fn create_html_header( &self, breadcrumb_navigation: &str, stats: &CoverageStats, ) -> String { let CoverageStats { line_hit, line_miss, branch_hit, branch_miss, fn_hit, fn_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); let (fn_total, fn_percent, _) = util::calc_coverage_display_info(*fn_hit, *fn_miss); let moon_svg = include_str!("moon.svg"); let sun_svg = include_str!("sun.svg"); format!( r#" <div class='pad1 flex-header'> <div> <h1>{breadcrumb_navigation}</h1> <div class='clearfix'> <div class='fl pad1y space-right2'> <span class='strong'>{branch_percent:.2}%</span> <span class='quiet'>Branches</span> <span class='fraction'>{branch_hit}/{branch_total}</span> </div> <div class='fl pad1y space-right2'> <span class='strong'>{fn_percent:.2}%</span> <span class='quiet'>Functions</span> <span class='fraction'>{fn_hit}/{fn_total}</span> </div> <div class='fl pad1y space-right2'> <span class='strong'>{line_percent:.2}%</span> <span class='quiet'>Lines</span> <span class='fraction'>{line_hit}/{line_total}</span> </div> </div> </div> <button id="theme-toggle" type="button" aria-label="Toggle dark mode" style="display: none;"> {moon_svg} {sun_svg} </button> </div> <div class='status-line {line_class}'></div>"# ) } /// Creates footer part of the contents for html report. pub fn create_html_footer(&self, now: &str) -> String { let version = DENO_VERSION_INFO.deno; format!( " <div class='footer quiet pad2 space-top1 center small'> Code coverage generated by <a href='https://deno.com/' target='_blank' rel='noopener noreferrer'>Deno v{version}</a> at {now} </div>" ) } /// Creates <table> 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::<Vec<_>>(); // Sort directories first, then files children.sort(); let table_rows: Vec<String> = children.iter().map(|(is_file, c)| { let CoverageStats { line_hit, line_miss, branch_hit, branch_miss, fn_hit, fn_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 (fn_total, fn_percent, fn_class) = util::calc_coverage_display_info(*fn_hit, *fn_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!(" <tr> <td class='file {line_class}'><a href='{path_link}'>{path_label}</a></td> <td class='pic {line_class}'> <div class='chart'> <div class='cover-fill' style='width: {line_percent:.1}%'></div><div class='cover-empty' style='width: calc(100% - {line_percent:.1}%)'></div> </div> </td> <td class='pct {branch_class}'>{branch_percent:.2}%</td> <td class='abs {branch_class}'>{branch_hit}/{branch_total}</td> <td class='pct {fn_class}'>{fn_percent:.2}%</td> <td class='abs {fn_class}'>{fn_hit}/{fn_total}</td> <td class='pct {line_class}'>{line_percent:.2}%</td> <td class='abs {line_class}'>{line_hit}/{line_total}</td> </tr>")}).collect(); let table_rows = table_rows.join("\n"); format!( " <table class='coverage-summary'> <thead> <tr> <th class='file'>File</th> <th class='pic'></th> <th class='pct'>Branches</th> <th class='abs'></th> <th class='pct'>Functions</th> <th class='abs'></th> <th class='pct'>Lines</th> <th class='abs'></th> </tr> </thead> <tbody> {table_rows} </tbody> </table>" ) } /// Creates <table> of single file code coverage. pub fn create_html_code_table( &self, file_text: &str, report: &CoverageReport, ) -> String { let line_num = file_text.lines().count(); let line_count = (1..line_num + 1) .map(|i| format!("<a href='#L{i}' id='L{i}'>{i}</a>")) .collect::<Vec<_>>() .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 { "<span class='cline-any cline-no'> </span>".to_string() } else { format!("<span class='cline-any cline-yes' title='This line is covered {count} time{}'>x{count}</span>", if *count > 1 { "s" } else { "" }) } } else { "<span class='cline-any cline-neutral'> </span>".to_string() } }) .collect::<Vec<_>>() .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 { "<span class='missing-if-branch' title='branch condition is missed in this line'>I</span>".to_string() } else { "".to_string() } }) .collect::<Vec<_>>() .join("\n"); let file_text = file_text .replace('&', "&") .replace('<', "<") .replace('>', ">"); // TODO(kt3k): Add syntax highlight to source code format!( "<table class='coverage'> <tr> <td class='line-count quiet'><pre>{line_count}</pre></td> <td class='line-coverage quiet'><pre>{line_coverage}</pre></td> <td class='branch-coverage quiet'><pre>{branch_coverage}</pre></td> <td class='text'><pre class='prettyprint'>{file_text}</pre></td> </tr> </table>" ) } pub fn create_breadcrumbs_navigation( &self, breadcrumbs_parts: &[&str], is_dir: bool, ) -> String { let mut breadcrumbs_html = Vec::new(); let root_repeats = if is_dir { breadcrumbs_parts.len() } else { breadcrumbs_parts.len() - 1 }; let mut root_url = "../".repeat(root_repeats); root_url += "index.html"; breadcrumbs_html.push(format!("<a href='{root_url}'>All files</a>")); for (index, breadcrumb) in breadcrumbs_parts.iter().enumerate() { let mut full_url = "../".repeat(breadcrumbs_parts.len() - (index + 1)); if index == breadcrumbs_parts.len() - 1 { breadcrumbs_html.push(breadcrumb.to_string()); continue; } if is_dir { full_url += "index.html"; } else { full_url += breadcrumb; if index != breadcrumbs_parts.len() - 1 { full_url += "/index.html"; } } breadcrumbs_html.push(format!("<a href='{full_url}'>{breadcrumb}</a>")) } if breadcrumbs_parts.is_empty() { return String::from("All files"); } breadcrumbs_html.into_iter().collect::<Vec<_>>().join(" / ") } } #[cfg(test)] mod tests { use super::*; // Regression test for https://github.com/denoland/deno/issues/30924. // // When the reports cannot be reduced to a common file-system root (e.g. on // Windows when coverage spans multiple drive letters), `collect_summary` // returns an empty map without the "" root entry. The summary reporter used // to `unwrap()` that entry and panic. It should instead skip the summary // gracefully. #[test] fn summary_reporter_does_not_panic_without_common_root() { let report = CoverageReport { // A non-`file:` URL has no file-system path, so `find_root` cannot // produce a usable root and `collect_summary` returns an empty map. url: Url::parse("https://example.com/main.ts").unwrap(), named_functions: Vec::new(), branches: Vec::new(), found_lines: vec![(0, 1)], output: None, }; let file_reports = vec![(report, "console.log(1);".to_string())]; let reporter = SummaryCoverageReporter::new(); // Must not panic. reporter.done(Path::new("coverage"), &file_reports); } }