mirror of
https://github.com/denoland/deno.git
synced 2024-11-24 15:19:26 -05:00
feat(coverage): add html reporter (#21495)
Co-authored-by: Bartek Iwańczuk <biwanczuk@gmail.com>
This commit is contained in:
parent
c5c5dea90d
commit
d68d1e2022
10 changed files with 996 additions and 14 deletions
|
@ -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<PathBuf>,
|
||||
pub include: Vec<String>,
|
||||
pub exclude: Vec<String>,
|
||||
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::<PathBuf>("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()
|
||||
|
|
|
@ -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("<h1>Coverage report for all files</h1>"));
|
||||
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("<h1>Coverage report for foo.ts</h1>"));
|
||||
|
||||
let bar_ts_html =
|
||||
fs::read_to_string(tempdir.join("html").join("bar.ts.html")).unwrap();
|
||||
assert!(bar_ts_html.contains("<h1>Coverage report for bar.ts</h1>"));
|
||||
|
||||
let baz_index_html =
|
||||
fs::read_to_string(tempdir.join("html").join("baz").join("index.html"))
|
||||
.unwrap();
|
||||
assert!(baz_index_html.contains("<h1>Coverage report for baz/</h1>"));
|
||||
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("<h1>Coverage report for baz/qux.ts</h1>"));
|
||||
|
||||
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("<h1>Coverage report for baz/quux.ts</h1>"));
|
||||
}
|
||||
|
|
7
cli/tests/testdata/coverage/multisource/bar.ts
vendored
Normal file
7
cli/tests/testdata/coverage/multisource/bar.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
export function bar(cond: boolean) {
|
||||
if (cond) {
|
||||
return 1;
|
||||
} else {
|
||||
return 2;
|
||||
}
|
||||
}
|
7
cli/tests/testdata/coverage/multisource/baz/quux.ts
vendored
Normal file
7
cli/tests/testdata/coverage/multisource/baz/quux.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
export function quux(cond: boolean) {
|
||||
if (cond) {
|
||||
return 1;
|
||||
} else {
|
||||
return 2;
|
||||
}
|
||||
}
|
7
cli/tests/testdata/coverage/multisource/baz/qux.ts
vendored
Normal file
7
cli/tests/testdata/coverage/multisource/baz/qux.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
export function qux(cond: boolean) {
|
||||
if (cond) {
|
||||
return 1;
|
||||
} else {
|
||||
return 2;
|
||||
}
|
||||
}
|
7
cli/tests/testdata/coverage/multisource/foo.ts
vendored
Normal file
7
cli/tests/testdata/coverage/multisource/foo.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
|||
export function foo(cond: boolean) {
|
||||
if (cond) {
|
||||
return 1;
|
||||
} else {
|
||||
return 2;
|
||||
}
|
||||
}
|
20
cli/tests/testdata/coverage/multisource/test.ts
vendored
Normal file
20
cli/tests/testdata/coverage/multisource/test.ts
vendored
Normal file
|
@ -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);
|
||||
});
|
|
@ -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<FunctionCoverageItem>,
|
||||
branches: Vec<BranchCoverageItem>,
|
||||
/// (line_index, number_of_hits)
|
||||
found_lines: Vec<(usize, i64)>,
|
||||
output: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CoverageStats<'a> {
|
||||
pub line_hit: usize,
|
||||
pub line_miss: usize,
|
||||
pub branch_hit: usize,
|
||||
pub branch_miss: usize,
|
||||
pub parent: Option<String>,
|
||||
pub file_text: Option<String>,
|
||||
pub report: Option<&'a CoverageReport>,
|
||||
}
|
||||
|
||||
type CoverageSummary<'a> = HashMap<String, CoverageStats<'a>>;
|
||||
|
||||
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!(
|
||||
"<!doctype html>
|
||||
<html>
|
||||
{head}
|
||||
<body>
|
||||
<div class='wrapper'>
|
||||
{header}
|
||||
<div class='pad1'>
|
||||
{main_content}
|
||||
</div>
|
||||
<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");
|
||||
format!(
|
||||
"
|
||||
<head>
|
||||
<meta charset='utf-8'>
|
||||
<title>{title}</title>
|
||||
<style>{style_css}</style>
|
||||
<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,
|
||||
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!(
|
||||
"
|
||||
<div class='pad1'>
|
||||
<h1>{title}</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'>{line_percent:.2}%</span>
|
||||
<span class='quiet'>Lines</span>
|
||||
<span class='fraction'>{line_hit}/{line_total}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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 = env!("CARGO_PKG_VERSION");
|
||||
format!(
|
||||
"
|
||||
<div class='footer quiet pad2 space-top1 center small'>
|
||||
Code coverage generated by
|
||||
<a href='https://deno.com/' target='_blank'>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, .. } =
|
||||
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!("
|
||||
<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 {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'>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: &String,
|
||||
report: &CoverageReport,
|
||||
) -> String {
|
||||
let line_num = file_text.lines().count();
|
||||
let line_count = (1..line_num + 1)
|
||||
.map(|i| format!("<a name='L{i}'></a><a href='#{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'>x{count}</span>")
|
||||
}
|
||||
} 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");
|
||||
|
||||
// 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>"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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(())
|
||||
}
|
||||
|
|
371
cli/tools/coverage/style.css
Normal file
371
cli/tools/coverage/style.css
Normal file
|
@ -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;
|
||||
}
|
103
cli/tools/coverage/util.rs
Normal file
103
cli/tools/coverage/util.rs
Normal file
|
@ -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<Url> {
|
||||
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::<String>();
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue