Skip to content

Commit f8be21c

Browse files
authored
feat(coverage): add configurable coverage thresholds (#35056)
Teams that gate CI on coverage had no way to make Deno fail when coverage dropped below a target; `deno coverage` and `deno test --coverage` always exited zero regardless of the numbers. This was the long-standing ask in issues #9669 and #29076. This adds a minimum-coverage threshold that exits non-zero when unmet. `deno coverage --threshold=<percent>` and `deno test --coverage --coverage-threshold=<percent>` apply one percentage to line, branch, and function coverage. For finer control, a `coverage` section in deno.json sets per-metric thresholds, modelled on Vitest's `coverage.thresholds`: { "coverage": { "thresholds": { "lines": 90, "branches": 80, "functions": 90 } } } When the CLI flag is given it applies its single value to all three metrics, overriding whatever the config sets for each. With no flag, the per-metric config is honored by both `deno coverage` and `deno test --coverage`. The check runs against the aggregate across all reported files, and reuses the same accumulation the summary reporter prints so the checked numbers match the displayed ones. A metric with no configured threshold is not checked (e.g. a `branches` threshold passes vacuously for files that have no branches), and a malformed or out-of-range `coverage` config is a hard error rather than being silently ignored. CLI flags take integer percentages in 0-100 (validated by the parser); the deno.json config accepts fractional values in 0-100 for finer per-metric targets. Per-file thresholds (Vitest's `perFile`) are not included here and could be a follow-up. Closes #9669 Closes #29076
1 parent 7479822 commit f8be21c

32 files changed

Lines changed: 564 additions & 23 deletions

cli/args/flags.rs

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,6 +342,9 @@ pub struct CoverageFlags {
342342
pub include: Vec<String>,
343343
pub exclude: Vec<String>,
344344
pub r#type: CoverageType,
345+
/// Minimum coverage percentage (0-100) applied to line, branch, and function
346+
/// coverage. Overrides per-metric thresholds from `deno.json`.
347+
pub threshold: Option<u32>,
345348
}
346349

347350
#[derive(Clone, Debug, Eq, PartialEq, Default)]
@@ -682,6 +685,9 @@ pub struct TestFlags {
682685
pub no_run: bool,
683686
pub coverage_dir: Option<String>,
684687
pub coverage_raw_data_only: bool,
688+
/// Minimum coverage percentage (0-100) required when `--coverage` is set.
689+
/// Overrides per-metric thresholds from `deno.json`.
690+
pub coverage_threshold: Option<u32>,
685691
pub clean: bool,
686692
pub fail_fast: Option<NonZeroUsize>,
687693
pub files: FileFlags,
@@ -3588,6 +3594,15 @@ Generate html reports from lcov:
35883594
.help("Output coverage report in detailed format in the terminal")
35893595
.action(ArgAction::SetTrue),
35903596
)
3597+
.arg(
3598+
Arg::new("threshold")
3599+
.long("threshold")
3600+
.value_name("PERCENT")
3601+
.value_parser(value_parser!(u32).range(0..=100))
3602+
.require_equals(true)
3603+
.help(cstr!("Fail if coverage is below this percentage (0-100), applied to line, branch, and function coverage.
3604+
<p(245)>Per-metric thresholds can be set in deno.json under \"coverage\": { \"thresholds\": { ... } }. The flag takes precedence.</>")),
3605+
)
35913606
.arg(
35923607
Arg::new("files")
35933608
.num_args(0..)
@@ -5247,6 +5262,16 @@ or <c>**/__tests__/**</>:
52475262
.action(ArgAction::SetTrue)
52485263
.help_heading(TEST_HEADING),
52495264
)
5265+
.arg(
5266+
Arg::new("coverage-threshold")
5267+
.long("coverage-threshold")
5268+
.value_name("PERCENT")
5269+
.value_parser(value_parser!(u32).range(0..=100))
5270+
.require_equals(true)
5271+
.requires("coverage")
5272+
.help("Fail if coverage is below this percentage (0-100). Requires --coverage")
5273+
.help_heading(TEST_HEADING),
5274+
)
52505275
.arg(
52515276
Arg::new("clean")
52525277
.long("clean")
@@ -7741,6 +7766,7 @@ fn coverage_parse(
77417766
CoverageType::Summary
77427767
};
77437768
let output = matches.remove_one::<String>("output");
7769+
let threshold = matches.remove_one::<u32>("threshold");
77447770
flags.subcommand = DenoSubcommand::Coverage(CoverageFlags {
77457771
files: FileFlags {
77467772
include: files,
@@ -7750,6 +7776,7 @@ fn coverage_parse(
77507776
include,
77517777
exclude,
77527778
r#type,
7779+
threshold,
77537780
});
77547781
Ok(())
77557782
}
@@ -8795,6 +8822,7 @@ fn test_parse(
87958822
doc,
87968823
coverage_dir: matches.remove_one::<String>("coverage"),
87978824
coverage_raw_data_only: matches.get_flag("coverage-raw-data-only"),
8825+
coverage_threshold: matches.remove_one::<u32>("coverage-threshold"),
87988826
clean,
87998827
fail_fast,
88008828
files: FileFlags { include, ignore },
@@ -13229,6 +13257,7 @@ mod tests {
1322913257
sanitize_resources: false,
1323013258
coverage_dir: Some("cov".to_string()),
1323113259
coverage_raw_data_only: false,
13260+
coverage_threshold: None,
1323213261
clean: true,
1323313262
watch: Default::default(),
1323413263
reporter: Default::default(),
@@ -13341,6 +13370,7 @@ mod tests {
1334113370
sanitize_resources: false,
1334213371
coverage_dir: None,
1334313372
coverage_raw_data_only: false,
13373+
coverage_threshold: None,
1334413374
clean: false,
1334513375
watch: Default::default(),
1334613376
reporter: Default::default(),
@@ -13390,6 +13420,7 @@ mod tests {
1339013420
sanitize_resources: false,
1339113421
coverage_dir: None,
1339213422
coverage_raw_data_only: false,
13423+
coverage_threshold: None,
1339313424
clean: false,
1339413425
watch: Default::default(),
1339513426
reporter: Default::default(),
@@ -13533,6 +13564,7 @@ mod tests {
1353313564
sanitize_resources: false,
1353413565
coverage_dir: None,
1353513566
coverage_raw_data_only: false,
13567+
coverage_threshold: None,
1353613568
clean: false,
1353713569
watch: Default::default(),
1353813570
reporter: Default::default(),
@@ -13575,6 +13607,7 @@ mod tests {
1357513607
sanitize_resources: false,
1357613608
coverage_dir: None,
1357713609
coverage_raw_data_only: false,
13610+
coverage_threshold: None,
1357813611
clean: false,
1357913612
watch: Default::default(),
1358013613
reporter: Default::default(),
@@ -13636,6 +13669,7 @@ mod tests {
1363613669
sanitize_resources: false,
1363713670
coverage_dir: None,
1363813671
coverage_raw_data_only: false,
13672+
coverage_threshold: None,
1363913673
clean: false,
1364013674
watch: Some(Default::default()),
1364113675
reporter: Default::default(),
@@ -13677,6 +13711,7 @@ mod tests {
1367713711
sanitize_resources: false,
1367813712
coverage_dir: None,
1367913713
coverage_raw_data_only: false,
13714+
coverage_threshold: None,
1368013715
clean: false,
1368113716
watch: Some(Default::default()),
1368213717
reporter: Default::default(),
@@ -13720,6 +13755,7 @@ mod tests {
1372013755
sanitize_resources: false,
1372113756
coverage_dir: None,
1372213757
coverage_raw_data_only: false,
13758+
coverage_threshold: None,
1372313759
clean: false,
1372413760
watch: Some(WatchFlagsWithPaths {
1372513761
hmr: false,
@@ -14575,6 +14611,36 @@ mod tests {
1457514611
);
1457614612
}
1457714613

14614+
#[test]
14615+
fn coverage_with_threshold() {
14616+
let r =
14617+
flags_from_vec(svec!["deno", "coverage", "--threshold=80", "foo.json"]);
14618+
assert_eq!(
14619+
r.unwrap(),
14620+
Flags {
14621+
subcommand: DenoSubcommand::Coverage(CoverageFlags {
14622+
files: FileFlags {
14623+
include: vec!["foo.json".to_string()],
14624+
ignore: vec![],
14625+
},
14626+
include: vec![r"^file:".to_string()],
14627+
exclude: vec![r"test\.(js|mjs|ts|jsx|tsx)$".to_string()],
14628+
threshold: Some(80),
14629+
..CoverageFlags::default()
14630+
}),
14631+
..Flags::default()
14632+
}
14633+
);
14634+
}
14635+
14636+
#[test]
14637+
fn coverage_threshold_out_of_range() {
14638+
// Percentages above 100 are rejected by the value parser.
14639+
let r =
14640+
flags_from_vec(svec!["deno", "coverage", "--threshold=150", "foo.json"]);
14641+
assert!(r.is_err());
14642+
}
14643+
1457814644
#[test]
1457914645
fn coverage_with_lcov_and_out_file() {
1458014646
let r = flags_from_vec(svec![
@@ -14595,6 +14661,7 @@ mod tests {
1459514661
include: vec![r"^file:".to_string()],
1459614662
exclude: vec![r"test\.(js|mjs|ts|jsx|tsx)$".to_string()],
1459714663
r#type: CoverageType::Lcov,
14664+
threshold: None,
1459814665
output: Some(String::from("foo.lcov")),
1459914666
}),
1460014667
..Flags::default()

cli/args/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use deno_cache_dir::file_fetcher::CacheSetting;
1818
pub use deno_config::deno_json::BenchConfig;
1919
pub use deno_config::deno_json::CompilerOptions;
2020
pub use deno_config::deno_json::ConfigFile;
21+
pub use deno_config::deno_json::CoverageThresholds;
2122
use deno_config::deno_json::FmtConfig;
2223
pub use deno_config::deno_json::FmtOptionsConfig;
2324
pub use deno_config::deno_json::LintRulesConfig;
@@ -1018,6 +1019,14 @@ impl CliOptions {
10181019
Ok(result)
10191020
}
10201021

1022+
/// Resolves the coverage thresholds configured in `deno.json`'s `coverage`
1023+
/// section. CLI flags are layered on top of this by the caller.
1024+
pub fn resolve_coverage_thresholds(
1025+
&self,
1026+
) -> Result<CoverageThresholds, AnyError> {
1027+
Ok(self.start_dir.to_coverage_config()?.thresholds)
1028+
}
1029+
10211030
pub fn resolve_workspace_test_options(
10221031
&self,
10231032
test_flags: &TestFlags,

cli/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,7 @@ async fn run_subcommand(
205205
coverage_flags.include,
206206
coverage_flags.exclude,
207207
coverage_flags.output,
208+
coverage_flags.threshold.map(|t| t as f64),
208209
&[&*reporter],
209210
)
210211
}),

cli/schemas/config-file.v1.json

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1080,6 +1080,36 @@
10801080
}
10811081
}
10821082
},
1083+
"coverage": {
1084+
"description": "Configuration for deno coverage and deno test --coverage",
1085+
"type": "object",
1086+
"properties": {
1087+
"thresholds": {
1088+
"description": "Minimum coverage percentages required for the command to succeed. Each metric is checked against the aggregate across all files.",
1089+
"type": "object",
1090+
"properties": {
1091+
"lines": {
1092+
"type": "number",
1093+
"description": "Minimum line coverage percentage (0-100).",
1094+
"minimum": 0,
1095+
"maximum": 100
1096+
},
1097+
"branches": {
1098+
"type": "number",
1099+
"description": "Minimum branch coverage percentage (0-100).",
1100+
"minimum": 0,
1101+
"maximum": 100
1102+
},
1103+
"functions": {
1104+
"type": "number",
1105+
"description": "Minimum function coverage percentage (0-100).",
1106+
"minimum": 0,
1107+
"maximum": 100
1108+
}
1109+
}
1110+
}
1111+
}
1112+
},
10831113
"license": {
10841114
"description": "The SPDX license identifier if this is a JSR package. Specify this or add a license file to the package.",
10851115
"type": ["string"]

cli/tools/coverage/mod.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ use self::ignore_directives::lex_comments;
3030
use self::ignore_directives::parse_next_ignore_directives;
3131
use self::ignore_directives::parse_range_ignore_directives;
3232
use crate::args::CliOptions;
33+
use crate::args::CoverageThresholds;
3334
use crate::args::FileFlags;
3435
use crate::args::Flags;
3536
use crate::cdp;
@@ -588,13 +589,15 @@ fn filter_coverages(
588589
.collect::<Vec<cdp::ScriptCoverage>>()
589590
}
590591

592+
#[allow(clippy::too_many_arguments, reason = "coverage entry point")]
591593
pub fn cover_files(
592594
flags: Arc<Flags>,
593595
files_include: Vec<String>,
594596
files_ignore: Vec<String>,
595597
include: Vec<String>,
596598
exclude: Vec<String>,
597599
output: Option<String>,
600+
cli_threshold: Option<f64>,
598601
reporters: &[&dyn CoverageReporter],
599602
) -> Result<(), AnyError> {
600603
if files_include.is_empty() {
@@ -770,5 +773,95 @@ pub fn cover_files(
770773
reporter.done(&coverage_root, &file_reports);
771774
}
772775

776+
// Layer the `--threshold` CLI flag over per-metric thresholds from deno.json
777+
// (the flag wins) and fail the command if any configured threshold is unmet.
778+
let config_thresholds = cli_options.resolve_coverage_thresholds()?;
779+
let thresholds = CoverageThresholds {
780+
lines: cli_threshold.or(config_thresholds.lines),
781+
branches: cli_threshold.or(config_thresholds.branches),
782+
functions: cli_threshold.or(config_thresholds.functions),
783+
};
784+
check_coverage_thresholds(&file_reports, thresholds)?;
785+
773786
Ok(())
774787
}
788+
789+
/// Computes the aggregate line, branch, and function coverage percentages
790+
/// across all reported files. A metric with no measurable items counts as 100%
791+
/// (e.g. a `branches` threshold passes vacuously for files that have no
792+
/// branches). Reuses the same accumulation and percentage helpers as the
793+
/// summary reporter so the checked numbers match the printed ones.
794+
fn aggregate_coverage_percentages(
795+
file_reports: &[(CoverageReport, String)],
796+
) -> (f64, f64, f64) {
797+
let mut stats = reporter::CoverageStats::default();
798+
for (report, _) in file_reports {
799+
stats.add_report(report);
800+
}
801+
let percent = |hit: usize, miss: usize| -> f64 {
802+
util::calc_coverage_display_info(hit, miss).1 as f64
803+
};
804+
(
805+
percent(stats.line_hit, stats.line_miss),
806+
percent(stats.branch_hit, stats.branch_miss),
807+
percent(stats.fn_hit, stats.fn_miss),
808+
)
809+
}
810+
811+
fn check_coverage_thresholds(
812+
file_reports: &[(CoverageReport, String)],
813+
thresholds: CoverageThresholds,
814+
) -> Result<(), AnyError> {
815+
if thresholds.lines.is_none()
816+
&& thresholds.branches.is_none()
817+
&& thresholds.functions.is_none()
818+
{
819+
return Ok(());
820+
}
821+
822+
let (line_percent, branch_percent, fn_percent) =
823+
aggregate_coverage_percentages(file_reports);
824+
825+
let mut failures = Vec::new();
826+
let mut check = |name: &str, actual: f64, threshold: Option<f64>| {
827+
if let Some(threshold) = threshold
828+
&& actual < threshold
829+
{
830+
// Floor the displayed value to two decimals so a near-miss like 89.999%
831+
// doesn't render as "90.00% is below the threshold of 90.00%".
832+
let actual = (actual * 100.0).floor() / 100.0;
833+
failures.push(format!(
834+
" - {name} coverage {actual:.2}% is below the threshold of {threshold:.2}%"
835+
));
836+
}
837+
};
838+
check("Line", line_percent, thresholds.lines);
839+
check("Branch", branch_percent, thresholds.branches);
840+
check("Function", fn_percent, thresholds.functions);
841+
842+
if failures.is_empty() {
843+
Ok(())
844+
} else {
845+
Err(
846+
CoverageThresholdError(format!(
847+
"Coverage threshold not met:\n{}",
848+
failures.join("\n")
849+
))
850+
.into(),
851+
)
852+
}
853+
}
854+
855+
/// Returned when a configured coverage threshold is not met. A distinct type so
856+
/// `deno test --coverage` can treat it as fatal while still tolerating benign
857+
/// report-generation errors.
858+
#[derive(Debug)]
859+
pub struct CoverageThresholdError(pub String);
860+
861+
impl std::fmt::Display for CoverageThresholdError {
862+
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
863+
write!(f, "{}", self.0)
864+
}
865+
}
866+
867+
impl std::error::Error for CoverageThresholdError {}

0 commit comments

Comments
 (0)