use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::atomic::{AtomicU64, Ordering};
use anyhow::{bail, Context, Result};
use serde::Deserialize;
const TEST_OMIT: &str = "*_test.py";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Thresholds {
pub fail_under: u8,
pub branch: bool,
}
#[derive(Debug, Clone, Deserialize)]
pub struct CoverageReport {
pub totals: Totals,
}
#[derive(Debug, Clone, Deserialize)]
pub struct Totals {
pub percent_covered: f64,
#[serde(default)]
pub num_branches: u64,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Outcome {
Pass,
Fail(String),
}
pub fn parse_report(json: &str) -> Result<CoverageReport> {
serde_json::from_str(json).context("parsing coverage.py JSON report")
}
pub fn evaluate(report: &CoverageReport, thresholds: Thresholds) -> Outcome {
if thresholds.branch && report.totals.num_branches == 0 {
return Outcome::Fail(
"branch coverage is required but the report measured no branches".to_string(),
);
}
let actual = report.totals.percent_covered;
let required = f64::from(thresholds.fail_under);
if actual + 1e-9 >= required {
Outcome::Pass
} else {
Outcome::Fail(format!(
"coverage {actual:.2}% is below the required {}%",
thresholds.fail_under
))
}
}
pub fn measure(root: &Path, thresholds: Thresholds, omit: &[String]) -> Result<Outcome> {
let report = run_coverage(root, omit)?;
Ok(evaluate(&report, thresholds))
}
struct DataFile(PathBuf);
impl DataFile {
fn new() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let name = format!(
"testing-conventions-{}-{}.coverage",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
);
DataFile(std::env::temp_dir().join(name))
}
}
impl Drop for DataFile {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
fn run_coverage(root: &Path, omit: &[String]) -> Result<CoverageReport> {
let data = DataFile::new();
let omit = build_omit(omit);
let run = Command::new("coverage")
.current_dir(root)
.args(["run", "--branch"])
.arg(format!("--omit={omit}"))
.args(["-m", "pytest", "-q", "-p", "no:cacheprovider", "."])
.env("COVERAGE_FILE", &data.0)
.env("PYTHONDONTWRITEBYTECODE", "1")
.output()
.context("running `coverage run -m pytest` (is coverage.py installed?)")?;
if !run.status.success() {
bail!(
"the unit suite did not run cleanly under coverage in `{}`:\n{}{}",
root.display(),
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr),
);
}
let json = Command::new("coverage")
.current_dir(root)
.args(["json", "-o", "-"])
.env("COVERAGE_FILE", &data.0)
.output()
.context("running `coverage json`")?;
if !json.status.success() {
bail!(
"`coverage json` failed:\n{}",
String::from_utf8_lossy(&json.stderr),
);
}
parse_report(&String::from_utf8_lossy(&json.stdout))
}
fn build_omit(omit: &[String]) -> String {
std::iter::once(TEST_OMIT.to_string())
.chain(omit.iter().cloned())
.collect::<Vec<_>>()
.join(",")
}
const TS_INCLUDE: &str = "**/*.{ts,tsx,mts,cts}";
const TS_TEST_EXCLUDE: &str = "**/*.test.*";
const TS_DECL_EXCLUDE: &str = "**/*.d.{ts,mts,cts}";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TypeScriptThresholds {
pub lines: u8,
pub branches: u8,
pub functions: u8,
pub statements: u8,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct VitestReport {
pub total: VitestTotals,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct VitestTotals {
pub lines: VitestMetric,
pub branches: VitestMetric,
pub functions: VitestMetric,
pub statements: VitestMetric,
}
#[derive(Debug, Clone, Copy, Deserialize)]
pub struct VitestMetric {
#[serde(deserialize_with = "deserialize_pct")]
pub pct: Option<f64>,
pub total: u64,
}
fn deserialize_pct<'de, D>(deserializer: D) -> std::result::Result<Option<f64>, D::Error>
where
D: serde::Deserializer<'de>,
{
struct PctVisitor;
impl serde::de::Visitor<'_> for PctVisitor {
type Value = Option<f64>;
fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("a coverage percent number or the string \"Unknown\"")
}
fn visit_f64<E>(self, value: f64) -> std::result::Result<Self::Value, E> {
Ok(Some(value))
}
fn visit_u64<E>(self, value: u64) -> std::result::Result<Self::Value, E> {
Ok(Some(value as f64))
}
fn visit_str<E>(self, _value: &str) -> std::result::Result<Self::Value, E> {
Ok(None)
}
}
deserializer.deserialize_any(PctVisitor)
}
pub fn parse_vitest_report(json: &str) -> Result<VitestReport> {
serde_json::from_str(json).context("parsing vitest coverage-summary JSON report")
}
pub fn evaluate_typescript(report: &VitestReport, thresholds: TypeScriptThresholds) -> Outcome {
let total = &report.total;
if total.lines.total == 0 {
return Outcome::Fail(
"the unit suite measured no code — check the path and that the suite runs".to_string(),
);
}
let checks = [
("lines", total.lines, thresholds.lines),
("branches", total.branches, thresholds.branches),
("functions", total.functions, thresholds.functions),
("statements", total.statements, thresholds.statements),
];
let mut shortfalls = Vec::new();
for (name, metric, required) in checks {
let actual = metric.pct.unwrap_or(100.0);
if actual + 1e-9 < f64::from(required) {
shortfalls.push(format!("{name} {actual:.2}% < {required}%"));
}
}
if shortfalls.is_empty() {
Outcome::Pass
} else {
Outcome::Fail(format!(
"coverage below thresholds: {}",
shortfalls.join(", ")
))
}
}
pub fn measure_typescript(
root: &Path,
thresholds: TypeScriptThresholds,
exclude: &[String],
) -> Result<Outcome> {
let report = run_vitest(root, exclude)?;
Ok(evaluate_typescript(&report, thresholds))
}
struct ReportDir(PathBuf);
impl ReportDir {
fn new() -> Self {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let name = format!(
"testing-conventions-vitest-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
);
ReportDir(std::env::temp_dir().join(name))
}
fn summary(&self) -> PathBuf {
self.0.join("coverage-summary.json")
}
}
impl Drop for ReportDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn run_vitest(root: &Path, exclude: &[String]) -> Result<VitestReport> {
let reports = ReportDir::new();
let mut command = Command::new("npx");
command
.current_dir(root)
.args(["--yes", "vitest", "run", "--no-cache"])
.args([
"--coverage.enabled",
"--coverage.provider=v8",
"--coverage.reporter=json-summary",
"--coverage.all=true",
])
.arg(format!(
"--coverage.reportsDirectory={}",
reports.0.display()
))
.arg(format!("--coverage.include={TS_INCLUDE}"))
.arg(format!("--coverage.exclude={TS_TEST_EXCLUDE}"))
.arg(format!("--coverage.exclude={TS_DECL_EXCLUDE}"));
for path in exclude {
command.arg(format!("--coverage.exclude={path}"));
}
let run = command.env("CI", "1").output().context(
"running `npx vitest run --coverage` (are vitest and @vitest/coverage-v8 installed?)",
)?;
if !run.status.success() {
bail!(
"the unit suite did not run cleanly under vitest in `{}`:\n{}{}",
root.display(),
String::from_utf8_lossy(&run.stdout),
String::from_utf8_lossy(&run.stderr),
);
}
let summary = reports.summary();
let json = std::fs::read_to_string(&summary).with_context(|| {
format!(
"reading vitest coverage summary `{}` (did the run produce a json-summary report?)",
summary.display()
)
})?;
parse_vitest_report(&json)
}
#[cfg(test)]
mod tests {
use super::*;
fn report(percent_covered: f64, num_branches: u64) -> CoverageReport {
CoverageReport {
totals: Totals {
percent_covered,
num_branches,
},
}
}
#[test]
fn passes_when_total_meets_the_floor() {
assert_eq!(
evaluate(
&report(100.0, 12),
Thresholds {
fail_under: 100,
branch: true
}
),
Outcome::Pass
);
}
#[test]
fn fails_when_total_is_below_the_floor() {
assert!(matches!(
evaluate(
&report(80.0, 12),
Thresholds {
fail_under: 100,
branch: true
}
),
Outcome::Fail(_)
));
}
#[test]
fn fails_when_branch_required_but_unmeasured() {
assert!(matches!(
evaluate(
&report(100.0, 0),
Thresholds {
fail_under: 90,
branch: true
}
),
Outcome::Fail(_)
));
}
#[test]
fn parses_a_coverage_py_report() {
let json = r#"{"totals":{"percent_covered":91.5,"num_branches":8,"covered_lines":91}}"#;
let report = parse_report(json).expect("valid coverage.py json");
assert_eq!(report.totals.percent_covered, 91.5);
assert_eq!(report.totals.num_branches, 8);
}
#[test]
fn omit_is_just_the_test_glob_when_nothing_is_exempt() {
assert_eq!(build_omit(&[]), "*_test.py");
}
#[test]
fn omit_folds_in_the_exempt_paths_after_the_test_glob() {
let exempt = vec!["pkg/gen.py".to_string(), "shim.py".to_string()];
assert_eq!(build_omit(&exempt), "*_test.py,pkg/gen.py,shim.py");
}
fn metric(pct: f64) -> VitestMetric {
VitestMetric {
pct: Some(pct),
total: 10,
}
}
fn ts_report(lines: f64, branches: f64, functions: f64, statements: f64) -> VitestReport {
VitestReport {
total: VitestTotals {
lines: metric(lines),
branches: metric(branches),
functions: metric(functions),
statements: metric(statements),
},
}
}
const TS_FULL: TypeScriptThresholds = TypeScriptThresholds {
lines: 100,
branches: 100,
functions: 100,
statements: 100,
};
const TS_MID: TypeScriptThresholds = TypeScriptThresholds {
lines: 80,
branches: 75,
functions: 80,
statements: 80,
};
#[test]
fn typescript_passes_when_every_metric_meets_its_floor() {
assert_eq!(
evaluate_typescript(&ts_report(100.0, 100.0, 100.0, 100.0), TS_FULL),
Outcome::Pass
);
}
#[test]
fn typescript_fails_on_the_one_metric_below_its_floor() {
let outcome = evaluate_typescript(&ts_report(100.0, 66.66, 100.0, 100.0), TS_MID);
assert!(
matches!(&outcome, Outcome::Fail(message) if message.contains("branches") && !message.contains("lines")),
"got: {outcome:?}"
);
}
#[test]
fn typescript_fail_message_names_every_metric_below() {
let outcome = evaluate_typescript(&ts_report(70.0, 70.0, 70.0, 70.0), TS_MID);
assert!(
matches!(&outcome, Outcome::Fail(message)
if message.contains("lines")
&& message.contains("branches")
&& message.contains("functions")
&& message.contains("statements")),
"got: {outcome:?}"
);
}
#[test]
fn typescript_tolerates_float_noise_at_the_floor() {
assert_eq!(
evaluate_typescript(&ts_report(99.999_999_999, 100.0, 100.0, 100.0), TS_FULL),
Outcome::Pass
);
}
#[test]
fn typescript_empty_denominator_metric_is_vacuously_satisfied() {
let report = VitestReport {
total: VitestTotals {
lines: metric(100.0),
branches: VitestMetric {
pct: None,
total: 0,
},
functions: metric(100.0),
statements: metric(100.0),
},
};
assert_eq!(evaluate_typescript(&report, TS_FULL), Outcome::Pass);
}
#[test]
fn typescript_fails_a_vacuous_run_that_measured_no_code() {
let nothing = VitestMetric {
pct: None,
total: 0,
};
let report = VitestReport {
total: VitestTotals {
lines: nothing,
branches: nothing,
functions: nothing,
statements: nothing,
},
};
let outcome = evaluate_typescript(&report, TS_MID);
assert!(
matches!(&outcome, Outcome::Fail(message) if message.contains("measured no code")),
"got: {outcome:?}"
);
}
#[test]
fn parses_a_vitest_summary_report() {
let json = r#"{
"total": {
"lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
"statements": {"total": 5, "covered": 4, "skipped": 0, "pct": 80},
"functions": {"total": 2, "covered": 2, "skipped": 0, "pct": 100},
"branches": {"total": 3, "covered": 2, "skipped": 0, "pct": 66.66},
"branchesTrue": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
},
"/abs/widget.ts": {
"lines": {"total": 5, "covered": 4, "skipped": 0, "pct": 80}
}
}"#;
let report = parse_vitest_report(json).expect("valid vitest json-summary");
assert_eq!(report.total.lines.pct, Some(80.0));
assert_eq!(report.total.branches.pct, Some(66.66));
assert_eq!(report.total.functions.total, 2);
}
#[test]
fn parses_an_unknown_pct_as_unmeasured() {
let json = r#"{"total": {
"lines": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
"statements": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
"functions": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"},
"branches": {"total": 0, "covered": 0, "skipped": 0, "pct": "Unknown"}
}}"#;
let report = parse_vitest_report(json).expect("valid vitest json-summary");
assert_eq!(report.total.lines.pct, None);
assert_eq!(report.total.lines.total, 0);
}
#[test]
fn a_pct_that_is_neither_number_nor_string_is_a_parse_error() {
let json = r#"{"total":{
"lines": {"total": 1, "covered": 1, "skipped": 0, "pct": true},
"statements": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
"functions": {"total": 1, "covered": 1, "skipped": 0, "pct": 100},
"branches": {"total": 1, "covered": 1, "skipped": 0, "pct": 100}
}}"#;
assert!(parse_vitest_report(json).is_err());
}
}