blob: 04c8050fbaf0373b16cdaeb6708bea6d3abfad06 [file]
// Copyright 2013 The Flutter Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:io' as io;
import 'package:file/file.dart';
import 'package:meta/meta.dart';
import 'package:yaml/yaml.dart';
import 'common/core.dart';
import 'common/output_utils.dart';
import 'common/package_looping_command.dart';
import 'common/repository_package.dart';
/// A command to run code coverage checks on changed packages.
class CoverageCheckCommand extends PackageLoopingCommand {
/// Creates a coverage check command instance.
CoverageCheckCommand(super.packagesDir, {super.processRunner, super.platform, super.gitDir});
@override
final String name = 'coverage-check';
@override
final String description =
'Checks that code coverage meets the specified minimums '
'for opted-in packages as specified in `script/configs/custom_coverage_minimums.yaml`.';
// TODO(camsim99): Determine how to ensure test/ subdirectories are covered by this check:
// https://github.com/flutter/flutter/issues/188880.
@override
PackageLoopingType get packageLoopingType => PackageLoopingType.includeAllSubpackages;
final Map<String, double> _customMinimums = <String, double>{};
@override
Future<void> initializeRun() async {
final File minimumsFile = packagesDir.parent
.childDirectory('script')
.childDirectory('configs')
.childFile('custom_coverage_minimums.yaml');
if (!minimumsFile.existsSync()) {
printError('The custom_coverage_minimums.yaml file is missing.');
throw ToolExit(1);
}
final Object? yaml = loadYaml(minimumsFile.readAsStringSync());
if (yaml is YamlMap) {
final Object? packageMap = yaml['custom_coverage_minimums'];
if (packageMap is YamlMap) {
for (final MapEntry<dynamic, dynamic> entry in packageMap.entries) {
final Object? key = entry.key;
final Object? value = entry.value;
if (key is String && value is num) {
_customMinimums[key] = value.toDouble();
}
}
}
}
}
@override
Future<PackageResult> runForPackage(RepositoryPackage package) async {
// Only run for first-party packages.
final List<String> pathComponents = package.directory.fileSystem.path.split(package.path);
if (pathComponents.contains('third_party')) {
return PackageResult.skip('Not a first-party package.');
}
if (!package.testDirectory.existsSync()) {
return PackageResult.skip('No test/ directory.');
}
final String packageName = package.directory.basename;
if (!_customMinimums.containsKey(packageName)) {
return PackageResult.skip('Package not opted into coverage checks.');
}
// Collect code coverage for the package.
double? currentCoverage;
try {
currentCoverage = await _runCoverageAndParse(package);
} on NoLinesFoundException {
return PackageResult.fail(<String>[
'No instrumented Dart lines were found in the coverage report for $packageName.',
'If this package contains no code to cover, please remove it from ',
'script/configs/custom_coverage_minimums.yaml.',
]);
}
if (currentCoverage == null) {
return PackageResult.fail(<String>['Failed to run tests or parse coverage']);
}
final double requiredCoverage = _customMinimums[packageName]!;
if (currentCoverage < requiredCoverage) {
return PackageResult.fail(<String>[
'Code coverage for $packageName is ${currentCoverage.toStringAsFixed(1)}%, which is below the required ${requiredCoverage.toStringAsFixed(1)}%.',
]);
}
return PackageResult.success();
}
Future<double?> _runCoverageAndParse(RepositoryPackage package) async {
final Directory coverageDir = package.directory.childDirectory('coverage');
try {
final io.ProcessResult result = await processRunner.run('flutter', <String>[
'test',
'--coverage',
], workingDir: package.directory);
if (result.exitCode != 0) {
print('Test failed for ${package.directory.basename}:\n${result.stdout}\n${result.stderr}');
return null;
}
final File lcovFile = coverageDir.childFile('lcov.info');
if (!lcovFile.existsSync()) {
print('Coverage file not found at ${lcovFile.path}.');
return null;
}
return calculateCoverage(lcovFile);
} finally {
if (coverageDir.existsSync()) {
coverageDir.deleteSync(recursive: true);
}
}
}
/// Calculates code coverage for non-generated code files by finding
/// the percentage of covered lines of code over the total lines of code.
@visibleForTesting
double calculateCoverage(File lcovFile) {
var linesHit = 0;
var linesFound = 0;
var skipCurrentFile = false;
final List<String> lines = lcovFile.readAsLinesSync();
for (final line in lines) {
if (line.startsWith('SF:')) {
final String fileName = line.substring(3);
skipCurrentFile = _isGeneratedFile(fileName);
}
if (!skipCurrentFile) {
if (line.startsWith('LH:')) {
linesHit += int.parse(line.substring(3));
} else if (line.startsWith('LF:')) {
linesFound += int.parse(line.substring(3));
}
}
}
if (linesFound == 0) {
throw const NoLinesFoundException();
}
return (linesHit / linesFound) * 100.0;
}
}
/// Exception thrown when no instrumented lines are found in the coverage report.
class NoLinesFoundException implements Exception {
/// Creates a new [NoLinesFoundException].
const NoLinesFoundException();
}
bool _isGeneratedFile(String fileName) {
return fileName.endsWith('.g.dart') ||
fileName.endsWith('.pb.dart') ||
fileName.endsWith('.mocks.dart');
}