Skip to content

Commit ba40ca8

Browse files
feat: add --collect-coverage-from to test command (#1457)
* feat: add --collect-coverage-from option to include untested files in coverage * fix: use forward slashes in LCOV SF entries on Windows LCOV format expects Unix-style forward slashes in file paths. On Windows, p.relative() returns backslash-separated paths, causing test failures when comparing LCOV output. * fix: add LF and LH lines to synthetic LCOV entries for untested files --------- Co-authored-by: Marcos Sevilla <31174242+marcossevilla@users.noreply.github.com>
1 parent cd9b31c commit ba40ca8

9 files changed

Lines changed: 698 additions & 18 deletions

File tree

lib/src/cli/dart_cli.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ class Dart {
104104
Set<String> ignore = const {},
105105
double? minCoverage,
106106
String? excludeFromCoverage,
107+
CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports,
107108
String? randomSeed,
108109
bool? forceAnsi,
109110
List<String>? arguments,
@@ -122,6 +123,7 @@ class Dart {
122123
ignore: ignore,
123124
minCoverage: minCoverage,
124125
excludeFromCoverage: excludeFromCoverage,
126+
collectCoverageFrom: collectCoverageFrom,
125127
randomSeed: randomSeed,
126128
forceAnsi: forceAnsi,
127129
arguments: arguments,

lib/src/cli/flutter_cli.dart

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ class Flutter {
159159
Set<String> ignore = const {},
160160
double? minCoverage,
161161
String? excludeFromCoverage,
162+
CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports,
162163
String? randomSeed,
163164
bool? forceAnsi,
164165
List<String>? arguments,
@@ -176,6 +177,7 @@ class Flutter {
176177
ignore: ignore,
177178
minCoverage: minCoverage,
178179
excludeFromCoverage: excludeFromCoverage,
180+
collectCoverageFrom: collectCoverageFrom,
179181
randomSeed: randomSeed,
180182
forceAnsi: forceAnsi,
181183
arguments: arguments,

lib/src/cli/test_cli_runner.dart

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,24 @@ enum TestRunType {
1919
dart,
2020
}
2121

22+
/// How to collect coverage.
23+
enum CoverageCollectionMode {
24+
/// Collect coverage from imported files only (default behavior).
25+
imports,
26+
27+
/// Collect coverage from all files in the project.
28+
all
29+
;
30+
31+
/// Parses a string value into a [CoverageCollectionMode].
32+
static CoverageCollectionMode fromString(String value) {
33+
return CoverageCollectionMode.values.firstWhere(
34+
(mode) => mode.name == value,
35+
orElse: () => CoverageCollectionMode.imports,
36+
);
37+
}
38+
}
39+
2240
/// A method which returns a [Future<MasonGenerator>] given a [MasonBundle].
2341
typedef GeneratorBuilder = Future<MasonGenerator> Function(MasonBundle);
2442

@@ -72,6 +90,7 @@ class TestCLIRunner {
7290
Set<String> ignore = const {},
7391
double? minCoverage,
7492
String? excludeFromCoverage,
93+
CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports,
7594
String? randomSeed,
7695
bool? forceAnsi,
7796
List<String>? arguments,
@@ -197,13 +216,36 @@ class TestCLIRunner {
197216
// Write the lcov output to the file.
198217
await lcovFile.create(recursive: true);
199218
await lcovFile.writeAsString(output);
219+
220+
// If collectCoverageFrom is 'all', enhance with untested
221+
// files
222+
if (collectCoverageFrom == CoverageCollectionMode.all) {
223+
await _enhanceLcovWithUntestedFiles(
224+
lcovPath: lcovPath,
225+
cwd: cwd,
226+
reportOn: reportOn ?? 'lib',
227+
excludeFromCoverage: excludeFromCoverage,
228+
);
229+
}
200230
}
201231

202232
if (collectCoverage) {
203233
assert(
204234
lcovFile.existsSync(),
205235
'coverage/lcov.info must exist',
206236
);
237+
238+
// For Flutter tests with collectCoverageFrom = all, enhance
239+
// lcov
240+
if (testType == TestRunType.flutter &&
241+
collectCoverageFrom == CoverageCollectionMode.all) {
242+
await _enhanceLcovWithUntestedFiles(
243+
lcovPath: lcovPath,
244+
cwd: cwd,
245+
reportOn: 'lib',
246+
excludeFromCoverage: excludeFromCoverage,
247+
);
248+
}
207249
}
208250

209251
if (minCoverage != null) {
@@ -256,6 +298,96 @@ class TestCLIRunner {
256298
);
257299
}
258300

301+
/// Discovers all Dart files in the specified directory for coverage.
302+
static List<String> _discoverDartFilesForCoverage({
303+
required String cwd,
304+
required String reportOn,
305+
String? excludeFromCoverage,
306+
}) {
307+
final reportOnPath = p.join(cwd, reportOn);
308+
final directory = Directory(reportOnPath);
309+
310+
if (!directory.existsSync()) return [];
311+
312+
final glob = excludeFromCoverage != null ? Glob(excludeFromCoverage) : null;
313+
314+
return directory
315+
.listSync(recursive: true)
316+
.whereType<File>()
317+
.where((file) => file.path.endsWith('.dart'))
318+
.where((file) => glob == null || !glob.matches(file.path))
319+
.map((file) => p.relative(file.path, from: cwd))
320+
.toList();
321+
}
322+
323+
/// Enhances an existing lcov file by adding uncovered files with 0% coverage.
324+
static Future<void> _enhanceLcovWithUntestedFiles({
325+
required String lcovPath,
326+
required String cwd,
327+
required String reportOn,
328+
String? excludeFromCoverage,
329+
}) async {
330+
final lcovFile = File(lcovPath);
331+
332+
final allDartFiles = _discoverDartFilesForCoverage(
333+
cwd: cwd,
334+
reportOn: reportOn,
335+
excludeFromCoverage: excludeFromCoverage,
336+
);
337+
338+
// Parse existing lcov to find covered files
339+
final existingRecords = await Parser.parse(lcovPath);
340+
final coveredFiles = existingRecords
341+
.where((r) => r.file != null)
342+
.map((r) => r.file!)
343+
.toSet();
344+
345+
// Find uncovered files
346+
final uncoveredFiles = allDartFiles.where((file) {
347+
final normalizedFile = p.normalize(file);
348+
for (final covered in coveredFiles) {
349+
if (p.normalize(covered).endsWith(normalizedFile)) {
350+
return false; // File is covered
351+
}
352+
}
353+
return true; // File is uncovered
354+
}).toList();
355+
356+
if (uncoveredFiles.isEmpty) return;
357+
358+
// Append uncovered files to lcov
359+
final lcovContent = await lcovFile.readAsString();
360+
final buffer = StringBuffer(lcovContent);
361+
362+
for (final file in uncoveredFiles) {
363+
final absolutePath = p.join(cwd, file);
364+
final dartFile = File(absolutePath);
365+
if (dartFile.existsSync()) {
366+
final lines = await dartFile.readAsLines();
367+
buffer.writeln('SF:${file.replaceAll(r'\', '/')}');
368+
// Mark non-trivial lines as uncovered
369+
var linesFound = 0;
370+
for (var i = 1; i <= lines.length; i++) {
371+
final line = lines[i - 1].trim();
372+
if (line.isNotEmpty &&
373+
!line.startsWith('//') &&
374+
!line.startsWith('import') &&
375+
!line.startsWith('export') &&
376+
!line.startsWith('part')) {
377+
buffer.writeln('DA:$i,0');
378+
linesFound++;
379+
}
380+
}
381+
buffer
382+
..writeln('LF:$linesFound')
383+
..writeln('LH:0')
384+
..writeln('end_of_record');
385+
}
386+
}
387+
388+
await lcovFile.writeAsString(buffer.toString());
389+
}
390+
259391
static List<File> _dartCoverageFilesToProcess(String absPath) {
260392
return Directory(absPath)
261393
.listSync(recursive: true)

lib/src/commands/dart/commands/dart_test_command.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class DartTestOptions {
1717
required this.excludeTags,
1818
required this.tags,
1919
required this.excludeFromCoverage,
20+
required this.collectCoverageFrom,
2021
required this.randomSeed,
2122
required this.optimizePerformance,
2223
required this.failFast,
@@ -36,6 +37,11 @@ class DartTestOptions {
3637
final excludeTags = argResults['exclude-tags'] as String?;
3738
final tags = argResults['tags'] as String?;
3839
final excludeFromCoverage = argResults['exclude-coverage'] as String?;
40+
final collectCoverageFromString =
41+
argResults['collect-coverage-from'] as String? ?? 'imports';
42+
final collectCoverageFrom = CoverageCollectionMode.fromString(
43+
collectCoverageFromString,
44+
);
3945
final randomOrderingSeed =
4046
argResults['test-randomize-ordering-seed'] as String?;
4147
final randomSeed = randomOrderingSeed == 'random'
@@ -55,6 +61,7 @@ class DartTestOptions {
5561
excludeTags: excludeTags,
5662
tags: tags,
5763
excludeFromCoverage: excludeFromCoverage,
64+
collectCoverageFrom: collectCoverageFrom,
5865
randomSeed: randomSeed,
5966
optimizePerformance: optimizePerformance,
6067
failFast: failFast,
@@ -83,6 +90,9 @@ class DartTestOptions {
8390
/// A glob which will be used to exclude files that match from the coverage.
8491
final String? excludeFromCoverage;
8592

93+
/// How to collect coverage.
94+
final CoverageCollectionMode collectCoverageFrom;
95+
8696
/// The seed to randomize the execution order of test cases within test files.
8797
final String? randomSeed;
8898

@@ -119,6 +129,7 @@ typedef DartTestCommandCall =
119129
bool optimizePerformance,
120130
double? minCoverage,
121131
String? excludeFromCoverage,
132+
CoverageCollectionMode collectCoverageFrom,
122133
String? randomSeed,
123134
bool? forceAnsi,
124135
List<String>? arguments,
@@ -188,6 +199,15 @@ class DartTestCommand extends Command<int> {
188199
'min-coverage',
189200
help: 'Whether to enforce a minimum coverage percentage.',
190201
)
202+
..addOption(
203+
'collect-coverage-from',
204+
help:
205+
'Whether to collect coverage from imported files only or all '
206+
'files.',
207+
allowed: ['imports', 'all'],
208+
defaultsTo: 'imports',
209+
valueHelp: 'imports|all',
210+
)
191211
..addOption(
192212
'test-randomize-ordering-seed',
193213
help:
@@ -271,6 +291,7 @@ This command should be run from the root of your Dart project.''');
271291
options.collectCoverage || options.minCoverage != null,
272292
minCoverage: options.minCoverage,
273293
excludeFromCoverage: options.excludeFromCoverage,
294+
collectCoverageFrom: options.collectCoverageFrom,
274295
randomSeed: options.randomSeed,
275296
forceAnsi: options.forceAnsi,
276297
arguments: [

lib/src/commands/test/test.dart

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ class FlutterTestOptions {
1717
required this.excludeTags,
1818
required this.tags,
1919
required this.excludeFromCoverage,
20+
required this.collectCoverageFrom,
2021
required this.randomSeed,
2122
required this.optimizePerformance,
2223
required this.updateGoldens,
@@ -38,6 +39,11 @@ class FlutterTestOptions {
3839
final excludeTags = argResults['exclude-tags'] as String?;
3940
final tags = argResults['tags'] as String?;
4041
final excludeFromCoverage = argResults['exclude-coverage'] as String?;
42+
final collectCoverageFromString =
43+
argResults['collect-coverage-from'] as String? ?? 'imports';
44+
final collectCoverageFrom = CoverageCollectionMode.fromString(
45+
collectCoverageFromString,
46+
);
4147
final randomOrderingSeed =
4248
argResults['test-randomize-ordering-seed'] as String?;
4349
final randomSeed = randomOrderingSeed == 'random'
@@ -60,6 +66,7 @@ class FlutterTestOptions {
6066
excludeTags: excludeTags,
6167
tags: tags,
6268
excludeFromCoverage: excludeFromCoverage,
69+
collectCoverageFrom: collectCoverageFrom,
6370
randomSeed: randomSeed,
6471
optimizePerformance: optimizePerformance,
6572
updateGoldens: updateGoldens,
@@ -90,6 +97,9 @@ class FlutterTestOptions {
9097
/// A glob which will be used to exclude files that match from the coverage.
9198
final String? excludeFromCoverage;
9299

100+
/// How to collect coverage.
101+
final CoverageCollectionMode collectCoverageFrom;
102+
93103
/// The seed to randomize the execution order of test cases within test files.
94104
final String? randomSeed;
95105

@@ -134,6 +144,7 @@ typedef FlutterTestCommand =
134144
bool optimizePerformance,
135145
double? minCoverage,
136146
String? excludeFromCoverage,
147+
CoverageCollectionMode collectCoverageFrom,
137148
String? randomSeed,
138149
bool? forceAnsi,
139150
List<String>? arguments,
@@ -202,6 +213,15 @@ class TestCommand extends Command<int> {
202213
'min-coverage',
203214
help: 'Whether to enforce a minimum coverage percentage.',
204215
)
216+
..addOption(
217+
'collect-coverage-from',
218+
help:
219+
'Whether to collect coverage from imported files only or all '
220+
'files.',
221+
allowed: ['imports', 'all'],
222+
defaultsTo: 'imports',
223+
valueHelp: 'imports|all',
224+
)
205225
..addOption(
206226
'test-randomize-ordering-seed',
207227
help:
@@ -311,6 +331,7 @@ This command should be run from the root of your Flutter project.''');
311331
options.collectCoverage || options.minCoverage != null,
312332
minCoverage: options.minCoverage,
313333
excludeFromCoverage: options.excludeFromCoverage,
334+
collectCoverageFrom: options.collectCoverageFrom,
314335
randomSeed: options.randomSeed,
315336
forceAnsi: options.forceAnsi,
316337
arguments: [

0 commit comments

Comments
 (0)