// Copyright 2018-2021 the Deno authors. All rights reserved. MIT license. use crate::ast; use crate::ast::TokenOrComment; use crate::colors; use crate::flags::Flags; use crate::fs_util::collect_files; use crate::media_type::MediaType; use crate::module_graph::TypeLib; use crate::program_state::ProgramState; use crate::source_maps::SourceMapGetter; use deno_core::error::AnyError; use deno_core::serde_json; use deno_core::serde_json::json; use deno_core::url::Url; use deno_runtime::inspector::InspectorSession; use deno_runtime::permissions::Permissions; use regex::Regex; use serde::Deserialize; use serde::Serialize; use sourcemap::SourceMap; use std::fs; use std::path::PathBuf; use swc_common::Span; use uuid::Uuid; pub struct CoverageCollector { pub dir: PathBuf, session: Box, } impl CoverageCollector { pub fn new(dir: PathBuf, session: Box) -> Self { Self { dir, session } } pub async fn start_collecting(&mut self) -> Result<(), AnyError> { self.session.post_message("Debugger.enable", None).await?; self.session.post_message("Profiler.enable", None).await?; self .session .post_message( "Profiler.startPreciseCoverage", Some(json!({"callCount": true, "detailed": true})), ) .await?; Ok(()) } pub async fn stop_collecting(&mut self) -> Result<(), AnyError> { let result = self .session .post_message("Profiler.takePreciseCoverage", None) .await?; let take_coverage_result: TakePreciseCoverageResult = serde_json::from_value(result)?; fs::create_dir_all(&self.dir)?; let script_coverages = take_coverage_result.result; for script_coverage in script_coverages { let filename = format!("{}.json", Uuid::new_v4()); let json = serde_json::to_string(&script_coverage)?; fs::write(self.dir.join(filename), &json)?; } self.session.post_message("Profiler.disable", None).await?; self.session.post_message("Debugger.disable", None).await?; Ok(()) } } // TODO(caspervonb) all of these structs can and should be made private, possibly moved to // inspector::protocol. #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct CoverageRange { pub start_offset: usize, pub end_offset: usize, pub count: usize, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct FunctionCoverage { pub function_name: String, pub ranges: Vec, pub is_block_coverage: bool, } #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ScriptCoverage { pub script_id: String, pub url: String, pub functions: Vec, } #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct TakePreciseCoverageResult { result: Vec, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct GetScriptSourceResult { pub script_source: String, pub bytecode: Option, } pub enum CoverageReporterKind { Pretty, Lcov, } fn create_reporter( kind: CoverageReporterKind, ) -> Box { match kind { CoverageReporterKind::Lcov => Box::new(LcovCoverageReporter::new()), CoverageReporterKind::Pretty => Box::new(PrettyCoverageReporter::new()), } } pub trait CoverageReporter { fn visit_coverage( &mut self, script_coverage: &ScriptCoverage, script_source: &str, maybe_source_map: Option>, maybe_original_source: Option, ); fn done(&mut self); } pub struct LcovCoverageReporter {} impl LcovCoverageReporter { pub fn new() -> LcovCoverageReporter { LcovCoverageReporter {} } } impl CoverageReporter for LcovCoverageReporter { fn visit_coverage( &mut self, script_coverage: &ScriptCoverage, script_source: &str, maybe_source_map: Option>, _maybe_original_source: Option, ) { // TODO(caspervonb) cleanup and reduce duplication between reporters, pre-compute line coverage // elsewhere. let maybe_source_map = if let Some(source_map) = maybe_source_map { Some(SourceMap::from_slice(&source_map).unwrap()) } else { None }; let url = Url::parse(&script_coverage.url).unwrap(); let file_path = url.to_file_path().unwrap(); println!("SF:{}", file_path.to_str().unwrap()); let mut functions_found = 0; for function in &script_coverage.functions { if function.function_name.is_empty() { continue; } let source_line = script_source[0..function.ranges[0].start_offset] .split('\n') .count(); 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) .map(|token| token.get_src_line() as usize) .unwrap_or(0) } else { source_line }; let function_name = &function.function_name; println!("FN:{},{}", line_index + 1, function_name); functions_found += 1; } let mut functions_hit = 0; for function in &script_coverage.functions { if function.function_name.is_empty() { continue; } let execution_count = function.ranges[0].count; let function_name = &function.function_name; println!("FNDA:{},{}", execution_count, function_name); if execution_count != 0 { functions_hit += 1; } } println!("FNF:{}", functions_found); println!("FNH:{}", functions_hit); let mut branches_found = 0; let mut branches_hit = 0; 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 = script_source[0..range.start_offset].split('\n').count(); 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) .map(|token| token.get_src_line() as usize) .unwrap_or(0) } else { source_line }; // 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 { range.count.to_string() } else { "-".to_string() }; println!( "BRDA:{},{},{},{}", line_index + 1, block_number, branch_number, taken ); branches_found += 1; if range.count > 0 { branches_hit += 1; } } } println!("BRF:{}", branches_found); println!("BRH:{}", branches_hit); let lines = script_source.split('\n').collect::>(); let line_offsets = { let mut offsets: Vec<(usize, usize)> = Vec::new(); let mut index = 0; for line in &lines { offsets.push((index, index + line.len() + 1)); index += line.len() + 1; } offsets }; let line_counts = line_offsets .iter() .map(|(line_start_offset, line_end_offset)| { let mut count = 0; // 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; } } } // Reset the count if any block intersects with the current line has a count of // zero. // // We check for intersection instead of inclusion here because a block may be anywhere // inside a line. for function in &script_coverage.functions { for range in &function.ranges { if range.count > 0 { continue; } if (range.start_offset < *line_start_offset && range.end_offset > *line_start_offset) || (range.start_offset < *line_end_offset && range.end_offset > *line_end_offset) { count = 0; } } } count }) .collect::>(); let found_lines = if let Some(source_map) = maybe_source_map.as_ref() { let mut found_lines = line_counts .iter() .enumerate() .map(|(index, count)| { source_map .tokens() .filter(move |token| token.get_dst_line() as usize == index) .map(move |token| (token.get_src_line() as usize, *count)) }) .flatten() .collect::>(); found_lines.sort_unstable_by_key(|(index, _)| *index); found_lines.dedup_by_key(|(index, _)| *index); found_lines } else { line_counts .iter() .enumerate() .map(|(index, count)| (index, *count)) .collect::>() }; for (index, count) in &found_lines { println!("DA:{},{}", index + 1, count); } let lines_hit = found_lines.iter().filter(|(_, count)| *count != 0).count(); println!("LH:{}", lines_hit); let lines_found = found_lines.len(); println!("LF:{}", lines_found); println!("end_of_record"); } fn done(&mut self) {} } pub struct PrettyCoverageReporter {} impl PrettyCoverageReporter { pub fn new() -> PrettyCoverageReporter { PrettyCoverageReporter {} } } impl CoverageReporter for PrettyCoverageReporter { fn visit_coverage( &mut self, script_coverage: &ScriptCoverage, script_source: &str, maybe_source_map: Option>, maybe_original_source: Option, ) { let maybe_source_map = if let Some(source_map) = maybe_source_map { Some(SourceMap::from_slice(&source_map).unwrap()) } else { None }; let mut ignored_spans: Vec = Vec::new(); for item in ast::lex("", script_source, &MediaType::JavaScript) { if let TokenOrComment::Token(_) = item.inner { continue; } ignored_spans.push(item.span); } let lines = script_source.split('\n').collect::>(); let line_offsets = { let mut offsets: Vec<(usize, usize)> = Vec::new(); let mut index = 0; for line in &lines { offsets.push((index, index + line.len() + 1)); index += line.len() + 1; } offsets }; // 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 line_counts = line_offsets .iter() .enumerate() .map(|(index, (line_start_offset, line_end_offset))| { let ignore = ignored_spans.iter().any(|span| { (span.lo.0 as usize) <= *line_start_offset && (span.hi.0 as usize) >= *line_end_offset }); if ignore { return (index, 1); } let mut count = 0; // 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; } } } // Reset the count if any block intersects with the current line has a count of // zero. // // We check for intersection instead of inclusion here because a block may be anywhere // inside a line. for function in &script_coverage.functions { for range in &function.ranges { if range.count > 0 { continue; } if (range.start_offset < *line_start_offset && range.end_offset > *line_start_offset) || (range.start_offset < *line_end_offset && range.end_offset > *line_end_offset) { count = 0; } } } (index, count) }) .collect::>(); let lines = if let Some(original_source) = maybe_original_source.as_ref() { original_source.split('\n').collect::>() } else { lines }; let line_counts = if let Some(source_map) = maybe_source_map.as_ref() { let mut line_counts = line_counts .iter() .map(|(index, count)| { source_map .tokens() .filter(move |token| token.get_dst_line() as usize == *index) .map(move |token| (token.get_src_line() as usize, *count)) }) .flatten() .collect::>(); line_counts.sort_unstable_by_key(|(index, _)| *index); line_counts.dedup_by_key(|(index, _)| *index); line_counts } else { line_counts }; print!("cover {} ... ", script_coverage.url); let hit_lines = line_counts .iter() .filter(|(_, count)| *count != 0) .map(|(index, _)| *index); let missed_lines = line_counts .iter() .filter(|(_, count)| *count == 0) .map(|(index, _)| *index); let lines_found = line_counts.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, ignore: Vec, ) -> Result, AnyError> { let mut coverages: Vec = 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)?; let existing_coverage = coverages.iter_mut().find(|x| x.url == new_coverage.url); if let Some(existing_coverage) = existing_coverage { for new_function in new_coverage.functions { let existing_function = existing_coverage .functions .iter_mut() .find(|x| x.function_name == new_function.function_name); if let Some(existing_function) = existing_function { for new_range in new_function.ranges { let existing_range = existing_function.ranges.iter_mut().find(|x| { x.start_offset == new_range.start_offset && x.end_offset == new_range.end_offset }); if let Some(existing_range) = existing_range { existing_range.count += new_range.count; } else { existing_function.ranges.push(new_range); } } } else { existing_coverage.functions.push(new_function); } } } else { coverages.push(new_coverage); } } coverages.sort_by_key(|k| k.url.clone()); Ok(coverages) } fn filter_coverages( coverages: Vec, include: Vec, exclude: Vec, ) -> Vec { let include: Vec = include.iter().map(|e| Regex::new(e).unwrap()).collect(); let exclude: Vec = 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.ts"); 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::>() } pub async fn cover_files( flags: Flags, files: Vec, ignore: Vec, include: Vec, exclude: Vec, lcov: bool, ) -> Result<(), AnyError> { let program_state = ProgramState::build(flags).await?; let script_coverages = collect_coverages(files, ignore)?; let script_coverages = filter_coverages(script_coverages, include, exclude); let reporter_kind = if 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)?; program_state .prepare_module_load( module_specifier.clone(), TypeLib::UnstableDenoWindow, Permissions::allow_all(), false, program_state.maybe_import_map.clone(), ) .await?; let module_source = program_state.load(module_specifier.clone(), None)?; let script_source = &module_source.code; let maybe_source_map = program_state.get_source_map(&script_coverage.url); let maybe_cached_source = program_state .file_fetcher .get_source(&module_specifier) .map(|f| f.source); reporter.visit_coverage( &script_coverage, &script_source, maybe_source_map, maybe_cached_source, ); } reporter.done(); Ok(()) }