| // Copyright 2014 The Flutter Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import 'package:coverage/coverage.dart' as coverage; |
| import 'package:meta/meta.dart'; |
| |
| import '../base/file_system.dart'; |
| import '../base/io.dart'; |
| import '../base/process.dart'; |
| import '../globals.dart' as globals; |
| import '../vmservice.dart'; |
| |
| import 'test_device.dart'; |
| import 'test_time_recorder.dart'; |
| import 'watcher.dart'; |
| |
| /// A class that collects code coverage data during test runs. |
| class CoverageCollector extends TestWatcher { |
| CoverageCollector({ |
| this.libraryNames, this.verbose = true, required this.packagesPath, |
| this.resolver, this.testTimeRecorder, this.branchCoverage = false}); |
| |
| /// True when log messages should be emitted. |
| final bool verbose; |
| |
| /// The path to the package_config.json of the package for which code |
| /// coverage is computed. |
| final String packagesPath; |
| |
| /// Map of file path to coverage hit map for that file. |
| Map<String, coverage.HitMap>? _globalHitmap; |
| |
| /// The names of the libraries to gather coverage for. If null, all libraries |
| /// will be accepted. |
| Set<String>? libraryNames; |
| |
| final coverage.Resolver? resolver; |
| final Map<String, List<List<int>>?> _ignoredLinesInFilesCache = <String, List<List<int>>?>{}; |
| |
| final TestTimeRecorder? testTimeRecorder; |
| |
| /// Whether to collect branch coverage information. |
| bool branchCoverage; |
| |
| static Future<coverage.Resolver> getResolver(String? packagesPath) async { |
| try { |
| return await coverage.Resolver.create(packagesPath: packagesPath); |
| } on FileSystemException { |
| // When given a bad packages path (as for instance done in some tests) |
| // just ignore it and return one without a packages path. |
| return coverage.Resolver.create(); |
| } |
| } |
| |
| @override |
| Future<void> handleFinishedTest(TestDevice testDevice) async { |
| _logMessage('Starting coverage collection'); |
| await collectCoverage(testDevice); |
| } |
| |
| void _logMessage(String line, { bool error = false }) { |
| if (!verbose) { |
| return; |
| } |
| if (error) { |
| globals.printError(line); |
| } else { |
| globals.printTrace(line); |
| } |
| } |
| |
| void _addHitmap(Map<String, coverage.HitMap> hitmap) { |
| final Stopwatch? stopwatch = testTimeRecorder?.start(TestTimePhases.CoverageAddHitmap); |
| if (_globalHitmap == null) { |
| _globalHitmap = hitmap; |
| } else { |
| _globalHitmap!.merge(hitmap); |
| } |
| testTimeRecorder?.stop(TestTimePhases.CoverageAddHitmap, stopwatch!); |
| } |
| |
| /// The directory of the package for which coverage is being collected. |
| String get packageDirectory { |
| // The coverage package expects the directory of the package itself, and |
| // uses that to locate the package_info.json file, which it treats as a |
| // private implementation detail. In general, the package_info.json file is |
| // located in `.dart_tool/package_info.json` relative to the package |
| // directory, so we return the grandparent directory of that file. |
| // |
| // This may not be a safe assumption in non-standard environments, such as |
| // when building under build systems such as Bazel. In those cases, this |
| // getter should be overridden. |
| return globals.fs.directory(globals.fs.file(packagesPath).dirname).dirname; |
| } |
| |
| /// Collects coverage for an isolate using the given `port`. |
| /// |
| /// This should be called when the code whose coverage data is being collected |
| /// has been run to completion so that all coverage data has been recorded. |
| /// |
| /// The returned [Future] completes when the coverage is collected. |
| Future<void> collectCoverageIsolate(Uri observatoryUri) async { |
| _logMessage('collecting coverage data from $observatoryUri...'); |
| final Map<String, dynamic> data = await collect( |
| observatoryUri, libraryNames, branchCoverage: branchCoverage); |
| |
| _logMessage('($observatoryUri): collected coverage data; merging...'); |
| _addHitmap(await coverage.HitMap.parseJson( |
| data['coverage'] as List<Map<String, dynamic>>, |
| packagePath: packageDirectory, |
| checkIgnoredLines: true, |
| )); |
| _logMessage('($observatoryUri): done merging coverage data into global coverage map.'); |
| } |
| |
| /// Collects coverage for the given [Process] using the given `port`. |
| /// |
| /// This should be called when the code whose coverage data is being collected |
| /// has been run to completion so that all coverage data has been recorded. |
| /// |
| /// The returned [Future] completes when the coverage is collected. |
| Future<void> collectCoverage(TestDevice testDevice, { |
| @visibleForTesting FlutterVmService? serviceOverride, |
| }) async { |
| final Stopwatch? totalTestTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.CoverageTotal); |
| |
| late Map<String, dynamic> data; |
| |
| final Stopwatch? collectTestTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.CoverageCollect); |
| |
| final Future<void> processComplete = testDevice.finished.then( |
| (Object? obj) => obj, |
| onError: (Object error, StackTrace stackTrace) { |
| if (error is TestDeviceException) { |
| throw Exception( |
| 'Failed to collect coverage, test device terminated prematurely with ' |
| 'error: ${error.message}.\n$stackTrace'); |
| } |
| return Future<Object?>.error(error, stackTrace); |
| } |
| ); |
| |
| final Future<void> collectionComplete = testDevice.observatoryUri |
| .then((Uri? observatoryUri) { |
| _logMessage('collecting coverage data from $testDevice at $observatoryUri...'); |
| return collect( |
| observatoryUri!, libraryNames, serviceOverride: serviceOverride, |
| branchCoverage: branchCoverage) |
| .then<void>((Map<String, dynamic> result) { |
| _logMessage('Collected coverage data.'); |
| data = result; |
| }); |
| }); |
| |
| await Future.any<void>(<Future<void>>[ processComplete, collectionComplete ]); |
| |
| testTimeRecorder?.stop(TestTimePhases.CoverageCollect, collectTestTimeRecorderStopwatch!); |
| |
| _logMessage('Merging coverage data...'); |
| final Stopwatch? parseTestTimeRecorderStopwatch = testTimeRecorder?.start(TestTimePhases.CoverageParseJson); |
| |
| final Map<String, coverage.HitMap> hitmap = coverage.HitMap.parseJsonSync( |
| data['coverage'] as List<Map<String, dynamic>>, |
| checkIgnoredLines: true, |
| resolver: resolver ?? await CoverageCollector.getResolver(packageDirectory), |
| ignoredLinesInFilesCache: _ignoredLinesInFilesCache); |
| testTimeRecorder?.stop(TestTimePhases.CoverageParseJson, parseTestTimeRecorderStopwatch!); |
| |
| _addHitmap(hitmap); |
| _logMessage('Done merging coverage data into global coverage map.'); |
| testTimeRecorder?.stop(TestTimePhases.CoverageTotal, totalTestTimeRecorderStopwatch!); |
| } |
| |
| /// Returns formatted coverage data once all coverage data has been collected. |
| /// |
| /// This will not start any collection tasks. It us up to the caller of to |
| /// call [collectCoverage] for each process first. |
| Future<String?> finalizeCoverage({ |
| String Function(Map<String, coverage.HitMap> hitmap)? formatter, |
| coverage.Resolver? resolver, |
| Directory? coverageDirectory, |
| }) async { |
| if (_globalHitmap == null) { |
| return null; |
| } |
| if (formatter == null) { |
| final coverage.Resolver usedResolver = resolver ?? this.resolver ?? await CoverageCollector.getResolver(packagesPath); |
| final String packagePath = globals.fs.currentDirectory.path; |
| final List<String> reportOn = coverageDirectory == null |
| ? <String>[globals.fs.path.join(packagePath, 'lib')] |
| : <String>[coverageDirectory.path]; |
| formatter = (Map<String, coverage.HitMap> hitmap) => hitmap |
| .formatLcov(usedResolver, reportOn: reportOn, basePath: packagePath); |
| } |
| final String result = formatter(_globalHitmap!); |
| _globalHitmap = null; |
| return result; |
| } |
| |
| Future<bool> collectCoverageData(String? coveragePath, { bool mergeCoverageData = false, Directory? coverageDirectory }) async { |
| final String? coverageData = await finalizeCoverage( |
| coverageDirectory: coverageDirectory, |
| ); |
| _logMessage('coverage information collection complete'); |
| if (coverageData == null) { |
| return false; |
| } |
| |
| final File coverageFile = globals.fs.file(coveragePath) |
| ..createSync(recursive: true) |
| ..writeAsStringSync(coverageData, flush: true); |
| _logMessage('wrote coverage data to $coveragePath (size=${coverageData.length})'); |
| |
| const String baseCoverageData = 'coverage/lcov.base.info'; |
| if (mergeCoverageData) { |
| if (!globals.fs.isFileSync(baseCoverageData)) { |
| _logMessage('Missing "$baseCoverageData". Unable to merge coverage data.', error: true); |
| return false; |
| } |
| |
| if (globals.os.which('lcov') == null) { |
| String installMessage = 'Please install lcov.'; |
| if (globals.platform.isLinux) { |
| installMessage = 'Consider running "sudo apt-get install lcov".'; |
| } else if (globals.platform.isMacOS) { |
| installMessage = 'Consider running "brew install lcov".'; |
| } |
| _logMessage('Missing "lcov" tool. Unable to merge coverage data.\n$installMessage', error: true); |
| return false; |
| } |
| |
| final Directory tempDir = globals.fs.systemTempDirectory.createTempSync('flutter_tools_test_coverage.'); |
| try { |
| final File sourceFile = coverageFile.copySync(globals.fs.path.join(tempDir.path, 'lcov.source.info')); |
| final RunResult result = globals.processUtils.runSync(<String>[ |
| 'lcov', |
| '--add-tracefile', baseCoverageData, |
| '--add-tracefile', sourceFile.path, |
| '--output-file', coverageFile.path, |
| ]); |
| if (result.exitCode != 0) { |
| return false; |
| } |
| } finally { |
| tempDir.deleteSync(recursive: true); |
| } |
| } |
| return true; |
| } |
| |
| @override |
| Future<void> handleTestCrashed(TestDevice testDevice) async { } |
| |
| @override |
| Future<void> handleTestTimedOut(TestDevice testDevice) async { } |
| } |
| |
| Future<Map<String, dynamic>> collect(Uri serviceUri, Set<String>? libraryNames, { |
| bool waitPaused = false, |
| String? debugName, |
| @visibleForTesting bool forceSequential = false, |
| @visibleForTesting FlutterVmService? serviceOverride, |
| bool branchCoverage = false, |
| }) { |
| return coverage.collect( |
| serviceUri, false, false, false, libraryNames, |
| serviceOverrideForTesting: serviceOverride?.service, |
| branchCoverage: branchCoverage); |
| } |