| // 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'); |
| } |