// Copyright 2016 The Chromium 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 'dart:async'; import 'package:coverage/coverage.dart' as coverage; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/os.dart'; import '../base/platform.dart'; import '../base/process.dart'; import '../base/utils.dart'; import '../dart/package_map.dart'; import '../globals.dart'; import '../vmservice.dart'; import 'watcher.dart'; /// A class that's used to collect coverage data during tests. class CoverageCollector extends TestWatcher { CoverageCollector({this.libraryPredicate}); Map> _globalHitmap; bool Function(String) libraryPredicate; @override Future handleFinishedTest(ProcessEvent event) async { printTrace('test ${event.childIndex}: collecting coverage'); await collectCoverage(event.process, event.observatoryUri); } void _addHitmap(Map> hitmap) { if (_globalHitmap == null) { _globalHitmap = hitmap; } else { coverage.mergeHitmaps(hitmap, _globalHitmap); } } /// 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); print('collecting coverage data from $observatoryUri...'); final Map data = await collect(observatoryUri, libraryPredicate); if (data == null) { throw Exception('Failed to collect coverage.'); } assert(data != null); print('($observatoryUri): collected coverage data; merging...'); _addHitmap(coverage.createHitmap(data['coverage'] as List)); print('($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(Process process, Uri observatoryUri) async { assert(process != null); assert(observatoryUri != null); final int pid = process.pid; printTrace('pid $pid: collecting coverage data from $observatoryUri...'); Map data; final Future processComplete = process.exitCode .then((int code) { throw Exception('Failed to collect coverage, process terminated prematurely with exit code $code.'); }); final Future collectionComplete = collect(observatoryUri, libraryPredicate) .then((Map result) { if (result == null) { throw Exception('Failed to collect coverage.'); } data = result; }); await Future.any(>[ processComplete, collectionComplete ]); assert(data != null); printTrace('pid $pid ($observatoryUri): collected coverage data; merging...'); _addHitmap(coverage.createHitmap(data['coverage'] as List)); printTrace('pid $pid ($observatoryUri): done merging coverage data into global coverage map.'); } /// Returns a future that will complete with the formatted coverage data /// (using [formatter]) 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 finalizeCoverage({ coverage.Formatter formatter, Directory coverageDirectory, }) async { if (_globalHitmap == null) { return null; } if (formatter == null) { final coverage.Resolver resolver = coverage.Resolver(packagesPath: PackageMap.globalPackagesPath); final String packagePath = fs.currentDirectory.path; final List reportOn = coverageDirectory == null ? [fs.path.join(packagePath, 'lib')] : [coverageDirectory.path]; formatter = coverage.LcovFormatter(resolver, reportOn: reportOn, basePath: packagePath); } final String result = await formatter.format(_globalHitmap); _globalHitmap = null; return result; } Future collectCoverageData(String coveragePath, { bool mergeCoverageData = false, Directory coverageDirectory }) async { final Status status = logger.startProgress('Collecting coverage information...', timeout: timeoutConfiguration.fastOperation); final String coverageData = await finalizeCoverage( coverageDirectory: coverageDirectory, ); status.stop(); printTrace('coverage information collection complete'); if (coverageData == null) { return false; } final File coverageFile = fs.file(coveragePath) ..createSync(recursive: true) ..writeAsStringSync(coverageData, flush: true); printTrace('wrote coverage data to $coveragePath (size=${coverageData.length})'); const String baseCoverageData = 'coverage/lcov.base.info'; if (mergeCoverageData) { if (!fs.isFileSync(baseCoverageData)) { printError('Missing "$baseCoverageData". Unable to merge coverage data.'); return false; } if (os.which('lcov') == null) { String installMessage = 'Please install lcov.'; if (platform.isLinux) { installMessage = 'Consider running "sudo apt-get install lcov".'; } else if (platform.isMacOS) { installMessage = 'Consider running "brew install lcov".'; } printError('Missing "lcov" tool. Unable to merge coverage data.\n$installMessage'); return false; } final Directory tempDir = fs.systemTempDirectory.createTempSync('flutter_tools_test_coverage.'); try { final File sourceFile = coverageFile.copySync(fs.path.join(tempDir.path, 'lcov.source.info')); final RunResult result = 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; } } Future _defaultConnect(Uri serviceUri) { return VMService.connect( serviceUri, compression: CompressionOptions.compressionOff); } Future> collect(Uri serviceUri, bool Function(String) libraryPredicate, { bool waitPaused = false, String debugName, Future Function(Uri) connector = _defaultConnect, }) async { final VMService vmService = await connector(serviceUri); await vmService.getVM(); return _getAllCoverage(vmService, libraryPredicate); } Future> _getAllCoverage(VMService service, bool Function(String) libraryPredicate) async { await service.getVM(); final List> coverage = >[]; for (Isolate isolateRef in service.vm.isolates) { await isolateRef.load(); final Map scriptList = await isolateRef.invokeRpcRaw('getScripts', params: {'isolateId': isolateRef.id}); final List> futures = >[]; final Map> scripts = >{}; final Map> sourceReports = >{}; // For each ScriptRef loaded into the VM, load the corresponding Script and // SourceReport object. // We may receive such objects as // {type: Sentinel, kind: Collected, valueAsString: } // that need to be skipped. if (scriptList['scripts'] == null) { continue; } for (Map script in scriptList['scripts']) { if (!libraryPredicate(script['uri'] as String)) { continue; } final String scriptId = script['id'] as String; futures.add( isolateRef.invokeRpcRaw('getSourceReport', params: { 'forceCompile': true, 'scriptId': scriptId, 'isolateId': isolateRef.id, 'reports': ['Coverage'], }) .then((Map report) { sourceReports[scriptId] = report; }) ); futures.add( isolateRef.invokeRpcRaw('getObject', params: { 'isolateId': isolateRef.id, 'objectId': scriptId, }) .then((Map script) { scripts[scriptId] = script; }) ); } await Future.wait(futures); _buildCoverageMap(scripts, sourceReports, coverage); } 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, ) { final Map> hitMaps = >{}; for (String scriptId in scripts.keys) { final Map sourceReport = sourceReports[scriptId]; for (Map range in sourceReport['ranges']) { final Map coverage = castStringKeyedMap(range['coverage']); // Coverage reports may sometimes be null for a Script. if (coverage == null) { continue; } final Map scriptRef = castStringKeyedMap(sourceReport['scripts'][range['scriptIndex']]); final String uri = scriptRef['uri'] as String; hitMaps[uri] ??= {}; final Map hitMap = hitMaps[uri]; final List hits = (coverage['hits'] as List).cast(); final List misses = (coverage['misses'] as List).cast(); final List tokenPositions = scripts[scriptRef['id']]['tokenPosTable'] as List; // The token positions can be null if the script has no coverable lines. if (tokenPositions == null) { continue; } if (hits != null) { for (int hit in hits) { final int line = _lineAndColumn(hit, tokenPositions)[0]; final int current = hitMap[line] ?? 0; hitMap[line] = current + 1; } } if (misses != null) { for (int miss in misses) { final int line = _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/master/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; }