diff options
Diffstat (limited to 'cli/tools/coverage/mod.rs')
-rw-r--r-- | cli/tools/coverage/mod.rs | 652 |
1 files changed, 652 insertions, 0 deletions
diff --git a/cli/tools/coverage/mod.rs b/cli/tools/coverage/mod.rs new file mode 100644 index 000000000..1c14859ea --- /dev/null +++ b/cli/tools/coverage/mod.rs @@ -0,0 +1,652 @@ +// Copyright 2018-2022 the Deno authors. All rights reserved. MIT license. + +use crate::colors; +use crate::flags::CoverageFlags; +use crate::flags::Flags; +use crate::fs_util::collect_files; +use crate::proc_state::ProcState; +use crate::source_maps::SourceMapGetter; +use crate::tools::fmt::format_json; + +use deno_ast::MediaType; +use deno_ast::ModuleSpecifier; +use deno_core::anyhow::anyhow; +use deno_core::anyhow::Context; +use deno_core::error::AnyError; +use deno_core::serde_json; +use deno_core::url::Url; +use deno_core::LocalInspectorSession; +use regex::Regex; +use sourcemap::SourceMap; +use std::fs; +use std::fs::File; +use std::io::BufWriter; +use std::io::Write; +use std::path::PathBuf; +use text_lines::TextLines; +use uuid::Uuid; + +mod json_types; +mod merge; +mod range_tree; + +use json_types::*; + +pub struct CoverageCollector { + pub dir: PathBuf, + session: LocalInspectorSession, +} + +impl CoverageCollector { + pub fn new(dir: PathBuf, session: LocalInspectorSession) -> Self { + Self { dir, session } + } + + async fn enable_debugger(&mut self) -> Result<(), AnyError> { + self.session.post_message("Debugger.enable", None).await?; + Ok(()) + } + + async fn enable_profiler(&mut self) -> Result<(), AnyError> { + self.session.post_message("Profiler.enable", None).await?; + Ok(()) + } + + async fn disable_debugger(&mut self) -> Result<(), AnyError> { + self.session.post_message("Debugger.disable", None).await?; + Ok(()) + } + + async fn disable_profiler(&mut self) -> Result<(), AnyError> { + self.session.post_message("Profiler.disable", None).await?; + Ok(()) + } + + async fn start_precise_coverage( + &mut self, + parameters: StartPreciseCoverageParameters, + ) -> Result<StartPreciseCoverageReturnObject, AnyError> { + let parameters_value = serde_json::to_value(parameters)?; + let return_value = self + .session + .post_message("Profiler.startPreciseCoverage", Some(parameters_value)) + .await?; + + let return_object = serde_json::from_value(return_value)?; + + Ok(return_object) + } + + async fn take_precise_coverage( + &mut self, + ) -> Result<TakePreciseCoverageReturnObject, AnyError> { + let return_value = self + .session + .post_message("Profiler.takePreciseCoverage", None) + .await?; + + let return_object = serde_json::from_value(return_value)?; + + Ok(return_object) + } + + pub async fn start_collecting(&mut self) -> Result<(), AnyError> { + self.enable_debugger().await?; + self.enable_profiler().await?; + self + .start_precise_coverage(StartPreciseCoverageParameters { + call_count: true, + detailed: true, + allow_triggered_updates: false, + }) + .await?; + + Ok(()) + } + + pub async fn stop_collecting(&mut self) -> Result<(), AnyError> { + fs::create_dir_all(&self.dir)?; + + let script_coverages = self.take_precise_coverage().await?.result; + for script_coverage in script_coverages { + let filename = format!("{}.json", Uuid::new_v4()); + let filepath = self.dir.join(filename); + + let mut out = BufWriter::new(File::create(filepath)?); + let coverage = serde_json::to_string(&script_coverage)?; + let formated_coverage = + format_json(&coverage, &Default::default()).unwrap_or(coverage); + + out.write_all(formated_coverage.as_bytes())?; + out.flush()?; + } + + self.disable_debugger().await?; + self.disable_profiler().await?; + + Ok(()) + } +} + +struct BranchCoverageItem { + line_index: usize, + block_number: usize, + branch_number: usize, + taken: Option<i64>, + is_hit: bool, +} + +struct FunctionCoverageItem { + name: String, + line_index: usize, + execution_count: i64, +} + +struct CoverageReport { + url: ModuleSpecifier, + named_functions: Vec<FunctionCoverageItem>, + branches: Vec<BranchCoverageItem>, + found_lines: Vec<(usize, i64)>, +} + +fn generate_coverage_report( + script_coverage: &ScriptCoverage, + script_source: &str, + maybe_source_map: &Option<Vec<u8>>, +) -> CoverageReport { + let maybe_source_map = maybe_source_map + .as_ref() + .map(|source_map| SourceMap::from_slice(source_map).unwrap()); + let text_lines = TextLines::new(script_source); + + let comment_spans = deno_ast::lex(script_source, MediaType::JavaScript) + .into_iter() + .filter(|item| { + matches!(item.inner, deno_ast::TokenOrComment::Comment { .. }) + }) + .map(|item| item.span) + .collect::<Vec<_>>(); + + let url = Url::parse(&script_coverage.url).unwrap(); + let mut coverage_report = CoverageReport { + url, + named_functions: Vec::with_capacity( + script_coverage + .functions + .iter() + .filter(|f| !f.function_name.is_empty()) + .count(), + ), + branches: Vec::new(), + found_lines: Vec::new(), + }; + + for function in &script_coverage.functions { + if function.function_name.is_empty() { + continue; + } + + let source_line_index = + text_lines.line_index(function.ranges[0].start_offset); + let line_index = if let Some(source_map) = maybe_source_map.as_ref() { + source_map + .tokens() + .find(|token| token.get_dst_line() as usize == source_line_index) + .map(|token| token.get_src_line() as usize) + .unwrap_or(0) + } else { + source_line_index + }; + + coverage_report.named_functions.push(FunctionCoverageItem { + name: function.function_name.clone(), + line_index, + execution_count: function.ranges[0].count, + }); + } + + for (block_number, function) in script_coverage.functions.iter().enumerate() { + let block_hits = function.ranges[0].count; + for (branch_number, range) in function.ranges[1..].iter().enumerate() { + let source_line_index = text_lines.line_index(range.start_offset); + let line_index = if let Some(source_map) = maybe_source_map.as_ref() { + source_map + .tokens() + .find(|token| token.get_dst_line() as usize == source_line_index) + .map(|token| token.get_src_line() as usize) + .unwrap_or(0) + } else { + source_line_index + }; + + // From https://manpages.debian.org/unstable/lcov/geninfo.1.en.html: + // + // Block number and branch number are gcc internal IDs for the branch. Taken is either '-' + // if the basic block containing the branch was never executed or a number indicating how + // often that branch was taken. + // + // However with the data we get from v8 coverage profiles it seems we can't actually hit + // this as appears it won't consider any nested branches it hasn't seen but its here for + // the sake of accuracy. + let taken = if block_hits > 0 { + Some(range.count) + } else { + None + }; + + coverage_report.branches.push(BranchCoverageItem { + 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(text_lines.lines_count()); + for line_index in 0..text_lines.lines_count() { + let line_start_offset = text_lines.line_start(line_index); + let line_end_offset = text_lines.line_end(line_index); + let ignore = comment_spans.iter().any(|span| { + (span.lo.0 as usize) <= line_start_offset + && (span.hi.0 as usize) >= line_end_offset + }) || script_source[line_start_offset..line_end_offset] + .trim() + .is_empty(); + let mut count = 0; + + if ignore { + count = 1; + } else { + // Count the hits of ranges that include the entire line which will always be at-least one + // as long as the code has been evaluated. + for function in &script_coverage.functions { + for range in &function.ranges { + if range.start_offset <= line_start_offset + && range.end_offset >= line_end_offset + { + count += range.count; + } + } + } + + // We reset the count if any block with a zero count overlaps with the line range. + for function in &script_coverage.functions { + for range in &function.ranges { + if range.count > 0 { + continue; + } + + let overlaps = range.start_offset < line_end_offset + && range.end_offset > line_start_offset; + if overlaps { + count = 0; + } + } + } + } + + line_counts.push(count); + } + + coverage_report.found_lines = + if let Some(source_map) = maybe_source_map.as_ref() { + let mut found_lines = line_counts + .iter() + .enumerate() + .map(|(index, count)| { + // get all the mappings from this destination line to a different src line + let mut results = source_map + .tokens() + .filter(move |token| token.get_dst_line() as usize == index) + .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() + }) + .flatten() + .collect::<Vec<(usize, i64)>>(); + + found_lines.sort_unstable_by_key(|(index, _)| *index); + // combine duplicated lines + for i in (1..found_lines.len()).rev() { + if found_lines[i].0 == found_lines[i - 1].0 { + found_lines[i - 1].1 += found_lines[i].1; + found_lines.remove(i); + } + } + found_lines + } else { + line_counts + .into_iter() + .enumerate() + .map(|(index, count)| (index, count)) + .collect::<Vec<(usize, i64)>>() + }; + + coverage_report +} + +enum CoverageReporterKind { + Pretty, + Lcov, +} + +fn create_reporter( + kind: CoverageReporterKind, +) -> Box<dyn CoverageReporter + Send> { + match kind { + CoverageReporterKind::Lcov => Box::new(LcovCoverageReporter::new()), + CoverageReporterKind::Pretty => Box::new(PrettyCoverageReporter::new()), + } +} + +trait CoverageReporter { + fn report(&mut self, coverage_report: &CoverageReport, file_text: &str); + + fn done(&mut self); +} + +struct LcovCoverageReporter {} + +impl LcovCoverageReporter { + pub fn new() -> LcovCoverageReporter { + LcovCoverageReporter {} + } +} + +impl CoverageReporter for LcovCoverageReporter { + fn report(&mut self, coverage_report: &CoverageReport, _file_text: &str) { + let file_path = coverage_report + .url + .to_file_path() + .ok() + .map(|p| p.to_str().map(|p| p.to_string())) + .flatten() + .unwrap_or_else(|| coverage_report.url.to_string()); + println!("SF:{}", file_path); + + for function in &coverage_report.named_functions { + println!("FN:{},{}", function.line_index + 1, function.name); + } + + for function in &coverage_report.named_functions { + println!("FNDA:{},{}", function.execution_count, function.name); + } + + let functions_found = coverage_report.named_functions.len(); + println!("FNF:{}", functions_found); + let functions_hit = coverage_report + .named_functions + .iter() + .filter(|f| f.execution_count > 0) + .count(); + println!("FNH:{}", functions_hit); + + for branch in &coverage_report.branches { + let taken = if let Some(taken) = &branch.taken { + taken.to_string() + } else { + "-".to_string() + }; + + println!( + "BRDA:{},{},{},{}", + branch.line_index + 1, + branch.block_number, + branch.branch_number, + taken + ); + } + + let branches_found = coverage_report.branches.len(); + println!("BRF:{}", branches_found); + let branches_hit = + coverage_report.branches.iter().filter(|b| b.is_hit).count(); + println!("BRH:{}", branches_hit); + + for (index, count) in &coverage_report.found_lines { + println!("DA:{},{}", index + 1, count); + } + + let lines_hit = coverage_report + .found_lines + .iter() + .filter(|(_, count)| *count != 0) + .count(); + println!("LH:{}", lines_hit); + + let lines_found = coverage_report.found_lines.len(); + println!("LF:{}", lines_found); + + println!("end_of_record"); + } + + fn done(&mut self) {} +} + +struct PrettyCoverageReporter {} + +impl PrettyCoverageReporter { + pub fn new() -> PrettyCoverageReporter { + PrettyCoverageReporter {} + } +} + +impl CoverageReporter for PrettyCoverageReporter { + fn report(&mut self, coverage_report: &CoverageReport, file_text: &str) { + let lines = file_text.split('\n').collect::<Vec<_>>(); + 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 { + println!("{}", colors::green(&line_coverage)); + } else if line_ratio >= 0.75 { + println!("{}", colors::yellow(&line_coverage)); + } else { + println!("{}", colors::red(&line_coverage)); + } + + let mut last_line = None; + for line_index in missed_lines { + const WIDTH: usize = 4; + const SEPERATOR: &str = "|"; + + // Put a horizontal separator between disjoint runs of lines + if let Some(last_line) = last_line { + if last_line + 1 != line_index { + let dash = colors::gray("-".repeat(WIDTH + 1)); + println!("{}{}{}", dash, colors::gray(SEPERATOR), dash); + } + } + + println!( + "{:width$} {} {}", + line_index + 1, + colors::gray(SEPERATOR), + colors::red(&lines[line_index]), + width = WIDTH + ); + + last_line = Some(line_index); + } + } + + fn done(&mut self) {} +} + +fn collect_coverages( + files: Vec<PathBuf>, + ignore: Vec<PathBuf>, +) -> Result<Vec<ScriptCoverage>, AnyError> { + let mut coverages: Vec<ScriptCoverage> = Vec::new(); + let file_paths = collect_files(&files, &ignore, |file_path| { + file_path.extension().map_or(false, |ext| ext == "json") + })?; + + for file_path in file_paths { + let json = fs::read_to_string(file_path.as_path())?; + let new_coverage: ScriptCoverage = serde_json::from_str(&json)?; + coverages.push(new_coverage); + } + + coverages.sort_by_key(|k| k.url.clone()); + + Ok(coverages) +} + +fn filter_coverages( + coverages: Vec<ScriptCoverage>, + include: Vec<String>, + exclude: Vec<String>, +) -> Vec<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(); + + coverages + .into_iter() + .filter(|e| { + let is_internal = e.url.starts_with("deno:") + || e.url.ends_with("__anonymous__") + || e.url.ends_with("$deno$test.js"); + + 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<ScriptCoverage>>() +} + +pub async fn cover_files( + flags: Flags, + coverage_flags: CoverageFlags, +) -> Result<(), AnyError> { + let ps = ProcState::build(flags).await?; + + let script_coverages = + collect_coverages(coverage_flags.files, coverage_flags.ignore)?; + let script_coverages = filter_coverages( + script_coverages, + coverage_flags.include, + coverage_flags.exclude, + ); + + let proc_coverages: Vec<_> = script_coverages + .into_iter() + .map(|cov| ProcessCoverage { result: vec![cov] }) + .collect(); + + let script_coverages = if let Some(c) = merge::merge_processes(proc_coverages) + { + c.result + } else { + vec![] + }; + + let reporter_kind = if coverage_flags.lcov { + CoverageReporterKind::Lcov + } else { + CoverageReporterKind::Pretty + }; + + let mut reporter = create_reporter(reporter_kind); + + for script_coverage in script_coverages { + let module_specifier = + deno_core::resolve_url_or_path(&script_coverage.url)?; + + let maybe_file = if module_specifier.scheme() == "file" { + ps.file_fetcher.get_source(&module_specifier) + } else { + ps.file_fetcher + .fetch_cached(&module_specifier, 10) + .with_context(|| { + format!("Failed to fetch \"{}\" from cache.", module_specifier) + })? + }; + let file = maybe_file.ok_or_else(|| { + anyhow!("Failed to fetch \"{}\" from cache. + Before generating coverage report, run `deno test --coverage` to ensure consistent state.", + module_specifier + ) + })?; + + // Check if file was transpiled + let transpiled_source = match file.media_type { + MediaType::JavaScript + | MediaType::Unknown + | MediaType::Cjs + | MediaType::Mjs + | MediaType::Json => file.source.as_ref().clone(), + MediaType::Dts | MediaType::Dmts | MediaType::Dcts => "".to_string(), + MediaType::TypeScript + | MediaType::Jsx + | MediaType::Mts + | MediaType::Cts + | MediaType::Tsx => { + let emit_path = ps + .dir + .gen_cache + .get_cache_filename_with_extension(&file.specifier, "js") + .unwrap_or_else(|| { + unreachable!("Unable to get cache filename: {}", &file.specifier) + }); + match ps.dir.gen_cache.get(&emit_path) { + Ok(b) => String::from_utf8(b).unwrap(), + Err(_) => { + return Err(anyhow!( + "Missing transpiled source code for: \"{}\". + Before generating coverage report, run `deno test --coverage` to ensure consistent state.", + file.specifier, + )) + } + } + } + MediaType::Wasm | MediaType::TsBuildInfo | MediaType::SourceMap => { + unreachable!() + } + }; + + let original_source = &file.source; + let maybe_source_map = ps.get_source_map(&script_coverage.url); + + let coverage_report = generate_coverage_report( + &script_coverage, + &transpiled_source, + &maybe_source_map, + ); + + reporter.report(&coverage_report, original_source); + } + + reporter.done(); + + Ok(()) +} |