@@ -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] .
2341typedef 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 )
0 commit comments