// 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. // @dart = 2.8 import 'package:coverage/coverage.dart' as coverage; import 'package:meta/meta.dart'; import 'package:vm_service/vm_service.dart' as vm_service; 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 'watcher.dart'; /// A class that collects code coverage data during test runs. class CoverageCollector extends TestWatcher { CoverageCollector({this.libraryPredicate, this.verbose = true, @required this.packagesPath}); /// 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 _globalHitmap; /// Predicate function that returns true if the specified library URI should /// be included in the computed coverage. bool Function(String) libraryPredicate; @override Future 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 hitmap) { if (_globalHitmap == null) { _globalHitmap = hitmap; } else { _globalHitmap.merge(hitmap); } } /// 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 collectCoverageIsolate(Uri observatoryUri) async { assert(observatoryUri != null); _logMessage('collecting coverage data from $observatoryUri...'); final Map data = await collect(observatoryUri, libraryPredicate); if (data == null) { throw Exception('Failed to collect coverage.'); } assert(data != null); _logMessage('($observatoryUri): collected coverage data; merging...'); _addHitmap(await coverage.HitMap.parseJson( data['coverage'] as List>, // TODO(cbracken): https://github.com/flutter/flutter/issues/103830 // Replace with packagePath: packageDirectory packagesPath: packagesPath, // ignore: deprecated_member_use 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 collectCoverage(TestDevice testDevice) async { assert(testDevice != null); Map data; final Future processComplete = testDevice.finished.catchError( (Object error) => throw Exception( 'Failed to collect coverage, test device terminated prematurely with ' 'error: ${(error as TestDeviceException).message}.'), test: (Object error) => error is TestDeviceException, ); final Future collectionComplete = testDevice.observatoryUri .then((Uri observatoryUri) { _logMessage('collecting coverage data from $testDevice at $observatoryUri...'); return collect(observatoryUri, libraryPredicate) .then((Map result) { if (result == null) { throw Exception('Failed to collect coverage.'); } _logMessage('Collected coverage data.'); data = result; }); }); await Future.any(>[ processComplete, collectionComplete ]); assert(data != null); _logMessage('Merging coverage data...'); _addHitmap(await coverage.HitMap.parseJson( data['coverage'] as List>, // TODO(cbracken): https://github.com/flutter/flutter/issues/103830 // Replace with packagePath: packageDirectory packagesPath: packagesPath, // ignore: deprecated_member_use checkIgnoredLines: true, )); _logMessage('Done merging coverage data into global coverage map.'); } /// 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. String finalizeCoverage({ String Function(Map hitmap) formatter, coverage.Resolver resolver, Directory coverageDirectory, }) { if (_globalHitmap == null) { return null; } if (formatter == null) { // TODO(cbracken): https://github.com/flutter/flutter/issues/103830 // Replace with: resolver ??= await coverage.Resolver.create(packagesPath: packagesPath); // ignore: deprecated_member_use resolver ??= coverage.Resolver(packagesPath: packagesPath); final String packagePath = globals.fs.currentDirectory.path; final List reportOn = coverageDirectory == null ? [globals.fs.path.join(packagePath, 'lib')] : [coverageDirectory.path]; formatter = (Map hitmap) => hitmap .formatLcov(resolver, reportOn: reportOn, basePath: packagePath); } final String result = formatter(_globalHitmap); _globalHitmap = null; return result; } bool collectCoverageData(String coveragePath, { bool mergeCoverageData = false, Directory coverageDirectory }) { final String coverageData = 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([ '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 handleTestCrashed(TestDevice testDevice) async { } @override Future handleTestTimedOut(TestDevice testDevice) async { } } Future _defaultConnect(Uri serviceUri) { return connectToVmService( serviceUri, compression: CompressionOptions.compressionOff, logger: globals.logger,); } Future> collect(Uri serviceUri, bool Function(String) libraryPredicate, { bool waitPaused = false, String debugName, Future Function(Uri) connector = _defaultConnect, @visibleForTesting bool forceSequential = false, }) async { final FlutterVmService vmService = await connector(serviceUri); final Map result = await _getAllCoverage(vmService.service, libraryPredicate, forceSequential); await vmService.dispose(); return result; } Future> _getAllCoverage( vm_service.VmService service, bool Function(String) libraryPredicate, bool forceSequential, ) async { final vm_service.Version version = await service.getVersion(); final bool reportLines = (version.major == 3 && version.minor >= 51) || version.major > 3; final vm_service.VM vm = await service.getVM(); final List> coverage = >[]; for (final vm_service.IsolateRef isolateRef in vm.isolates) { if (isolateRef.isSystemIsolate) { continue; } vm_service.ScriptList scriptList; try { scriptList = await service.getScripts(isolateRef.id); } on vm_service.SentinelException { continue; } final List> futures = >[]; final Map scripts = {}; final Map sourceReports = {}; // For each ScriptRef loaded into the VM, load the corresponding Script and // SourceReport object. for (final vm_service.ScriptRef script in scriptList.scripts) { final String libraryUri = script.uri; if (!libraryPredicate(libraryUri)) { continue; } final String scriptId = script.id; final Future getSourceReport = service.getSourceReport( isolateRef.id, ['Coverage'], scriptId: scriptId, forceCompile: true, reportLines: reportLines ? true : null, ) .then((vm_service.SourceReport report) { sourceReports[scriptId] = report; }); if (forceSequential) { await null; } futures.add(getSourceReport); if (reportLines) { continue; } final Future getObject = service .getObject(isolateRef.id, scriptId) .then((vm_service.Obj response) { final vm_service.Script script = response as vm_service.Script; scripts[scriptId] = script; }); futures.add(getObject); } await Future.wait(futures); _buildCoverageMap(scripts, sourceReports, coverage, reportLines); } return {'type': 'CodeCoverage', 'coverage': coverage}; } // Build a hitmap of Uri -> Line -> Hit Count for each script object. void _buildCoverageMap( Map scripts, Map sourceReports, List> coverage, bool reportLines, ) { final Map> hitMaps = >{}; for (final String scriptId in sourceReports.keys) { final vm_service.SourceReport sourceReport = sourceReports[scriptId]; for (final vm_service.SourceReportRange range in sourceReport.ranges) { final vm_service.SourceReportCoverage coverage = range.coverage; // Coverage reports may sometimes be null for a Script. if (coverage == null) { continue; } final vm_service.ScriptRef scriptRef = sourceReport.scripts[range.scriptIndex]; final String uri = scriptRef.uri; hitMaps[uri] ??= {}; final Map hitMap = hitMaps[uri]; final List hits = coverage.hits; final List misses = coverage.misses; final List tokenPositions = scripts[scriptRef.id]?.tokenPosTable; // The token positions can be null if the script has no lines that may be // covered. It will also be null if reportLines is true. if (tokenPositions == null && !reportLines) { continue; } if (hits != null) { for (final int hit in hits) { final int line = reportLines ? hit : _lineAndColumn(hit, tokenPositions)[0]; final int current = hitMap[line] ?? 0; hitMap[line] = current + 1; } } if (misses != null) { for (final int miss in misses) { final int line = reportLines ? miss : _lineAndColumn(miss, tokenPositions)[0]; hitMap[line] ??= 0; } } } } hitMaps.forEach((String uri, Map hitMap) { coverage.add(_toScriptCoverageJson(uri, hitMap)); }); } // Binary search the token position table for the line and column which // corresponds to each token position. // The format of this table is described in https://github.com/dart-lang/sdk/blob/main/runtime/vm/service/service.md#script List _lineAndColumn(int position, List tokenPositions) { int min = 0; int max = tokenPositions.length; while (min < max) { final int mid = min + ((max - min) >> 1); final List row = (tokenPositions[mid] as List).cast(); if (row[1] > position) { max = mid; } else { for (int i = 1; i < row.length; i += 2) { if (row[i] == position) { return [row.first, row[i + 1]]; } } min = mid + 1; } } throw StateError('Unreachable'); } // Returns a JSON hit map backward-compatible with pre-1.16.0 SDKs. Map _toScriptCoverageJson(String scriptUri, Map hitMap) { final Map json = {}; final List hits = []; hitMap.forEach((int line, int hitCount) { hits.add(line); hits.add(hitCount); }); json['source'] = scriptUri; json['script'] = { 'type': '@Script', 'fixedId': true, 'id': 'libraries/1/scripts/${Uri.encodeComponent(scriptUri)}', 'uri': scriptUri, '_kind': 'library', }; json['hits'] = hits; return json; }