/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
cli/tools/coverage/mod.rs
1 046 строк
34 KB
Ian Hickson
fix(coverage): count a branch-junction line as covered when either arm runs (#35858)
08 июл 2026, 11:53
Не верифицирован
08 июл 2026, 11:53
95407f0
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::fs; use std::fs::File; use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use deno_ast::MediaType; use deno_ast::ModuleKind; use deno_ast::ModuleSpecifier; use deno_config::glob::FileCollector; use deno_config::glob::FilePatterns; use deno_config::glob::PathOrPattern; use deno_config::glob::PathOrPatternSet; use deno_core::anyhow::Context; use deno_core::anyhow::anyhow; use deno_core::error::AnyError; use deno_core::serde_json; use deno_core::sourcemap::SourceMap; use deno_core::url::Url; use deno_resolver::npm::DenoInNpmPackageChecker; use node_resolver::InNpmPackageChecker; use regex::Regex; use reporter::CoverageReporter; use text_lines::TextLines; use self::ignore_directives::CoverageComment; use self::ignore_directives::has_file_ignore_directive; use self::ignore_directives::lex_comments; use self::ignore_directives::parse_next_ignore_directives; use self::ignore_directives::parse_range_ignore_directives; use crate::args::CliOptions; use crate::args::CoverageThresholds; use crate::args::FileFlags; use crate::args::Flags; use crate::cdp; use crate::factory::CliFactory; use crate::file_fetcher::TextDecodedFile; use crate::sys::CliSys; use crate::tools::test::is_supported_test_path; use crate::util::text_encoding::source_map_from_code; mod ignore_directives; mod merge; mod range_tree; pub mod reporter; mod util; use merge::ProcessCoverage; #[derive(Debug, Clone)] struct BranchCoverageItem { line_index: usize, block_number: usize, branch_number: usize, taken: Option<i64>, is_hit: bool, } #[derive(Debug, Clone)] struct FunctionCoverageItem { name: String, line_index: usize, execution_count: i64, } #[derive(Debug, Clone)] pub struct CoverageReport { url: ModuleSpecifier, named_functions: Vec<FunctionCoverageItem>, branches: Vec<BranchCoverageItem>, /// (line_index, number_of_hits) found_lines: Vec<(usize, i64)>, output: Option<PathBuf>, } struct GenerateCoverageReportOptions<'a> { script_module_specifier: Url, script_media_type: MediaType, script_coverage: &'a cdp::ScriptCoverage, script_original_source: String, script_runtime_source: String, maybe_source_map: &'a Option<Vec<u8>>, output: &'a Option<PathBuf>, } /// The byte offset of the end of the code on the line /// `line_start_byte_offset..line_end_byte_offset`, with a trailing comment and /// the whitespace before it removed. A comment does not execute, so it must not /// decide whether a zero-count range reaches the line's end edge when line hit /// counts are projected from V8's block ranges. Without this, the same statement /// is credited to its un-taken branch (0 hits) when nothing follows it and to /// the covered trailing comment (1 hit) when one does. A line with no trailing /// comment keeps its raw end, so its attribution is unchanged. fn line_content_end_byte_offset( source: &str, line_start_byte_offset: usize, line_end_byte_offset: usize, comments: &[CoverageComment], ) -> usize { let mut end = line_end_byte_offset; let mut stripped_comment = false; loop { let trimmed_end = line_start_byte_offset + source[line_start_byte_offset..end].trim_end().len(); let trailing_comment = comments.iter().find(|comment| { comment.range.start >= line_start_byte_offset && comment.range.start < trimmed_end && comment.range.end >= trimmed_end }); match trailing_comment { Some(comment) => { stripped_comment = true; end = comment.range.start; } None => { if stripped_comment { end = trimmed_end; } return end; } } } } fn generate_coverage_report( options: GenerateCoverageReportOptions, ) -> Result<CoverageReport, AnyError> { let original_comments = lex_comments(&options.script_original_source, options.script_media_type); let url = Url::parse(&options.script_coverage.url).unwrap(); if has_file_ignore_directive(&original_comments) { return Ok(CoverageReport { url, named_functions: Vec::new(), branches: Vec::new(), found_lines: Vec::new(), output: options.output.clone(), }); } let maybe_source_map = options .maybe_source_map .as_ref() .map(|source_map| SourceMap::from_slice(source_map).unwrap()); let mut coverage_report = CoverageReport { url, named_functions: Vec::with_capacity( options .script_coverage .functions .iter() .filter(|f| !f.function_name.is_empty()) .count(), ), branches: Vec::new(), found_lines: Vec::new(), output: options.output.clone(), }; let original_text_lines = TextLines::new(&options.script_original_source); let coverage_ignore_next_directives = parse_next_ignore_directives( &original_comments.comments, &original_text_lines, ); let coverage_ignore_range_directives = parse_range_ignore_directives( &options.script_module_specifier, &original_comments.comments, &original_text_lines, ); let runtime_comments = lex_comments(&options.script_runtime_source, MediaType::JavaScript); let runtime_text_lines = TextLines::new(&options.script_runtime_source); for function in &options.script_coverage.functions { if function.function_name.is_empty() { continue; } // Skip functions that the source map can't trace back to the original // source — these are injected by the transformer (e.g. SWC's decorator // runtime helpers) and shouldn't show up in user-visible coverage. let Some(line_index) = range_to_src_line_index( &function.ranges[0], &runtime_text_lines, &maybe_source_map, ) else { continue; }; if line_index > 0 && coverage_ignore_next_directives.contains(&(line_index - 1_usize)) { continue; } if coverage_ignore_range_directives.iter().any(|range| { range.start_line_index <= line_index && range.stop_line_index >= line_index }) { continue; } coverage_report.named_functions.push(FunctionCoverageItem { name: function.function_name.clone(), line_index, execution_count: function.ranges[0].count, }); } for (block_number, function) in options.script_coverage.functions.iter().enumerate() { let block_hits = function.ranges[0].count; // For each sub-range, find the parent count: the count of the smallest // enclosing range. V8 ranges are nested (ranges[0] is the function body, // inner ranges are blocks/branches/loops). The parent count is needed to // correctly compute complement branches — e.g. for an if/else inside a // loop, the parent is the loop body, not the function. let mut parent_counts: Vec<i64> = Vec::with_capacity(function.ranges.len().saturating_sub(1)); for range in &function.ranges[1..] { let mut parent_count = block_hits; let mut parent_size = usize::MAX; for candidate in &function.ranges { if std::ptr::eq(candidate, range) { continue; } if candidate.start_char_offset <= range.start_char_offset && candidate.end_char_offset >= range.end_char_offset { let size = candidate.end_char_offset - candidate.start_char_offset; if size < parent_size { parent_size = size; parent_count = candidate.count; } } } parent_counts.push(parent_count); } // Group sub-ranges by their source line to detect branch points. // When multiple ranges map to the same source line, they represent // different arms of the same branch (e.g. if/else). let mut branches_by_line: std::collections::BTreeMap< usize, Vec<(usize, &cdp::CoverageRange)>, > = std::collections::BTreeMap::new(); for (idx, range) in function.ranges[1..].iter().enumerate() { // Skip whitespace-only "gap" ranges V8 emits between blocks (e.g. the // sliver between a catch's `return` and `finally`). They aren't real // branch arms, so counting them yields phantom branches (#35765). let start_byte_offset = runtime_text_lines.byte_index_from_char_index(range.start_char_offset); let end_byte_offset = runtime_text_lines.byte_index_from_char_index(range.end_char_offset); if options.script_runtime_source[start_byte_offset..end_byte_offset] .trim() .is_empty() { continue; } // Same rationale as above: drop sub-ranges with no source mapping — // they belong to transformer-injected helper code, not the user's // source. let Some(line_index) = range_to_src_line_index(range, &runtime_text_lines, &maybe_source_map) else { continue; }; branches_by_line .entry(line_index) .or_default() .push((idx, range)); } for (line_index, ranges) in &branches_by_line { if *line_index > 0 && coverage_ignore_next_directives.contains(&(line_index - 1_usize)) { continue; } if coverage_ignore_range_directives.iter().any(|range| { range.start_line_index <= *line_index && range.stop_line_index >= *line_index }) { continue; } if ranges.len() == 1 { let (idx, range) = &ranges[0]; let enclosing_count = parent_counts[*idx]; if range.count > enclosing_count { // Range executes more often than its enclosing scope — this is a // loop body, not a branch arm. Report it without a complement. coverage_report.branches.push(BranchCoverageItem { line_index: *line_index, block_number, branch_number: 0, taken: if enclosing_count > 0 { Some(range.count) } else { None }, is_hit: range.count > 0, }); } else { // Single range at this line: one arm of a branch. The complement // (e.g. the else for an if) is implicit with count equal to the // enclosing range's count minus this range's count. Using the // enclosing (parent) range rather than the function-level count // gives correct complements for branches inside loops. let taken = if enclosing_count > 0 { Some(range.count) } else { None }; let complement_taken = if enclosing_count > 0 { Some(enclosing_count - range.count) } else { None }; coverage_report.branches.push(BranchCoverageItem { line_index: *line_index, block_number, branch_number: 0, taken, is_hit: range.count > 0, }); coverage_report.branches.push(BranchCoverageItem { line_index: *line_index, block_number, branch_number: 1, taken: complement_taken, is_hit: complement_taken.is_some_and(|c| c > 0), }); } } else { // Multiple ranges at the same line: these are explicit branch arms // (e.g. both if and else blocks reported by V8). for (branch_number, (_, range)) in ranges.iter().enumerate() { let taken = if block_hits > 0 { Some(range.count) } else { None }; coverage_report.branches.push(BranchCoverageItem { line_index: *line_index, block_number, branch_number, taken, is_hit: range.count > 0, }); } } } } // TODO(caspervonb): collect uncovered ranges on the lines so that we can highlight specific // parts of a line in color (word diff style) instead of the entire line. let mut line_counts = Vec::with_capacity(runtime_text_lines.lines_count()); for line_index in 0..runtime_text_lines.lines_count() { let (line_start_byte_offset, line_end_byte_offset) = runtime_text_lines.line_range(line_index); let line_start_char_offset = runtime_text_lines.char_index(line_start_byte_offset); let line_end_char_offset = runtime_text_lines.char_index(line_end_byte_offset); // The line's end edge for the reset pass below is the end of its code, // excluding a trailing comment so a comment cannot change what the line is // credited with. let content_end_byte_offset = line_content_end_byte_offset( &options.script_runtime_source, line_start_byte_offset, line_end_byte_offset, &runtime_comments.comments, ); let line_content_end_char_offset = runtime_text_lines.char_index(content_end_byte_offset); let ignore = runtime_comments.comments.iter().any(|comment| { comment.range.start <= line_start_byte_offset && comment.range.end >= line_end_byte_offset }) || options.script_runtime_source [line_start_byte_offset..line_end_byte_offset] .trim() .is_empty(); let mut count = 0; if ignore { count = 1; } else { // Find the count from the most specific (smallest/innermost) range that // fully covers this line. V8 coverage ranges are nested: ranges[0] is the // function body and ranges[1..] are inner blocks/branches. The innermost // range that covers a line gives the correct execution count for that line. let mut best_range_size = usize::MAX; for function in &options.script_coverage.functions { for range in &function.ranges { if range.start_char_offset <= line_start_char_offset && range.end_char_offset >= line_end_char_offset { let range_size = range.end_char_offset - range.start_char_offset; if range_size < best_range_size { best_range_size = range_size; count = range.count; } } } } // Reset the count when a zero-count range genuinely covers this line's // code, judged three ways below. Merely reaching one edge is not enough: // where a covered arm and a zero-count sibling arm meet on one line, the // covered side is real code that ran and must not be hidden. That is the // case at a branch junction such as `} else {`, `} catch {`, or // `} finally {`: when only one arm ran, the other arm's zero-count range // clips the opposite edge, and zeroing the whole line on that single edge // is what otherwise makes the junction count as covered only when both // arms run in the same coverage process. Splitting the two arms across // separate test files then drops the junction from the merged line // coverage. A zero-count range floating in the middle of a line (matching // none of the tests) is a V8 block-boundary gap, e.g. the unreachable path // between a catch's return and finally, and is ignored. for (range_index, range) in options .script_coverage .functions .iter() .flat_map(|function| function.ranges.iter().enumerate()) { if range.count > 0 { continue; } let overlaps = range.start_char_offset < line_end_char_offset && range.end_char_offset > line_start_char_offset; if !overlaps { continue; } // The line sits entirely inside the uncovered range: a fully uncovered // line, or one in the middle of a multi-line uncovered block. let spans_content = range.start_char_offset <= line_start_char_offset && range.end_char_offset >= line_content_end_char_offset; // An uncovered statement confined to this line that runs to the end of // its code, e.g. the never-taken `throw` in `if (!x) throw …`. A // multi-line block whose brace only clips a junction extends past the // line end and is excluded here, so the covered half survives. let inline_uncovered = range.start_char_offset >= line_start_char_offset && range.end_char_offset <= line_end_char_offset && range.end_char_offset >= line_content_end_char_offset; // A never-called function is a single count-0 range (ranges[0]) covering // the whole function. Its signature line stays uncovered like its body, // even though the range starts a few characters into that line, after an // `export`/`async` keyword, which the span test above would miss. let whole_function = range_index == 0 && (range.start_char_offset <= line_start_char_offset || range.end_char_offset >= line_content_end_char_offset); if spans_content || inline_uncovered || whole_function { count = 0; } } } line_counts.push(count); } let found_lines_coverage_filter = |(line, _): &(usize, i64)| -> bool { if coverage_ignore_range_directives.iter().any(|range| { range.start_line_index <= *line && range.stop_line_index >= *line }) { return false; } if coverage_ignore_next_directives.contains(line) { return false; } if *line == 0_usize { return true; } if coverage_ignore_next_directives.contains(&(line - 1_usize)) { return false; } true }; coverage_report.found_lines = if let Some(source_map) = maybe_source_map.as_ref() { let script_runtime_source_lines = options.script_runtime_source.lines().collect::<Vec<_>>(); let mut found_lines = line_counts .iter() .enumerate() .flat_map(|(index, count)| { // get all the mappings from this destination line to a different src line let mut results = source_map .tokens() .filter(|token| { let dst_line = token.get_dst_line() as usize; dst_line == index && { let dst_col = token.get_dst_col() as usize; let content = script_runtime_source_lines .get(dst_line) .and_then(|line| { line.get(dst_col..std::cmp::min(dst_col + 2, line.len())) }) .unwrap_or(""); !content.is_empty() && content != "/*" && content != "*/" && content != "//" } }) .map(move |token| (token.get_src_line() as usize, *count)) .collect::<Vec<_>>(); // only keep the results that point at different src lines results.sort_unstable_by_key(|(index, _)| *index); results.dedup_by_key(|(index, _)| *index); results.into_iter() }) .filter(found_lines_coverage_filter) .collect::<Vec<(usize, i64)>>(); found_lines.sort_unstable_by_key(|(index, _)| *index); // combine duplicated lines - when multiple compiled JS lines map to the // same source line, use the maximum count rather than summing, since // hitting the same source line from different compiled lines doesn't mean // it was executed more times. for i in (1..found_lines.len()).rev() { if found_lines[i].0 == found_lines[i - 1].0 { found_lines[i - 1].1 = std::cmp::max(found_lines[i - 1].1, found_lines[i].1); found_lines.remove(i); } } found_lines } else { line_counts .into_iter() .enumerate() .filter(found_lines_coverage_filter) .collect::<Vec<(usize, i64)>>() }; Ok(coverage_report) } /// Maps a runtime coverage range back to a line in the original source. /// /// Returns `None` when the source map exists but has no mapping for this /// position. That happens for code injected by the transformer (e.g. SWC's /// decorator runtime helpers like `applyDecs2203R`) — the bytes exist in /// the runtime JS but didn't come from the user's source, so callers /// should drop these coverage entries rather than reporting them as if /// they were the user's own code. fn range_to_src_line_index( range: &cdp::CoverageRange, text_lines: &TextLines, maybe_source_map: &Option<SourceMap>, ) -> Option<usize> { let source_lc = text_lines.line_and_column_index( text_lines.byte_index_from_char_index(range.start_char_offset), ); if let Some(source_map) = maybe_source_map.as_ref() { source_map .lookup_token(source_lc.line_index as u32, source_lc.column_index as u32) .map(|token| token.get_src_line() as usize) } else { Some(source_lc.line_index) } } fn collect_coverages( cli_options: &CliOptions, files: FileFlags, initial_cwd: &Path, ) -> Result<Vec<cdp::ScriptCoverage>, AnyError> { let mut coverages: Vec<cdp::ScriptCoverage> = Vec::new(); let file_patterns = FilePatterns { base: initial_cwd.to_path_buf(), include: Some({ if files.include.is_empty() { PathOrPatternSet::new(vec![PathOrPattern::Path( initial_cwd.to_path_buf(), )]) } else { PathOrPatternSet::from_include_relative_path_or_patterns( initial_cwd, &files.include, )? } }), exclude: PathOrPatternSet::new(vec![]), }; let file_paths = FileCollector::new(|e| { e.path.extension().map(|ext| ext == "json").unwrap_or(false) }) .ignore_git_folder() .ignore_node_modules() .set_vendor_folder(cli_options.vendor_dir_path().map(ToOwned::to_owned)) .collect_file_patterns(&CliSys::default(), &file_patterns); let coverage_patterns = FilePatterns { base: initial_cwd.to_path_buf(), include: None, exclude: PathOrPatternSet::from_exclude_relative_path_or_patterns( initial_cwd, &files.ignore, ) .context("Invalid ignore pattern.")?, }; for file_path in file_paths { let new_coverage = fs::read_to_string(file_path.as_path()) .map_err(AnyError::from) .and_then(|json| { serde_json::from_str::<cdp::ScriptCoverage>(&json) .map_err(AnyError::from) }) .with_context(|| format!("Failed reading '{}'", file_path.display()))?; let url = Url::parse(&new_coverage.url)?; if coverage_patterns.matches_specifier(&url) { coverages.push(new_coverage); } } coverages.sort_by_key(|k| k.url.clone()); Ok(coverages) } fn filter_coverages( coverages: Vec<cdp::ScriptCoverage>, include: Vec<String>, exclude: Vec<String>, in_npm_pkg_checker: &DenoInNpmPackageChecker, link_dir_urls: &[String], ) -> Vec<cdp::ScriptCoverage> { let include: Vec<Regex> = include.iter().map(|e| Regex::new(e).unwrap()).collect(); let exclude: Vec<Regex> = exclude.iter().map(|e| Regex::new(e).unwrap()).collect(); // Matches virtual file paths for doc testing // e.g. file:///path/to/mod.ts#23-29.ts let doc_test_re = Regex::new(r"#\d+-\d+\.(js|mjs|cjs|jsx|ts|mts|cts|tsx)$").unwrap(); coverages .into_iter() .filter(|e| { let is_internal = e.url.starts_with("ext:") || e.url.starts_with("data:") || e.url.starts_with("blob:") || e.url.ends_with("__anonymous__") || e.url.ends_with("$deno$test.mjs") || e.url.contains("/$deno$stdin.") || e.url.ends_with(".snap") || is_supported_test_path(Path::new(e.url.as_str())) || doc_test_re.is_match(e.url.as_str()) // Exclude packages brought in via the "links" (formerly "patch") // feature. These are third-party dependencies replacing a registry // version, so they shouldn't show up in the user's coverage report, // matching how npm packages are excluded above. || link_dir_urls.iter().any(|dir| e.url.starts_with(dir)) || Url::parse(&e.url) .ok() .map(|url| in_npm_pkg_checker.in_npm_package(&url)) .unwrap_or(false); let is_included = include.iter().any(|p| p.is_match(&e.url)); let is_excluded = exclude.iter().any(|p| p.is_match(&e.url)); (include.is_empty() || is_included) && !is_excluded && !is_internal }) .collect::<Vec<cdp::ScriptCoverage>>() } #[allow(clippy::too_many_arguments, reason = "coverage entry point")] pub fn cover_files( flags: Arc<Flags>, files_include: Vec<String>, files_ignore: Vec<String>, include: Vec<String>, exclude: Vec<String>, output: Option<String>, cli_threshold: Option<f64>, reporters: &[&dyn CoverageReporter], ) -> Result<(), AnyError> { if files_include.is_empty() { return Err(anyhow!("No matching coverage profiles found")); } let factory = CliFactory::from_flags(flags); let cli_options = factory.cli_options()?; let in_npm_pkg_checker = factory.in_npm_pkg_checker()?; let file_fetcher = factory.file_fetcher()?; let emitter = factory.emitter()?; let cjs_tracker = factory.cjs_tracker()?; // Use the first include path as the default output path. let coverage_root = cli_options.initial_cwd().join(&files_include[0]); let script_coverages = collect_coverages( cli_options, FileFlags { include: files_include, ignore: files_ignore, }, cli_options.initial_cwd(), )?; if script_coverages.is_empty() { return Err(anyhow!("No coverage files found")); } let link_dir_urls = cli_options .workspace() .link_folders() .keys() .map(|url| url.as_str().to_string()) .collect::<Vec<_>>(); let script_coverages = filter_coverages( script_coverages, include, exclude, in_npm_pkg_checker, &link_dir_urls, ); if script_coverages.is_empty() { return Err(anyhow!("No covered files included in the report")); } let proc_coverages: Vec<_> = script_coverages .into_iter() .map(|mut cov| { // Merge the same module loaded under different specifiers. Fragments // never affect the source, so strip them for any file/http(s) URL. // Query strings are only stripped for `file:` (same file on disk); // http(s) servers can return different bytes per query and the cache // keys on the full URL, so keep it there. if let Ok(mut url) = Url::parse(&cov.url) { let strip_fragment = matches!(url.scheme(), "file" | "http" | "https") && url.fragment().is_some(); let strip_query = url.scheme() == "file" && url.query().is_some(); if strip_fragment { url.set_fragment(None); } if strip_query { url.set_query(None); } if strip_fragment || strip_query { cov.url = url.to_string(); } } ProcessCoverage { result: vec![cov] } }) .collect(); let script_coverages = if let Some(c) = merge::merge_processes(proc_coverages) { c.result } else { vec![] }; let out_mode = match output { Some(ref path) => match File::create(path) { Ok(_) => Some(PathBuf::from(path)), Err(e) => { return Err(anyhow!("Failed to create output file: {}", e)); } }, None => None, }; let get_message = |specifier: &ModuleSpecifier| -> String { format!( "Source not found for \"{}\" (was it deleted after coverage was collected?). Skipping.", specifier, ) }; let mut file_reports = Vec::with_capacity(script_coverages.len()); for script_coverage in script_coverages { let module_specifier = deno_path_util::resolve_url_or_path( &script_coverage.url, cli_options.initial_cwd(), )?; let maybe_file_result = file_fetcher.get_cached_source_or_local(&module_specifier); let file = match maybe_file_result { Ok(Some(file)) => TextDecodedFile::decode(file)?, Ok(None) => { log::warn!("{}", get_message(&module_specifier),); continue; } Err(err) => { log::warn!("{}: {}", get_message(&module_specifier), err,); continue; } }; let original_source = file.source.clone(); // Check if file was transpiled let transpiled_code = match file.media_type { MediaType::JavaScript | MediaType::Unknown | MediaType::Css | MediaType::Html | MediaType::Markdown | MediaType::Sql | MediaType::Wasm | MediaType::Cjs | MediaType::Mjs | MediaType::Json | MediaType::Jsonc | MediaType::Json5 => None, MediaType::Dts | MediaType::Dmts | MediaType::Dcts => Some(String::new()), MediaType::TypeScript | MediaType::Jsx | MediaType::Mts | MediaType::Cts | MediaType::Tsx => { let module_kind = ModuleKind::from_is_cjs( cjs_tracker.is_maybe_cjs(&file.specifier, file.media_type)?, ); Some( match emitter.maybe_cached_emit( &file.specifier, module_kind, &file.source, )? { Some(code) => code, None => { log::warn!( "Missing transpiled source code for: \"{}\" (was it deleted after coverage was collected?). Skipping.", file.specifier, ); continue; } }, ) } MediaType::SourceMap => { unreachable!() } }; let runtime_code: String = match transpiled_code { Some(code) => code, None => original_source.to_string(), }; let source_map = source_map_from_code(runtime_code.as_bytes()); let coverage_report = generate_coverage_report(GenerateCoverageReportOptions { script_module_specifier: module_specifier.clone(), script_media_type: file.media_type, script_coverage: &script_coverage, script_original_source: original_source.to_string(), script_runtime_source: runtime_code.as_str().to_owned(), maybe_source_map: &source_map, output: &out_mode, }) .with_context(|| { format!( "Failed to generate coverage report for file ({module_specifier})" ) })?; if !coverage_report.found_lines.is_empty() { file_reports.push((coverage_report, original_source.to_string())); } } // All covered files, might have had ignore directive and we can end up // with no reports at this point. if file_reports.is_empty() { return Err(anyhow!("No covered files included in the report")); } for reporter in reporters { reporter.done(&coverage_root, &file_reports); } // Layer the `--threshold` CLI flag over per-metric thresholds from deno.json // (the flag wins) and fail the command if any configured threshold is unmet. let config_thresholds = cli_options.resolve_coverage_thresholds()?; let thresholds = CoverageThresholds { lines: cli_threshold.or(config_thresholds.lines), branches: cli_threshold.or(config_thresholds.branches), functions: cli_threshold.or(config_thresholds.functions), }; check_coverage_thresholds(&file_reports, thresholds)?; Ok(()) } /// Computes the aggregate line, branch, and function coverage percentages /// across all reported files. A metric with no measurable items counts as 100% /// (e.g. a `branches` threshold passes vacuously for files that have no /// branches). Reuses the same accumulation and percentage helpers as the /// summary reporter so the checked numbers match the printed ones. fn aggregate_coverage_percentages( file_reports: &[(CoverageReport, String)], ) -> (f64, f64, f64) { let mut stats = reporter::CoverageStats::default(); for (report, _) in file_reports { stats.add_report(report); } let percent = |hit: usize, miss: usize| -> f64 { util::calc_coverage_display_info(hit, miss).1 as f64 }; ( percent(stats.line_hit, stats.line_miss), percent(stats.branch_hit, stats.branch_miss), percent(stats.fn_hit, stats.fn_miss), ) } fn check_coverage_thresholds( file_reports: &[(CoverageReport, String)], thresholds: CoverageThresholds, ) -> Result<(), AnyError> { if thresholds.lines.is_none() && thresholds.branches.is_none() && thresholds.functions.is_none() { return Ok(()); } let (line_percent, branch_percent, fn_percent) = aggregate_coverage_percentages(file_reports); let mut failures = Vec::new(); let mut check = |name: &str, actual: f64, threshold: Option<f64>| { if let Some(threshold) = threshold && actual < threshold { // Floor the displayed value to two decimals so a near-miss like 89.999% // doesn't render as "90.00% is below the threshold of 90.00%". let actual = (actual * 100.0).floor() / 100.0; failures.push(format!( " - {name} coverage {actual:.2}% is below the threshold of {threshold:.2}%" )); } }; check("Line", line_percent, thresholds.lines); check("Branch", branch_percent, thresholds.branches); check("Function", fn_percent, thresholds.functions); if failures.is_empty() { Ok(()) } else { Err( CoverageThresholdError(format!( "Coverage threshold not met:\n{}", failures.join("\n") )) .into(), ) } } /// Returned when a configured coverage threshold is not met. A distinct type so /// `deno test --coverage` can treat it as fatal while still tolerating benign /// report-generation errors. #[derive(Debug)] pub struct CoverageThresholdError(pub String); impl std::fmt::Display for CoverageThresholdError { fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { write!(f, "{}", self.0) } } impl std::error::Error for CoverageThresholdError {} #[cfg(test)] mod tests { use super::*; /// The source substring credited as code on `line_index`: the line with any /// trailing comment and the whitespace before it removed from its end. /// Exercises `line_content_end_byte_offset` with real lexer comment ranges. fn code_on_line(source: &str, line_index: usize) -> &str { let comments = lex_comments(source, MediaType::JavaScript).comments; let text_lines = TextLines::new(source); let (start, end) = text_lines.line_range(line_index); let content_end = line_content_end_byte_offset(source, start, end, &comments); &source[start..content_end] } #[test] fn no_trailing_comment_keeps_whole_line() { assert_eq!( code_on_line("if (!x) throw new Error(\"e\");", 0), "if (!x) throw new Error(\"e\");", ); } #[test] fn strips_trailing_line_comment() { assert_eq!( code_on_line("if (!x) throw new Error(\"e\"); // never taken", 0), "if (!x) throw new Error(\"e\");", ); } #[test] fn strips_trailing_block_comment() { assert_eq!(code_on_line("foo(); /* done */", 0), "foo();"); } #[test] fn strips_multiple_stacked_trailing_comments() { assert_eq!(code_on_line("foo(); /* a */ // b", 0), "foo();"); } #[test] fn keeps_interior_comment_when_code_follows() { assert_eq!( code_on_line("foo(/* inline */ x);", 0), "foo(/* inline */ x);" ); } #[test] fn keeps_leading_indentation() { assert_eq!(code_on_line(" return 1; // done", 0), " return 1;"); } #[test] fn comment_only_line_has_no_code() { assert_eq!(code_on_line(" // just a comment", 0), ""); } }