Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/src/cli/dart_cli.dart
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ class Dart {
Set<String> ignore = const {},
double? minCoverage,
String? excludeFromCoverage,
CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports,
String? randomSeed,
bool? forceAnsi,
List<String>? arguments,
Expand All @@ -122,6 +123,7 @@ class Dart {
ignore: ignore,
minCoverage: minCoverage,
excludeFromCoverage: excludeFromCoverage,
collectCoverageFrom: collectCoverageFrom,
randomSeed: randomSeed,
forceAnsi: forceAnsi,
arguments: arguments,
Expand Down
2 changes: 2 additions & 0 deletions lib/src/cli/flutter_cli.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ class Flutter {
Set<String> ignore = const {},
double? minCoverage,
String? excludeFromCoverage,
CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports,
String? randomSeed,
bool? forceAnsi,
List<String>? arguments,
Expand All @@ -176,6 +177,7 @@ class Flutter {
ignore: ignore,
minCoverage: minCoverage,
excludeFromCoverage: excludeFromCoverage,
collectCoverageFrom: collectCoverageFrom,
randomSeed: randomSeed,
forceAnsi: forceAnsi,
arguments: arguments,
Expand Down
127 changes: 127 additions & 0 deletions lib/src/cli/test_cli_runner.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<MasonGenerator>] given a [MasonBundle].
typedef GeneratorBuilder = Future<MasonGenerator> Function(MasonBundle);

Expand Down Expand Up @@ -72,6 +90,7 @@ class TestCLIRunner {
Set<String> ignore = const {},
double? minCoverage,
String? excludeFromCoverage,
CoverageCollectionMode collectCoverageFrom = CoverageCollectionMode.imports,
String? randomSeed,
bool? forceAnsi,
List<String>? arguments,
Expand Down Expand Up @@ -197,13 +216,36 @@ 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) {
assert(
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) {
Expand Down Expand Up @@ -256,6 +298,91 @@ class TestCLIRunner {
);
}

/// Discovers all Dart files in the specified directory for coverage.
static List<String> _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<File>()
.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<void> _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<File> _dartCoverageFilesToProcess(String absPath) {
return Directory(absPath)
.listSync(recursive: true)
Expand Down
21 changes: 21 additions & 0 deletions lib/src/commands/dart/commands/dart_test_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand All @@ -55,6 +61,7 @@ class DartTestOptions {
excludeTags: excludeTags,
tags: tags,
excludeFromCoverage: excludeFromCoverage,
collectCoverageFrom: collectCoverageFrom,
randomSeed: randomSeed,
optimizePerformance: optimizePerformance,
failFast: failFast,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -119,6 +129,7 @@ typedef DartTestCommandCall =
bool optimizePerformance,
double? minCoverage,
String? excludeFromCoverage,
CoverageCollectionMode collectCoverageFrom,
String? randomSeed,
bool? forceAnsi,
List<String>? arguments,
Expand Down Expand Up @@ -188,6 +199,15 @@ class DartTestCommand extends Command<int> {
'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:
Expand Down Expand Up @@ -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: [
Expand Down
21 changes: 21 additions & 0 deletions lib/src/commands/test/test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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'
Expand All @@ -60,6 +66,7 @@ class FlutterTestOptions {
excludeTags: excludeTags,
tags: tags,
excludeFromCoverage: excludeFromCoverage,
collectCoverageFrom: collectCoverageFrom,
randomSeed: randomSeed,
optimizePerformance: optimizePerformance,
updateGoldens: updateGoldens,
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -134,6 +144,7 @@ typedef FlutterTestCommand =
bool optimizePerformance,
double? minCoverage,
String? excludeFromCoverage,
CoverageCollectionMode collectCoverageFrom,
String? randomSeed,
bool? forceAnsi,
List<String>? arguments,
Expand Down Expand Up @@ -202,6 +213,15 @@ class TestCommand extends Command<int> {
'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:
Expand Down Expand Up @@ -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: [
Expand Down
Loading