feat(test): create coverage reports when --coverage specified in deno test (#28260)

This PR updates the behavior of `deno test --coverage` option. Now if
`--coverage` option is specified, `deno test` command automatically
shows summary report in the terminal, and generates the lcov report in
`$coverage_dir/lcov.info` and html report in `$coverage_dir/html/`

This change also adds `--coverage-raw-data-only` flag, which prevents
the above reports generated, instead only generates the raw json
coverage data (which is the same as current behavior)
This commit is contained in:
Yoshiya Hinosawa 2025-04-18 18:56:14 +09:00 committed by GitHub
parent 0bb16651c0
commit 83f15ece09
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 203 additions and 36 deletions

View file

@ -409,6 +409,7 @@ pub struct TestFlags {
pub doc: bool, pub doc: bool,
pub no_run: bool, pub no_run: bool,
pub coverage_dir: Option<String>, pub coverage_dir: Option<String>,
pub coverage_raw_data_only: bool,
pub clean: bool, pub clean: bool,
pub fail_fast: Option<NonZeroUsize>, pub fail_fast: Option<NonZeroUsize>,
pub files: FileFlags, pub files: FileFlags,
@ -3241,6 +3242,13 @@ or <c>**/__tests__/**</>:
<p(245)>This option can also be set via the DENO_COVERAGE_DIR environment variable.")) <p(245)>This option can also be set via the DENO_COVERAGE_DIR environment variable."))
.help_heading(TEST_HEADING), .help_heading(TEST_HEADING),
) )
.arg(
Arg::new("coverage-raw-data-only")
.long("coverage-raw-data-only")
.help("Only collect raw coverage data, without generating a report")
.action(ArgAction::SetTrue)
.help_heading(TEST_HEADING),
)
.arg( .arg(
Arg::new("clean") Arg::new("clean")
.long("clean") .long("clean")
@ -5536,6 +5544,7 @@ fn test_parse(
no_run, no_run,
doc, doc,
coverage_dir: matches.remove_one::<String>("coverage"), coverage_dir: matches.remove_one::<String>("coverage"),
coverage_raw_data_only: matches.get_flag("coverage-raw-data-only"),
clean, clean,
fail_fast, fail_fast,
files: FileFlags { include, ignore }, files: FileFlags { include, ignore },
@ -9478,6 +9487,7 @@ mod tests {
concurrent_jobs: None, concurrent_jobs: None,
trace_leaks: true, trace_leaks: true,
coverage_dir: Some("cov".to_string()), coverage_dir: Some("cov".to_string()),
coverage_raw_data_only: false,
clean: true, clean: true,
watch: Default::default(), watch: Default::default(),
reporter: Default::default(), reporter: Default::default(),
@ -9562,6 +9572,7 @@ mod tests {
concurrent_jobs: None, concurrent_jobs: None,
trace_leaks: false, trace_leaks: false,
coverage_dir: None, coverage_dir: None,
coverage_raw_data_only: false,
clean: false, clean: false,
watch: Default::default(), watch: Default::default(),
reporter: Default::default(), reporter: Default::default(),
@ -9605,6 +9616,7 @@ mod tests {
concurrent_jobs: None, concurrent_jobs: None,
trace_leaks: false, trace_leaks: false,
coverage_dir: None, coverage_dir: None,
coverage_raw_data_only: false,
clean: false, clean: false,
watch: Default::default(), watch: Default::default(),
reporter: Default::default(), reporter: Default::default(),
@ -9742,6 +9754,7 @@ mod tests {
concurrent_jobs: None, concurrent_jobs: None,
trace_leaks: false, trace_leaks: false,
coverage_dir: None, coverage_dir: None,
coverage_raw_data_only: false,
clean: false, clean: false,
watch: Default::default(), watch: Default::default(),
reporter: Default::default(), reporter: Default::default(),
@ -9778,6 +9791,7 @@ mod tests {
concurrent_jobs: None, concurrent_jobs: None,
trace_leaks: false, trace_leaks: false,
coverage_dir: None, coverage_dir: None,
coverage_raw_data_only: false,
clean: false, clean: false,
watch: Some(Default::default()), watch: Some(Default::default()),
reporter: Default::default(), reporter: Default::default(),
@ -9813,6 +9827,7 @@ mod tests {
concurrent_jobs: None, concurrent_jobs: None,
trace_leaks: false, trace_leaks: false,
coverage_dir: None, coverage_dir: None,
coverage_raw_data_only: false,
clean: false, clean: false,
watch: Some(Default::default()), watch: Some(Default::default()),
reporter: Default::default(), reporter: Default::default(),
@ -9850,6 +9865,7 @@ mod tests {
concurrent_jobs: None, concurrent_jobs: None,
trace_leaks: false, trace_leaks: false,
coverage_dir: None, coverage_dir: None,
coverage_raw_data_only: false,
clean: false, clean: false,
watch: Some(WatchFlagsWithPaths { watch: Some(WatchFlagsWithPaths {
hmr: false, hmr: false,

View file

@ -144,8 +144,17 @@ async fn run_subcommand(flags: Arc<Flags>) -> Result<i32, AnyError> {
tools::compile::compile(flags, compile_flags).await tools::compile::compile(flags, compile_flags).await
} }
}), }),
DenoSubcommand::Coverage(coverage_flags) => spawn_subcommand(async { DenoSubcommand::Coverage(coverage_flags) => spawn_subcommand(async move {
tools::coverage::cover_files(flags, coverage_flags) let reporter = crate::tools::coverage::reporter::create(coverage_flags.r#type.clone());
tools::coverage::cover_files(
flags,
coverage_flags.files.include,
coverage_flags.files.ignore,
coverage_flags.include,
coverage_flags.exclude,
coverage_flags.output,
&[&*reporter]
)
}), }),
DenoSubcommand::Fmt(fmt_flags) => { DenoSubcommand::Fmt(fmt_flags) => {
spawn_subcommand( spawn_subcommand(
@ -285,7 +294,8 @@ async fn run_subcommand(flags: Arc<Flags>) -> Result<i32, AnyError> {
DenoSubcommand::Test(test_flags) => { DenoSubcommand::Test(test_flags) => {
spawn_subcommand(async { spawn_subcommand(async {
if let Some(ref coverage_dir) = test_flags.coverage_dir { if let Some(ref coverage_dir) = test_flags.coverage_dir {
if test_flags.clean { if !test_flags.coverage_raw_data_only || test_flags.clean {
// Keeps coverage_dir contents only when --coverage-raw-data-only is set and --clean is not set
let _ = std::fs::remove_dir_all(coverage_dir); let _ = std::fs::remove_dir_all(coverage_dir);
} }
std::fs::create_dir_all(coverage_dir) std::fs::create_dir_all(coverage_dir)

View file

@ -27,6 +27,7 @@ use deno_error::JsErrorBox;
use deno_resolver::npm::DenoInNpmPackageChecker; use deno_resolver::npm::DenoInNpmPackageChecker;
use node_resolver::InNpmPackageChecker; use node_resolver::InNpmPackageChecker;
use regex::Regex; use regex::Regex;
use reporter::CoverageReporter;
use text_lines::TextLines; use text_lines::TextLines;
use uuid::Uuid; use uuid::Uuid;
@ -35,7 +36,6 @@ use self::ignore_directives::lex_comments;
use self::ignore_directives::parse_next_ignore_directives; use self::ignore_directives::parse_next_ignore_directives;
use self::ignore_directives::parse_range_ignore_directives; use self::ignore_directives::parse_range_ignore_directives;
use crate::args::CliOptions; use crate::args::CliOptions;
use crate::args::CoverageFlags;
use crate::args::FileFlags; use crate::args::FileFlags;
use crate::args::Flags; use crate::args::Flags;
use crate::cdp; use crate::cdp;
@ -49,7 +49,7 @@ use crate::util::text_encoding::source_map_from_code;
mod ignore_directives; mod ignore_directives;
mod merge; mod merge;
mod range_tree; mod range_tree;
mod reporter; pub mod reporter;
mod util; mod util;
use merge::ProcessCoverage; use merge::ProcessCoverage;
@ -596,9 +596,14 @@ fn filter_coverages(
pub fn cover_files( pub fn cover_files(
flags: Arc<Flags>, flags: Arc<Flags>,
coverage_flags: CoverageFlags, files_include: Vec<String>,
files_ignore: Vec<String>,
include: Vec<String>,
exclude: Vec<String>,
output: Option<String>,
reporters: &[&dyn CoverageReporter],
) -> Result<(), AnyError> { ) -> Result<(), AnyError> {
if coverage_flags.files.include.is_empty() { if files_include.is_empty() {
return Err(anyhow!("No matching coverage profiles found")); return Err(anyhow!("No matching coverage profiles found"));
} }
@ -609,26 +614,21 @@ pub fn cover_files(
let emitter = factory.emitter()?; let emitter = factory.emitter()?;
let cjs_tracker = factory.cjs_tracker()?; let cjs_tracker = factory.cjs_tracker()?;
assert!(!coverage_flags.files.include.is_empty());
// Use the first include path as the default output path. // Use the first include path as the default output path.
let coverage_root = cli_options let coverage_root = cli_options.initial_cwd().join(&files_include[0]);
.initial_cwd()
.join(&coverage_flags.files.include[0]);
let script_coverages = collect_coverages( let script_coverages = collect_coverages(
cli_options, cli_options,
coverage_flags.files, FileFlags {
include: files_include,
ignore: files_ignore,
},
cli_options.initial_cwd(), cli_options.initial_cwd(),
)?; )?;
if script_coverages.is_empty() { if script_coverages.is_empty() {
return Err(anyhow!("No coverage files found")); return Err(anyhow!("No coverage files found"));
} }
let script_coverages = filter_coverages( let script_coverages =
script_coverages, filter_coverages(script_coverages, include, exclude, in_npm_pkg_checker);
coverage_flags.include,
coverage_flags.exclude,
in_npm_pkg_checker,
);
if script_coverages.is_empty() { if script_coverages.is_empty() {
return Err(anyhow!("No covered files included in the report")); return Err(anyhow!("No covered files included in the report"));
} }
@ -645,9 +645,7 @@ pub fn cover_files(
vec![] vec![]
}; };
let mut reporter = reporter::create(coverage_flags.r#type); let out_mode = match output {
let out_mode = match coverage_flags.output {
Some(ref path) => match File::create(path) { Some(ref path) => match File::create(path) {
Ok(_) => Some(PathBuf::from(path)), Ok(_) => Some(PathBuf::from(path)),
Err(e) => { Err(e) => {
@ -742,7 +740,9 @@ pub fn cover_files(
} }
} }
for reporter in reporters {
reporter.done(&coverage_root, &file_reports); reporter.done(&coverage_root, &file_reports);
}
Ok(()) Ok(())
} }

View file

@ -42,7 +42,7 @@ pub fn create(kind: CoverageType) -> Box<dyn CoverageReporter + Send> {
pub trait CoverageReporter { pub trait CoverageReporter {
fn done( fn done(
&mut self, &self,
coverage_root: &Path, coverage_root: &Path,
file_reports: &[(CoverageReport, String)], file_reports: &[(CoverageReport, String)],
); );
@ -105,7 +105,7 @@ pub trait CoverageReporter {
} }
} }
struct SummaryCoverageReporter {} pub struct SummaryCoverageReporter {}
#[allow(clippy::print_stdout)] #[allow(clippy::print_stdout)]
impl SummaryCoverageReporter { impl SummaryCoverageReporter {
@ -172,7 +172,7 @@ impl SummaryCoverageReporter {
#[allow(clippy::print_stdout)] #[allow(clippy::print_stdout)]
impl CoverageReporter for SummaryCoverageReporter { impl CoverageReporter for SummaryCoverageReporter {
fn done( fn done(
&mut self, &self,
_coverage_root: &Path, _coverage_root: &Path,
file_reports: &[(CoverageReport, String)], file_reports: &[(CoverageReport, String)],
) { ) {
@ -206,17 +206,30 @@ impl CoverageReporter for SummaryCoverageReporter {
} }
} }
struct LcovCoverageReporter {} pub struct LcovCoverageReporter {}
impl CoverageReporter for LcovCoverageReporter { impl CoverageReporter for LcovCoverageReporter {
fn done( fn done(
&mut self, &self,
_coverage_root: &Path, _coverage_root: &Path,
file_reports: &[(CoverageReport, String)], file_reports: &[(CoverageReport, String)],
) { ) {
file_reports.iter().for_each(|(report, file_text)| { file_reports.iter().for_each(|(report, file_text)| {
self.report(report, file_text).unwrap(); self.report(report, file_text).unwrap();
}); });
if let Some((report, _)) = file_reports.first() {
if let Some(ref output) = report.output {
if let Ok(path) = output.canonicalize() {
let url = Url::from_file_path(path).unwrap();
log::info!("Lcov coverage report has been generated at {}", url);
} else {
log::error!(
"Failed to resolve the output path of Lcov report: {}",
output.display()
);
}
}
}
} }
} }
@ -226,7 +239,7 @@ impl LcovCoverageReporter {
} }
fn report( fn report(
&mut self, &self,
coverage_report: &CoverageReport, coverage_report: &CoverageReport,
_file_text: &str, _file_text: &str,
) -> Result<(), AnyError> { ) -> Result<(), AnyError> {
@ -320,7 +333,7 @@ struct DetailedCoverageReporter {}
impl CoverageReporter for DetailedCoverageReporter { impl CoverageReporter for DetailedCoverageReporter {
fn done( fn done(
&mut self, &self,
_coverage_root: &Path, _coverage_root: &Path,
file_reports: &[(CoverageReport, String)], file_reports: &[(CoverageReport, String)],
) { ) {
@ -337,7 +350,7 @@ impl DetailedCoverageReporter {
} }
fn report( fn report(
&mut self, &self,
coverage_report: &CoverageReport, coverage_report: &CoverageReport,
file_text: &str, file_text: &str,
) -> Result<(), AnyError> { ) -> Result<(), AnyError> {
@ -398,11 +411,11 @@ impl DetailedCoverageReporter {
} }
} }
struct HtmlCoverageReporter {} pub struct HtmlCoverageReporter {}
impl CoverageReporter for HtmlCoverageReporter { impl CoverageReporter for HtmlCoverageReporter {
fn done( fn done(
&mut self, &self,
coverage_root: &Path, coverage_root: &Path,
file_reports: &[(CoverageReport, String)], file_reports: &[(CoverageReport, String)],
) { ) {

View file

@ -12,6 +12,7 @@ use std::future::poll_fn;
use std::io::Write; use std::io::Write;
use std::num::NonZeroUsize; use std::num::NonZeroUsize;
use std::path::Path; use std::path::Path;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicUsize; use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
@ -109,6 +110,8 @@ use reporters::PrettyTestReporter;
use reporters::TapTestReporter; use reporters::TapTestReporter;
use reporters::TestReporter; use reporters::TestReporter;
use crate::tools::coverage::cover_files;
use crate::tools::coverage::reporter;
use crate::tools::test::channel::ChannelClosedError; use crate::tools::test::channel::ChannelClosedError;
/// How many times we're allowed to spin the event loop before considering something a leak. /// How many times we're allowed to spin the event loop before considering something a leak.
@ -1562,7 +1565,7 @@ pub async fn run_tests(
flags: Arc<Flags>, flags: Arc<Flags>,
test_flags: TestFlags, test_flags: TestFlags,
) -> Result<(), AnyError> { ) -> Result<(), AnyError> {
let factory = CliFactory::from_flags(flags); let factory = CliFactory::from_flags(flags.clone());
let cli_options = factory.cli_options()?; let cli_options = factory.cli_options()?;
let workspace_test_options = let workspace_test_options =
cli_options.resolve_workspace_test_options(&test_flags); cli_options.resolve_workspace_test_options(&test_flags);
@ -1650,6 +1653,34 @@ pub async fn run_tests(
) )
.await?; .await?;
if test_flags.coverage_raw_data_only {
return Ok(());
}
if let Some(ref coverage) = test_flags.coverage_dir {
let reporters: [&dyn reporter::CoverageReporter; 3] = [
&reporter::SummaryCoverageReporter::new(),
&reporter::LcovCoverageReporter::new(),
&reporter::HtmlCoverageReporter::new(),
];
if let Err(err) = cover_files(
flags,
vec![coverage.clone()],
vec![],
vec![],
vec![],
Some(
PathBuf::from(coverage)
.join("lcov.info")
.to_string_lossy()
.to_string(),
),
&reporters,
) {
log::info!("Error generating coverage report: {}", err);
}
}
Ok(()) Ok(())
} }

View file

@ -665,7 +665,7 @@ fn test_collect_summary_with_no_matches() {
let temp_dir: &TempDir = context.temp_dir(); let temp_dir: &TempDir = context.temp_dir();
let temp_dir_path: PathRef = PathRef::new(temp_dir.path().join("cov")); let temp_dir_path: PathRef = PathRef::new(temp_dir.path().join("cov"));
let empty_test_dir: PathRef = temp_dir_path.join("empty_dir"); let empty_test_dir: PathRef = temp_dir.path().join("empty_dir");
empty_test_dir.create_dir_all(); empty_test_dir.create_dir_all();
let output: util::TestCommandOutput = context let output: util::TestCommandOutput = context

View file

@ -0,0 +1,10 @@
{
"tempDir": true,
"steps": [
{
"args": "test --doc --coverage --coverage-raw-data-only",
"output": "main.out",
"exitCode": 0
}
]
}

View file

@ -0,0 +1,6 @@
Check [WILDCARD]/test.ts
running 1 test from ./test.ts
add() ... ok ([WILDCARD])
ok | 1 passed | 0 failed ([WILDCARD])

View file

@ -0,0 +1,3 @@
export function add(a: number, b: number): number {
return a + b;
}

View file

@ -0,0 +1,7 @@
import { add } from "./source.ts";
Deno.test("add()", () => {
if (add(1, 2) !== 3) {
throw new Error("test failed");
}
});

View file

@ -0,0 +1,20 @@
{
"tempDir": true,
"steps": [
{
"args": "test --coverage --doc",
"output": "main.out",
"exitCode": 0
},
{
"args": "run -A cat.ts coverage/lcov.info",
"output": "lcov_info.out",
"exitCode": 0
},
{
"args": "run -A cat.ts coverage/html/index.html",
"output": "html_index_html.out",
"exitCode": 0
}
]
}

View file

@ -0,0 +1 @@
console.log(await Deno.readTextFile(Deno.args[0]));

View file

@ -0,0 +1,2 @@
<!doctype html>
<html>[WILDCARD]</html>

View file

@ -0,0 +1,4 @@
SF:[WILDCARD]source.ts
[WILDCARD]
end_of_record

View file

@ -0,0 +1,18 @@
Check [WILDCARD]/test.ts
Check [WILDCARD]/source.ts$[WILDCARD].ts
running 1 test from ./test.ts
add() ... ok ([WILDCARD])
running 1 test from ./source.ts$[WILDCARD].ts
file:///[WILDCARD]/source.ts$[WILDCARD].ts ... ok ([WILDCARD])
ok | 2 passed | 0 failed ([WILDCARD])
--------------------------------
File | Branch % | Line % |
--------------------------------
source.ts | 100.0 | 100.0 |
--------------------------------
All files | 100.0 | 100.0 |
--------------------------------
Lcov coverage report has been generated at file://[WILDCARD]/coverage/lcov.info
HTML coverage report has been generated at file://[WILDCARD]/coverage/html/index.html

View file

@ -0,0 +1,9 @@
/**
* @example Usage
* ```ts
* add(1, 2); // 3
* ```
*/
export function add(a: number, b: number): number {
return a + b;
}

View file

@ -0,0 +1,7 @@
import { add } from "./source.ts";
Deno.test("add()", () => {
if (add(1, 2) !== 3) {
throw new Error("test failed");
}
});

View file

@ -7,3 +7,12 @@ file:///[WILDCARD]/source.ts$[WILDCARD].ts ... ok ([WILDCARD])
ok | 2 passed | 0 failed ([WILDCARD]) ok | 2 passed | 0 failed ([WILDCARD])
--------------------------------
File | Branch % | Line % |
--------------------------------
source.ts | 100.0 | 100.0 |
--------------------------------
All files | 100.0 | 100.0 |
--------------------------------
Lcov coverage report has been generated at file://[WILDCARD]/coverage/lcov.info
HTML coverage report has been generated at file://[WILDCARD]/coverage/html/index.html

View file

@ -4,3 +4,4 @@ test ... ok ([WILDCARD])
ok | 1 passed | 0 failed ([WILDCARD]) ok | 1 passed | 0 failed ([WILDCARD])
Error generating coverage report: No covered files included in the report

View file

@ -2,7 +2,7 @@ import { emptyDir } from "@std/fs/empty-dir";
const DIR = "./coverage"; const DIR = "./coverage";
const COMMAND = new Deno.Command(Deno.execPath(), { const COMMAND = new Deno.Command(Deno.execPath(), {
args: ["test", "--coverage", "--clean"], args: ["test", "--coverage", "--clean", "--coverage-raw-data-only"],
stdout: "null", stdout: "null",
}); });