Skip to content

Commit 7e8cb03

Browse files
Add --fail-under flag for minimum coverage threshold (#2075)
1 parent 11f4cf7 commit 7e8cb03

File tree

9 files changed

+322
-19
lines changed

9 files changed

+322
-19
lines changed

pkgs/coverage/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## 1.13.0-wip
2+
3+
- Introduced support for minimum coverage thresholds using --fail-under flag in
4+
format_coverage.
5+
16
## 1.12.0
27

38
- Introduced support for specifying coverage flags through a YAML file.

pkgs/coverage/bin/format_coverage.dart

Lines changed: 63 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import 'dart:io';
77
import 'package:args/args.dart';
88
import 'package:coverage/coverage.dart';
99
import 'package:coverage/src/coverage_options.dart';
10+
import 'package:coverage/src/coverage_percentage.dart';
1011
import 'package:glob/glob.dart';
1112
import 'package:path/path.dart' as p;
1213

@@ -30,6 +31,7 @@ class Environment {
3031
required this.sdkRoot,
3132
required this.verbose,
3233
required this.workers,
34+
required this.failUnder,
3335
});
3436

3537
String? baseDirectory;
@@ -49,6 +51,7 @@ class Environment {
4951
String? sdkRoot;
5052
bool verbose;
5153
int workers;
54+
double? failUnder;
5255
}
5356

5457
Future<void> main(List<String> arguments) async {
@@ -130,6 +133,29 @@ Future<void> main(List<String> arguments) async {
130133
}
131134
}
132135
await outputSink.close();
136+
137+
// Check coverage against the fail-under threshold if specified.
138+
final failUnder = env.failUnder;
139+
if (failUnder != null) {
140+
// Calculate the overall coverage percentage using the utility function.
141+
final result = calculateCoveragePercentage(
142+
hitmap,
143+
);
144+
145+
if (env.verbose) {
146+
print('Coverage: ${result.percentage.toStringAsFixed(2)}% '
147+
'(${result.coveredLines} of ${result.totalLines} lines)');
148+
}
149+
150+
if (result.percentage < failUnder) {
151+
print('Error: Coverage ${result.percentage.toStringAsFixed(2)}% '
152+
'is less than required ${failUnder.toStringAsFixed(2)}%');
153+
exit(1);
154+
} else if (env.verbose) {
155+
print('Coverage ${result.percentage.toStringAsFixed(2)}% meets or exceeds'
156+
'the required ${failUnder.toStringAsFixed(2)}%');
157+
}
158+
}
133159
}
134160

135161
/// Checks the validity of the provided arguments. Does not initialize actual
@@ -143,6 +169,10 @@ Environment parseArgs(List<String> arguments, CoverageOptions defaultOptions) {
143169
abbr: 's',
144170
help: 'path to the SDK root',
145171
)
172+
..addOption(
173+
'fail-under',
174+
help: 'Fail if coverage is less than the given percentage (0-100)',
175+
)
146176
..addOption(
147177
'packages',
148178
help: '[DEPRECATED] path to the package spec file',
@@ -308,24 +338,40 @@ Environment parseArgs(List<String> arguments, CoverageOptions defaultOptions) {
308338
final checkIgnore = args['check-ignore'] as bool;
309339
final ignoredGlobs = args['ignore-files'] as List<String>;
310340
final verbose = args['verbose'] as bool;
341+
342+
double? failUnder;
343+
final failUnderStr = args['fail-under'] as String?;
344+
if (failUnderStr != null) {
345+
try {
346+
failUnder = double.parse(failUnderStr);
347+
if (failUnder < 0 || failUnder > 100) {
348+
fail('--fail-under must be a percentage between 0 and 100');
349+
}
350+
} catch (e) {
351+
fail('Invalid --fail-under value: $e');
352+
}
353+
}
354+
311355
return Environment(
312-
baseDirectory: baseDirectory,
313-
bazel: bazel,
314-
bazelWorkspace: bazelWorkspace,
315-
checkIgnore: checkIgnore,
316-
input: input,
317-
lcov: lcov,
318-
output: output,
319-
packagesPath: packagesPath,
320-
packagePath: packagePath,
321-
prettyPrint: prettyPrint,
322-
prettyPrintFunc: prettyPrintFunc,
323-
prettyPrintBranch: prettyPrintBranch,
324-
reportOn: reportOn,
325-
ignoreFiles: ignoredGlobs,
326-
sdkRoot: sdkRoot,
327-
verbose: verbose,
328-
workers: workers);
356+
baseDirectory: baseDirectory,
357+
bazel: bazel,
358+
bazelWorkspace: bazelWorkspace,
359+
checkIgnore: checkIgnore,
360+
input: input,
361+
lcov: lcov,
362+
output: output,
363+
packagesPath: packagesPath,
364+
packagePath: packagePath,
365+
prettyPrint: prettyPrint,
366+
prettyPrintFunc: prettyPrintFunc,
367+
prettyPrintBranch: prettyPrintBranch,
368+
reportOn: reportOn,
369+
ignoreFiles: ignoredGlobs,
370+
sdkRoot: sdkRoot,
371+
verbose: verbose,
372+
workers: workers,
373+
failUnder: failUnder,
374+
);
329375
}
330376

331377
/// Given an absolute path absPath, this function returns a [List] of files

pkgs/coverage/bin/test_with_coverage.dart

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ ArgParser _createArgParser(CoverageOptions defaultOptions) => ArgParser()
8585
defaultsTo: defaultOptions.branchCoverage,
8686
help: 'Collect branch coverage info.',
8787
)
88+
..addOption(
89+
'fail-under',
90+
help: 'Fail if coverage is less than the given percentage (0-100)',
91+
)
8892
..addMultiOption('scope-output',
8993
defaultsTo: defaultOptions.scopeOutput,
9094
help: 'restrict coverage results so that only scripts that start with '
@@ -101,7 +105,8 @@ class Flags {
101105
this.testScript,
102106
this.functionCoverage,
103107
this.branchCoverage,
104-
this.scopeOutput, {
108+
this.scopeOutput,
109+
this.failUnder, {
105110
required this.rest,
106111
});
107112

@@ -113,6 +118,7 @@ class Flags {
113118
final bool functionCoverage;
114119
final bool branchCoverage;
115120
final List<String> scopeOutput;
121+
final String? failUnder;
116122
final List<String> rest;
117123
}
118124

@@ -169,6 +175,7 @@ ${parser.usage}
169175
args['function-coverage'] as bool,
170176
args['branch-coverage'] as bool,
171177
args['scope-output'] as List<String>,
178+
args['fail-under'] as String?,
172179
rest: args.rest,
173180
);
174181
}
@@ -233,6 +240,7 @@ Future<void> main(List<String> arguments) async {
233240
outJson,
234241
'-o',
235242
outLcov,
243+
if (flags.failUnder != null) '--fail-under=${flags.failUnder}',
236244
]);
237245
exit(0);
238246
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'hitmap.dart';
6+
7+
/// Calculates the coverage percentage from a hitmap.
8+
///
9+
/// [hitmap] is the map of file paths to hit maps.
10+
///
11+
/// Returns a [CoverageResult] containing the coverage percentage and line
12+
/// counts.
13+
CoverageResult calculateCoveragePercentage(Map<String, HitMap> hitmap) {
14+
var totalLines = 0;
15+
var coveredLines = 0;
16+
for (final entry in hitmap.entries) {
17+
final lineHits = entry.value.lineHits;
18+
final branchHits = entry.value.branchHits;
19+
totalLines += lineHits.length;
20+
if (branchHits != null) {
21+
totalLines += branchHits.length;
22+
coveredLines += branchHits.values.where((v) => v > 0).length;
23+
}
24+
coveredLines += lineHits.values.where((v) => v > 0).length;
25+
}
26+
final coveragePercentage =
27+
totalLines > 0 ? coveredLines * 100 / totalLines : 0.0;
28+
29+
return CoverageResult(
30+
percentage: coveragePercentage,
31+
coveredLines: coveredLines,
32+
totalLines: totalLines,
33+
);
34+
}
35+
36+
/// The result of a coverage calculation.
37+
class CoverageResult {
38+
/// Creates a new [CoverageResult].
39+
const CoverageResult({
40+
required this.percentage,
41+
required this.coveredLines,
42+
required this.totalLines,
43+
});
44+
45+
/// The coverage percentage.
46+
final double percentage;
47+
48+
/// The number of covered lines.
49+
final int coveredLines;
50+
51+
/// The total number of lines.
52+
final int totalLines;
53+
}

pkgs/coverage/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
name: coverage
2-
version: 1.12.0
2+
version: 1.13.0-wip
33
description: Coverage data manipulation and formatting
44
repository: https://github.com/dart-lang/tools/tree/main/pkgs/coverage
55
issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Acoverage
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:coverage/src/coverage_percentage.dart';
6+
import 'package:coverage/src/hitmap.dart';
7+
import 'package:test/test.dart';
8+
9+
void main() {
10+
group('calculateCoveragePercentage', () {
11+
test('calculates correct percentage', () {
12+
final hitmap = {
13+
'file1.dart': HitMap({
14+
1: 1, // covered
15+
2: 1, // covered
16+
3: 0, // not covered
17+
4: 1, // covered
18+
}),
19+
'file2.dart': HitMap({
20+
1: 1, // covered
21+
2: 0, // not covered
22+
3: 0, // not covered
23+
}),
24+
};
25+
26+
final result = calculateCoveragePercentage(hitmap);
27+
28+
// 4 covered lines out of 7 total lines = 57.14%
29+
expect(result.percentage.toStringAsFixed(2), equals('57.14'));
30+
expect(result.coveredLines, equals(4));
31+
expect(result.totalLines, equals(7));
32+
});
33+
test('handles empty hitmap', () {
34+
final hitmap = <String, HitMap>{};
35+
36+
final result = calculateCoveragePercentage(hitmap);
37+
38+
expect(result.percentage, equals(0));
39+
expect(result.coveredLines, equals(0));
40+
expect(result.totalLines, equals(0));
41+
});
42+
43+
test('handles hitmap with no covered lines', () {
44+
final hitmap = {
45+
'file1.dart': HitMap({
46+
1: 0, // not covered
47+
2: 0, // not covered
48+
3: 0, // not covered
49+
}),
50+
};
51+
52+
final result = calculateCoveragePercentage(hitmap);
53+
54+
expect(result.percentage, equals(0));
55+
expect(result.coveredLines, equals(0));
56+
expect(result.totalLines, equals(3));
57+
});
58+
59+
test('handles hitmap with all lines covered', () {
60+
final hitmap = {
61+
'file1.dart': HitMap({
62+
1: 1, // covered
63+
2: 1, // covered
64+
3: 1, // covered
65+
}),
66+
};
67+
68+
final result = calculateCoveragePercentage(hitmap);
69+
70+
expect(result.percentage, equals(100));
71+
expect(result.coveredLines, equals(3));
72+
expect(result.totalLines, equals(3));
73+
});
74+
test('includes branch hits in coverage percentage', () {
75+
// 2 lines (1 covered) + 3 branches (2 covered) = 5 total, 3 covered
76+
final hitmap = {
77+
'file.dart': HitMap(
78+
{1: 1, 2: 0}, // lineHits
79+
null,
80+
null,
81+
{10: 1, 11: 0, 12: 2}, // branchHits
82+
),
83+
};
84+
final result = calculateCoveragePercentage(hitmap);
85+
86+
expect(result.totalLines, equals(5));
87+
expect(result.coveredLines, equals(3));
88+
expect(result.percentage.toStringAsFixed(2), equals('60.00'));
89+
});
90+
});
91+
}

pkgs/coverage/test/test_with_coverage_package/lib/validate_lib.dart

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,17 @@ int product(Iterable<int> values) {
1717
}
1818
return val;
1919
}
20+
21+
String evaluateScore(int score) {
22+
if (score < 0) {
23+
return 'Invalid';
24+
} else if (score < 50) {
25+
return 'Fail';
26+
} else if (score < 70) {
27+
return 'Pass';
28+
} else if (score <= 100) {
29+
return 'Excellent';
30+
} else {
31+
return 'Overflow';
32+
}
33+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) 2022, the Dart project authors. Please see the AUTHORS file
2+
// for details. All rights reserved. Use of this source code is governed by a
3+
// BSD-style license that can be found in the LICENSE file.
4+
5+
import 'package:test/test.dart';
6+
7+
// ignore: avoid_relative_lib_imports
8+
import '../lib/validate_lib.dart';
9+
10+
void main() {
11+
group('evaluateScore', () {
12+
test('returns Invalid for negative score', () {
13+
expect(evaluateScore(-10), equals('Invalid'));
14+
});
15+
16+
test('returns Fail for score < 50', () {
17+
expect(evaluateScore(30), equals('Fail'));
18+
});
19+
20+
test('returns Excellent for score 85', () {
21+
expect(evaluateScore(85), equals('Excellent'));
22+
});
23+
});
24+
}

0 commit comments

Comments
 (0)