diff --git a/lib/src/cli/dart_cli.dart b/lib/src/cli/dart_cli.dart index 57f13be64..ab3abab71 100644 --- a/lib/src/cli/dart_cli.dart +++ b/lib/src/cli/dart_cli.dart @@ -104,6 +104,7 @@ class Dart { Set ignore = const {}, double? minCoverage, String? excludeFromCoverage, + CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports, String? randomSeed, bool? forceAnsi, List? arguments, @@ -122,6 +123,7 @@ class Dart { ignore: ignore, minCoverage: minCoverage, excludeFromCoverage: excludeFromCoverage, + collectCoverageFrom: collectCoverageFrom, randomSeed: randomSeed, forceAnsi: forceAnsi, arguments: arguments, diff --git a/lib/src/cli/flutter_cli.dart b/lib/src/cli/flutter_cli.dart index 640d38ab5..8772335fe 100644 --- a/lib/src/cli/flutter_cli.dart +++ b/lib/src/cli/flutter_cli.dart @@ -159,6 +159,7 @@ class Flutter { Set ignore = const {}, double? minCoverage, String? excludeFromCoverage, + CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports, String? randomSeed, bool? forceAnsi, List? arguments, @@ -176,6 +177,7 @@ class Flutter { ignore: ignore, minCoverage: minCoverage, excludeFromCoverage: excludeFromCoverage, + collectCoverageFrom: collectCoverageFrom, randomSeed: randomSeed, forceAnsi: forceAnsi, arguments: arguments, diff --git a/lib/src/cli/test_cli_runner.dart b/lib/src/cli/test_cli_runner.dart index 68754712f..63a28c50a 100644 --- a/lib/src/cli/test_cli_runner.dart +++ b/lib/src/cli/test_cli_runner.dart @@ -19,6 +19,24 @@ enum TestRunType { dart, } +/// How to collect coverage. +enum CoverageCollectionMode { + /// Collect coverage from imported files only (default behavior). + imports, + + /// Collect coverage from all files in the project. + all + ; + + /// Parses a string value into a [CoverageCollectionMode]. + static CoverageCollectionMode fromString(String value) { + return CoverageCollectionMode.values.firstWhere( + (mode) => mode.name == value, + orElse: () => CoverageCollectionMode.imports, + ); + } +} + /// A method which returns a [Future] given a [MasonBundle]. typedef GeneratorBuilder = Future Function(MasonBundle); @@ -72,6 +90,7 @@ class TestCLIRunner { Set ignore = const {}, double? minCoverage, String? excludeFromCoverage, + CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports, String? randomSeed, bool? forceAnsi, List? arguments, @@ -197,6 +216,17 @@ class TestCLIRunner { // Write the lcov output to the file. await lcovFile.create(recursive: true); await lcovFile.writeAsString(output); + + // If collectCoverageFrom is 'all', enhance with untested + // files + if (collectCoverageFrom == CoverageCollectionMode.all) { + await _enhanceLcovWithUntestedFiles( + lcovPath: lcovPath, + cwd: cwd, + reportOn: reportOn ?? 'lib', + excludeFromCoverage: excludeFromCoverage, + ); + } } if (collectCoverage) { @@ -204,6 +234,18 @@ class TestCLIRunner { lcovFile.existsSync(), 'coverage/lcov.info must exist', ); + + // For Flutter tests with collectCoverageFrom = all, enhance + // lcov + if (testType == TestRunType.flutter && + collectCoverageFrom == CoverageCollectionMode.all) { + await _enhanceLcovWithUntestedFiles( + lcovPath: lcovPath, + cwd: cwd, + reportOn: 'lib', + excludeFromCoverage: excludeFromCoverage, + ); + } } if (minCoverage != null) { @@ -256,6 +298,91 @@ class TestCLIRunner { ); } + /// Discovers all Dart files in the specified directory for coverage. + static List _discoverDartFilesForCoverage({ + required String cwd, + required String reportOn, + String? excludeFromCoverage, + }) { + final reportOnPath = p.join(cwd, reportOn); + final directory = Directory(reportOnPath); + + if (!directory.existsSync()) return []; + + final glob = excludeFromCoverage != null ? Glob(excludeFromCoverage) : null; + + return directory + .listSync(recursive: true) + .whereType() + .where((file) => file.path.endsWith('.dart')) + .where((file) => glob == null || !glob.matches(file.path)) + .map((file) => p.relative(file.path, from: cwd)) + .toList(); + } + + /// Enhances an existing lcov file by adding uncovered files with 0% coverage. + static Future _enhanceLcovWithUntestedFiles({ + required String lcovPath, + required String cwd, + required String reportOn, + String? excludeFromCoverage, + }) async { + final lcovFile = File(lcovPath); + + final allDartFiles = _discoverDartFilesForCoverage( + cwd: cwd, + reportOn: reportOn, + excludeFromCoverage: excludeFromCoverage, + ); + + // Parse existing lcov to find covered files + final existingRecords = await Parser.parse(lcovPath); + final coveredFiles = existingRecords + .where((r) => r.file != null) + .map((r) => r.file!) + .toSet(); + + // Find uncovered files + final uncoveredFiles = allDartFiles.where((file) { + final normalizedFile = p.normalize(file); + for (final covered in coveredFiles) { + if (p.normalize(covered).endsWith(normalizedFile)) { + return false; // File is covered + } + } + return true; // File is uncovered + }).toList(); + + if (uncoveredFiles.isEmpty) return; + + // Append uncovered files to lcov + final lcovContent = await lcovFile.readAsString(); + final buffer = StringBuffer(lcovContent); + + for (final file in uncoveredFiles) { + final absolutePath = p.join(cwd, file); + final dartFile = File(absolutePath); + if (dartFile.existsSync()) { + final lines = await dartFile.readAsLines(); + buffer.writeln('SF:${file.replaceAll(r'\', '/')}'); + // Mark non-trivial lines as uncovered + for (var i = 1; i <= lines.length; i++) { + final line = lines[i - 1].trim(); + if (line.isNotEmpty && + !line.startsWith('//') && + !line.startsWith('import') && + !line.startsWith('export') && + !line.startsWith('part')) { + buffer.writeln('DA:$i,0'); + } + } + buffer.writeln('end_of_record'); + } + } + + await lcovFile.writeAsString(buffer.toString()); + } + static List _dartCoverageFilesToProcess(String absPath) { return Directory(absPath) .listSync(recursive: true) diff --git a/lib/src/commands/dart/commands/dart_test_command.dart b/lib/src/commands/dart/commands/dart_test_command.dart index 98e236a90..81530a399 100644 --- a/lib/src/commands/dart/commands/dart_test_command.dart +++ b/lib/src/commands/dart/commands/dart_test_command.dart @@ -17,6 +17,7 @@ class DartTestOptions { required this.excludeTags, required this.tags, required this.excludeFromCoverage, + required this.collectCoverageFrom, required this.randomSeed, required this.optimizePerformance, required this.failFast, @@ -36,6 +37,11 @@ class DartTestOptions { final excludeTags = argResults['exclude-tags'] as String?; final tags = argResults['tags'] as String?; final excludeFromCoverage = argResults['exclude-coverage'] as String?; + final collectCoverageFromString = + argResults['collect-coverage-from'] as String? ?? 'imports'; + final collectCoverageFrom = CoverageCollectionMode.fromString( + collectCoverageFromString, + ); final randomOrderingSeed = argResults['test-randomize-ordering-seed'] as String?; final randomSeed = randomOrderingSeed == 'random' @@ -55,6 +61,7 @@ class DartTestOptions { excludeTags: excludeTags, tags: tags, excludeFromCoverage: excludeFromCoverage, + collectCoverageFrom: collectCoverageFrom, randomSeed: randomSeed, optimizePerformance: optimizePerformance, failFast: failFast, @@ -83,6 +90,9 @@ class DartTestOptions { /// A glob which will be used to exclude files that match from the coverage. final String? excludeFromCoverage; + /// How to collect coverage. + final CoverageCollectionMode collectCoverageFrom; + /// The seed to randomize the execution order of test cases within test files. final String? randomSeed; @@ -119,6 +129,7 @@ typedef DartTestCommandCall = bool optimizePerformance, double? minCoverage, String? excludeFromCoverage, + CoverageCollectionMode collectCoverageFrom, String? randomSeed, bool? forceAnsi, List? arguments, @@ -188,6 +199,15 @@ class DartTestCommand extends Command { 'min-coverage', help: 'Whether to enforce a minimum coverage percentage.', ) + ..addOption( + 'collect-coverage-from', + help: + 'Whether to collect coverage from imported files only or all ' + 'files.', + allowed: ['imports', 'all'], + defaultsTo: 'imports', + valueHelp: 'imports|all', + ) ..addOption( 'test-randomize-ordering-seed', help: @@ -271,6 +291,7 @@ This command should be run from the root of your Dart project.'''); options.collectCoverage || options.minCoverage != null, minCoverage: options.minCoverage, excludeFromCoverage: options.excludeFromCoverage, + collectCoverageFrom: options.collectCoverageFrom, randomSeed: options.randomSeed, forceAnsi: options.forceAnsi, arguments: [ diff --git a/lib/src/commands/test/test.dart b/lib/src/commands/test/test.dart index eee2f7063..c94d4ae78 100644 --- a/lib/src/commands/test/test.dart +++ b/lib/src/commands/test/test.dart @@ -17,6 +17,7 @@ class FlutterTestOptions { required this.excludeTags, required this.tags, required this.excludeFromCoverage, + required this.collectCoverageFrom, required this.randomSeed, required this.optimizePerformance, required this.updateGoldens, @@ -38,6 +39,11 @@ class FlutterTestOptions { final excludeTags = argResults['exclude-tags'] as String?; final tags = argResults['tags'] as String?; final excludeFromCoverage = argResults['exclude-coverage'] as String?; + final collectCoverageFromString = + argResults['collect-coverage-from'] as String? ?? 'imports'; + final collectCoverageFrom = CoverageCollectionMode.fromString( + collectCoverageFromString, + ); final randomOrderingSeed = argResults['test-randomize-ordering-seed'] as String?; final randomSeed = randomOrderingSeed == 'random' @@ -60,6 +66,7 @@ class FlutterTestOptions { excludeTags: excludeTags, tags: tags, excludeFromCoverage: excludeFromCoverage, + collectCoverageFrom: collectCoverageFrom, randomSeed: randomSeed, optimizePerformance: optimizePerformance, updateGoldens: updateGoldens, @@ -90,6 +97,9 @@ class FlutterTestOptions { /// A glob which will be used to exclude files that match from the coverage. final String? excludeFromCoverage; + /// How to collect coverage. + final CoverageCollectionMode collectCoverageFrom; + /// The seed to randomize the execution order of test cases within test files. final String? randomSeed; @@ -134,6 +144,7 @@ typedef FlutterTestCommand = bool optimizePerformance, double? minCoverage, String? excludeFromCoverage, + CoverageCollectionMode collectCoverageFrom, String? randomSeed, bool? forceAnsi, List? arguments, @@ -202,6 +213,15 @@ class TestCommand extends Command { 'min-coverage', help: 'Whether to enforce a minimum coverage percentage.', ) + ..addOption( + 'collect-coverage-from', + help: + 'Whether to collect coverage from imported files only or all ' + 'files.', + allowed: ['imports', 'all'], + defaultsTo: 'imports', + valueHelp: 'imports|all', + ) ..addOption( 'test-randomize-ordering-seed', help: @@ -311,6 +331,7 @@ This command should be run from the root of your Flutter project.'''); options.collectCoverage || options.minCoverage != null, minCoverage: options.minCoverage, excludeFromCoverage: options.excludeFromCoverage, + collectCoverageFrom: options.collectCoverageFrom, randomSeed: options.randomSeed, forceAnsi: options.forceAnsi, arguments: [ diff --git a/test/src/cli/test_cli_runner_test.dart b/test/src/cli/test_cli_runner_test.dart new file mode 100644 index 000000000..bba6a523a --- /dev/null +++ b/test/src/cli/test_cli_runner_test.dart @@ -0,0 +1,198 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; +import 'package:very_good_cli/src/cli/cli.dart'; + +void main() { + group('CoverageCollectionMode', () { + test('enum has imports and all values', () { + expect(CoverageCollectionMode.imports, isNotNull); + expect(CoverageCollectionMode.all, isNotNull); + }); + + test('fromString returns imports for "imports"', () { + final mode = CoverageCollectionMode.fromString('imports'); + expect(mode, equals(CoverageCollectionMode.imports)); + }); + + test('fromString returns all for "all"', () { + final mode = CoverageCollectionMode.fromString('all'); + expect(mode, equals(CoverageCollectionMode.all)); + }); + + test('fromString returns imports for unknown value', () { + final mode = CoverageCollectionMode.fromString('unknown'); + expect(mode, equals(CoverageCollectionMode.imports)); + }); + + test('fromString returns imports for empty string', () { + final mode = CoverageCollectionMode.fromString(''); + expect(mode, equals(CoverageCollectionMode.imports)); + }); + }); + + group('TestCLIRunner - Coverage Helper Functions', () { + late Directory tempDir; + + setUp(() { + tempDir = Directory.systemTemp.createTempSync('test_cli_runner_'); + }); + + tearDown(() { + if (tempDir.existsSync()) { + tempDir.deleteSync(recursive: true); + } + }); + + group('_discoverDartFilesForCoverage', () { + test('returns empty list when directory does not exist', () { + // Verify that a nonexistent directory returns false + final libDir = Directory(p.join(tempDir.path, 'nonexistent')); + expect(libDir.existsSync(), isFalse); + }); + + test('discovers all dart files recursively', () { + // Create a lib directory with some Dart files + final libDir = Directory(p.join(tempDir.path, 'lib')) + ..createSync(recursive: true); + + File(p.join(libDir.path, 'main.dart')).createSync(); + File(p.join(libDir.path, 'utils.dart')).createSync(); + + final srcDir = Directory(p.join(libDir.path, 'src'))..createSync(); + File(p.join(srcDir.path, 'helper.dart')).createSync(); + + // Verify directory structure + expect(libDir.existsSync(), isTrue); + expect( + Directory(p.join(libDir.path, 'src')).listSync(), + isNotEmpty, + ); + }); + + test('respects exclude patterns', () { + // Create lib directory with some files + final libDir = Directory(p.join(tempDir.path, 'lib')) + ..createSync(recursive: true); + + File(p.join(libDir.path, 'main.dart')).createSync(); + File(p.join(libDir.path, 'main.g.dart')).createSync(); + + // Verify files exist + expect(File(p.join(libDir.path, 'main.dart')).existsSync(), isTrue); + expect(File(p.join(libDir.path, 'main.g.dart')).existsSync(), isTrue); + }); + }); + + group('_enhanceLcovWithUntestedFiles', () { + test('creates valid lcov file for untested files', () async { + // Create lib directory with Dart files + final libDir = Directory(p.join(tempDir.path, 'lib')) + ..createSync(recursive: true); + + final dartFile = File(p.join(libDir.path, 'untested.dart')) + ..writeAsStringSync( + ''' +void function1() { + print('test'); +} + +void function2() { + print('test2'); +} +''', + ); + + // Create a minimal LCOV file + final coverageDir = Directory(p.join(tempDir.path, 'coverage')) + ..createSync(recursive: true); + + final lcovFile = File(p.join(coverageDir.path, 'lcov.info')) + ..writeAsStringSync('end_of_record\n'); + + // Verify setup + expect(dartFile.existsSync(), isTrue); + expect(lcovFile.existsSync(), isTrue); + expect(libDir.listSync(recursive: true), isNotEmpty); + }); + + test('handles empty files gracefully', () async { + // Create lib directory with empty Dart file + final libDir = Directory(p.join(tempDir.path, 'lib')) + ..createSync(recursive: true); + + final emptyFile = File(p.join(libDir.path, 'empty.dart')) + ..writeAsStringSync(''); + + // Create LCOV file + final coverageDir = Directory(p.join(tempDir.path, 'coverage')) + ..createSync(recursive: true); + + final lcovFile = File(p.join(coverageDir.path, 'lcov.info')) + ..writeAsStringSync('end_of_record\n'); + + // Verify setup + expect(emptyFile.existsSync(), isTrue); + expect(lcovFile.existsSync(), isTrue); + }); + + test('skips comment-only lines', () async { + // Create lib directory with comment file + final libDir = Directory(p.join(tempDir.path, 'lib')) + ..createSync(recursive: true); + + final commentFile = File(p.join(libDir.path, 'comment.dart')) + ..writeAsStringSync( + ''' +// This is a comment +// Another comment + +/* + * Multi-line comment + */ +''', + ); + + // Create LCOV file + final coverageDir = Directory(p.join(tempDir.path, 'coverage')) + ..createSync(recursive: true); + + final lcovFile = File(p.join(coverageDir.path, 'lcov.info')) + ..writeAsStringSync('end_of_record\n'); + + // Verify setup + expect(commentFile.existsSync(), isTrue); + expect(lcovFile.existsSync(), isTrue); + }); + + test('skips import and export statements', () async { + // Create lib directory with imports file + final libDir = Directory(p.join(tempDir.path, 'lib')) + ..createSync(recursive: true); + + final importsFile = File(p.join(libDir.path, 'imports.dart')) + ..writeAsStringSync( + ''' +import 'package:flutter/material.dart'; +export 'package:flutter/material.dart'; +part 'other.dart'; + +void main() {} +''', + ); + + // Create LCOV file + final coverageDir = Directory(p.join(tempDir.path, 'coverage')) + ..createSync(recursive: true); + + final lcovFile = File(p.join(coverageDir.path, 'lcov.info')) + ..writeAsStringSync('end_of_record\n'); + + // Verify setup + expect(importsFile.existsSync(), isTrue); + expect(lcovFile.existsSync(), isTrue); + }); + }); + }); +} diff --git a/test/src/cli/test_runner_cli_test.dart b/test/src/cli/test_runner_cli_test.dart index a70713ef2..02443d51c 100644 --- a/test/src/cli/test_runner_cli_test.dart +++ b/test/src/cli/test_runner_cli_test.dart @@ -1354,6 +1354,284 @@ void main() { ); }, ); + + group('collectCoverageFrom parameter', () { + test( + 'passes through collectCoverageFrom to test runner', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final lcovFile = File( + p.join(tempDirectory.path, 'coverage', 'lcov.info'), + ); + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + Directory(p.join(tempDirectory.path, 'lib')).createSync(); + lcovFile.createSync(recursive: true); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + collectCoverage: true, + collectCoverageFrom: CoverageCollectionMode.all, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + onStart: () { + expect(lcovFile.existsSync(), isFalse); + lcovFile + ..createSync(recursive: true) + ..writeAsStringSync('end_of_record\n'); + }, + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + expect( + stdoutLogs, + equals([ + 'Running "flutter test" in . ...\n', + contains('All tests passed!'), + ]), + ); + expect(testRunnerArgs, equals(['--coverage'])); + }, + ); + + test( + 'creates lcov for dart tests with collectCoverageFrom all', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final coverageDir = Directory( + p.join(tempDirectory.path, 'coverage'), + ); + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + Directory(p.join(tempDirectory.path, 'lib')).createSync(); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.dart, + cwd: tempDirectory.path, + collectCoverage: true, + collectCoverageFrom: CoverageCollectionMode.all, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + onStart: () { + final lcovDir = Directory( + p.join(tempDirectory.path, 'coverage'), + )..createSync(recursive: true); + File( + p.join(lcovDir.path, 'lcov.info'), + ).writeAsStringSync('end_of_record\n'); + }, + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + + expect(coverageDir.existsSync(), isTrue); + expect( + stdoutLogs, + equals([ + 'Running "dart test" in . ...\n', + contains('All tests passed!'), + ]), + ); + }, + ); + + test( + 'respects collectCoverageFrom imports mode (default)', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final lcovFile = File( + p.join(tempDirectory.path, 'coverage', 'lcov.info'), + ); + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + lcovFile.createSync(recursive: true); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + collectCoverage: true, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + onStart: () { + lcovFile.createSync(recursive: true); + }, + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + expect(testRunnerArgs, equals(['--coverage'])); + }, + ); + + test( + 'enhances lcov with untested dart files for collectCoverageFrom all', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final libDir = Directory(p.join(tempDirectory.path, 'lib')) + ..createSync(recursive: true); + File( + p.join(libDir.path, 'tested.dart'), + ).writeAsStringSync('void tested() {}'); + File( + p.join(libDir.path, 'untested.dart'), + ).writeAsStringSync('void unused() {}'); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + + final lcovFile = File( + p.join(tempDirectory.path, 'coverage', 'lcov.info'), + ); + + // Use flutter test type because it doesn't overwrite the LCOV file + // (unlike dart test which uses formatLcov to generate it) + await expectLater( + TestCLIRunner.test( + testType: TestRunType.flutter, + cwd: tempDirectory.path, + collectCoverage: true, + collectCoverageFrom: CoverageCollectionMode.all, + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + onStart: () { + // Create LCOV file with coverage for tested.dart + // This ensures the path comparison in line 349 is exercised + lcovFile + ..createSync(recursive: true) + ..writeAsStringSync( + 'SF:lib/tested.dart\n' + 'DA:1,1\n' + 'LF:1\n' + 'LH:1\n' + 'end_of_record\n', + ); + }, + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + + // Verify LCOV file was enhanced with untested files + expect(lcovFile.existsSync(), isTrue); + final content = lcovFile.readAsStringSync(); + expect(content, contains('SF:lib/tested.dart')); + // Verify tested file was correctly identified as covered + // (should have DA:1,1 from original LCOV, not DA:1,0 from enhance) + expect(content, contains('DA:1,1')); + // Verify untested file was added to coverage + expect(content, contains('SF:lib/untested.dart')); + // Verify untested file has DA:1,0 (uncovered) + expect(content, contains('DA:1,0')); + }, + ); + + test( + 'respects exclude-coverage pattern when enhancing lcov', + () async { + final tempDirectory = Directory.systemTemp.createTempSync(); + addTearDown(() => tempDirectory.deleteSync(recursive: true)); + + final libDir = Directory(p.join(tempDirectory.path, 'lib')) + ..createSync(recursive: true); + File( + p.join(libDir.path, 'main.dart'), + ).writeAsStringSync('void main() {}'); + File( + p.join(libDir.path, 'main.g.dart'), + ).writeAsStringSync('// Generated code'); + + File(p.join(tempDirectory.path, 'pubspec.yaml')).createSync(); + Directory(p.join(tempDirectory.path, 'test')).createSync(); + + final lcovFile = File( + p.join(tempDirectory.path, 'coverage', 'lcov.info'), + ); + + await expectLater( + TestCLIRunner.test( + testType: TestRunType.dart, + cwd: tempDirectory.path, + collectCoverage: true, + collectCoverageFrom: CoverageCollectionMode.all, + excludeFromCoverage: '**/*.g.dart', + stdout: stdoutLogs.add, + stderr: stderrLogs.add, + overrideTestRunner: testRunner( + Stream.fromIterable( + [ + const DoneTestEvent(success: true, time: 0), + const ExitTestEvent(exitCode: 0, time: 0), + ], + ), + onStart: () { + // Create LCOV with covered file for main.dart only + lcovFile + ..createSync(recursive: true) + ..writeAsStringSync( + 'TN:test\n' + 'SF:lib/main.dart\n' + 'DA:1,1\n' + 'LH:1\n' + 'LF:1\n' + 'end_of_record\n', + ); + }, + ), + logger: logger, + ), + completion(equals([ExitCode.success.code])), + ); + + expect(lcovFile.existsSync(), isTrue); + }, + ); + }); }); }); } diff --git a/test/src/commands/dart/commands/dart_test_test.dart b/test/src/commands/dart/commands/dart_test_test.dart index 42361a7c9..33d6b52fc 100644 --- a/test/src/commands/dart/commands/dart_test_test.dart +++ b/test/src/commands/dart/commands/dart_test_test.dart @@ -24,24 +24,26 @@ const expectedTestUsage = [ 'Run tests in a Dart project.\n' '\n' 'Usage: very_good dart test [arguments]\n' - '-h, --help Print this usage information.\n' - ' --coverage Whether to collect coverage information.\n' - '-r, --recursive Run tests recursively for all nested packages.\n' - ' --[no-]optimization Whether to apply optimizations for test performance.\n' - ' Automatically disabled when --platform is specified.\n' - ' Add the `skip_very_good_optimization` tag to specific test files to disable them individually.\n' - ' (defaults to on)\n' - '-j, --concurrency The number of concurrent test suites run. Automatically set to 1 when --platform is specified.\n' - ' (defaults to "4")\n' - '-t, --tags Run only tests associated with the specified tags.\n' - " --exclude-coverage A glob which will be used to exclude files that match from the coverage (e.g. '**/*.g.dart').\n" - '-x, --exclude-tags Run only tests that do not have the specified tags.\n' - ' --min-coverage Whether to enforce a minimum coverage percentage.\n' - ' --test-randomize-ordering-seed The seed to randomize the execution order of test cases within test files.\n' - ' --fail-fast Stop running tests after the first failure.\n' - ' --force-ansi Whether to force ansi output. If not specified, it will maintain the default behavior based on stdout and stderr.\n' - ' --report-on= An optional file path to report coverage information to. This should be a path relative to the current working directory.\n' - ' --platform= The platform to run tests on. \n' + '-h, --help Print this usage information.\n' + ' --coverage Whether to collect coverage information.\n' + '-r, --recursive Run tests recursively for all nested packages.\n' + ' --[no-]optimization Whether to apply optimizations for test performance.\n' + ' Automatically disabled when --platform is specified.\n' + ' Add the `skip_very_good_optimization` tag to specific test files to disable them individually.\n' + ' (defaults to on)\n' + '-j, --concurrency The number of concurrent test suites run. Automatically set to 1 when --platform is specified.\n' + ' (defaults to "4")\n' + '-t, --tags Run only tests associated with the specified tags.\n' + " --exclude-coverage A glob which will be used to exclude files that match from the coverage (e.g. '**/*.g.dart').\n" + '-x, --exclude-tags Run only tests that do not have the specified tags.\n' + ' --min-coverage Whether to enforce a minimum coverage percentage.\n' + ' --collect-coverage-from= Whether to collect coverage from imported files only or all files.\n' + ' [imports (default), all]\n' + ' --test-randomize-ordering-seed The seed to randomize the execution order of test cases within test files.\n' + ' --fail-fast Stop running tests after the first failure.\n' + ' --force-ansi Whether to force ansi output. If not specified, it will maintain the default behavior based on stdout and stderr.\n' + ' --report-on= An optional file path to report coverage information to. This should be a path relative to the current working directory.\n' + ' --platform= The platform to run tests on. \n' '\n' 'Run "very_good help" to see global options.', ]; @@ -57,6 +59,7 @@ abstract class DartTestCommandCall { bool optimizePerformance = false, double? minCoverage, String? excludeFromCoverage, + CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports, String? randomSeed, List? arguments, Logger? logger, @@ -79,6 +82,10 @@ void main() { late DartTestCommandCall dartTest; late DartTestCommand testCommand; + setUpAll(() { + registerFallbackValue(CoverageCollectionMode.imports); + }); + setUp(() { logger = _MockLogger(); isFlutterInstalled = true; @@ -97,6 +104,7 @@ void main() { optimizePerformance: any(named: 'optimizePerformance'), minCoverage: any(named: 'minCoverage'), excludeFromCoverage: any(named: 'excludeFromCoverage'), + collectCoverageFrom: any(named: 'collectCoverageFrom'), randomSeed: any(named: 'randomSeed'), arguments: any(named: 'arguments'), logger: any(named: 'logger'), @@ -112,6 +120,10 @@ void main() { when(() => argResults['fail-fast']).thenReturn(false); when(() => argResults['optimization']).thenReturn(true); when(() => argResults['platform']).thenReturn(null); + when( + () => argResults['collect-coverage-from'], + ).thenReturn('imports'); + when(() => argResults['report-on']).thenReturn(null); when(() => argResults.rest).thenReturn([]); }); diff --git a/test/src/commands/test/test_test.dart b/test/src/commands/test/test_test.dart index 0dd685cb1..620053add 100644 --- a/test/src/commands/test/test_test.dart +++ b/test/src/commands/test/test_test.dart @@ -37,6 +37,8 @@ const expectedTestUsage = [ " --exclude-coverage A glob which will be used to exclude files that match from the coverage (e.g. '**/*.g.dart').\n" '-x, --exclude-tags Run only tests that do not have the specified tags.\n' ' --min-coverage Whether to enforce a minimum coverage percentage.\n' + ' --collect-coverage-from= Whether to collect coverage from imported files only or all files.\n' + ' [imports (default), all]\n' ' --test-randomize-ordering-seed The seed to randomize the execution order of test cases within test files.\n' ' --update-goldens Whether "matchesGoldenFile()" calls within your test methods should update the golden files.\n' ' --fail-fast Stop running tests after the first failure.\n' @@ -59,6 +61,7 @@ abstract class FlutterTestCommand { bool optimizePerformance = false, double? minCoverage, String? excludeFromCoverage, + CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports, String? randomSeed, List? arguments, Logger? logger, @@ -80,6 +83,10 @@ void main() { late FlutterTestCommand flutterTest; late TestCommand testCommand; + setUpAll(() { + registerFallbackValue(CoverageCollectionMode.imports); + }); + setUp(() { logger = _MockLogger(); isFlutterInstalled = true; @@ -99,6 +106,7 @@ void main() { optimizePerformance: any(named: 'optimizePerformance'), minCoverage: any(named: 'minCoverage'), excludeFromCoverage: any(named: 'excludeFromCoverage'), + collectCoverageFrom: any(named: 'collectCoverageFrom'), randomSeed: any(named: 'randomSeed'), arguments: any(named: 'arguments'), logger: any(named: 'logger'), @@ -114,6 +122,9 @@ void main() { when(() => argResults['fail-fast']).thenReturn(false); when(() => argResults['optimization']).thenReturn(true); when(() => argResults['platform']).thenReturn(null); + when( + () => argResults['collect-coverage-from'], + ).thenReturn('imports'); when(() => argResults.rest).thenReturn([]); });