1// Copyright 2016 The Chromium Authors. All rights reserved. 2// Use of this source code is governed by a BSD-style license that can be 3// found in the LICENSE file. 4 5import 'dart:async'; 6 7import 'package:coverage/coverage.dart' as coverage; 8 9import '../base/file_system.dart'; 10import '../base/io.dart'; 11import '../base/logger.dart'; 12import '../base/os.dart'; 13import '../base/platform.dart'; 14import '../base/process_manager.dart'; 15import '../dart/package_map.dart'; 16import '../globals.dart'; 17import '../vmservice.dart'; 18 19import 'watcher.dart'; 20 21/// A class that's used to collect coverage data during tests. 22class CoverageCollector extends TestWatcher { 23 CoverageCollector({this.libraryPredicate}); 24 25 Map<String, dynamic> _globalHitmap; 26 bool Function(String) libraryPredicate; 27 28 @override 29 Future<void> handleFinishedTest(ProcessEvent event) async { 30 printTrace('test ${event.childIndex}: collecting coverage'); 31 await collectCoverage(event.process, event.observatoryUri); 32 } 33 34 void _addHitmap(Map<String, dynamic> hitmap) { 35 if (_globalHitmap == null) { 36 _globalHitmap = hitmap; 37 } else { 38 coverage.mergeHitmaps(hitmap, _globalHitmap); 39 } 40 } 41 42 /// Collects coverage for an isolate using the given `port`. 43 /// 44 /// This should be called when the code whose coverage data is being collected 45 /// has been run to completion so that all coverage data has been recorded. 46 /// 47 /// The returned [Future] completes when the coverage is collected. 48 Future<void> collectCoverageIsolate(Uri observatoryUri) async { 49 assert(observatoryUri != null); 50 print('collecting coverage data from $observatoryUri...'); 51 final Map<String, dynamic> data = await collect(observatoryUri, libraryPredicate); 52 if (data == null) { 53 throw Exception('Failed to collect coverage.'); 54 } 55 assert(data != null); 56 57 print('($observatoryUri): collected coverage data; merging...'); 58 _addHitmap(coverage.createHitmap(data['coverage'])); 59 print('($observatoryUri): done merging coverage data into global coverage map.'); 60 } 61 62 /// Collects coverage for the given [Process] using the given `port`. 63 /// 64 /// This should be called when the code whose coverage data is being collected 65 /// has been run to completion so that all coverage data has been recorded. 66 /// 67 /// The returned [Future] completes when the coverage is collected. 68 Future<void> collectCoverage(Process process, Uri observatoryUri) async { 69 assert(process != null); 70 assert(observatoryUri != null); 71 final int pid = process.pid; 72 printTrace('pid $pid: collecting coverage data from $observatoryUri...'); 73 74 Map<String, dynamic> data; 75 final Future<void> processComplete = process.exitCode 76 .then<void>((int code) { 77 throw Exception('Failed to collect coverage, process terminated prematurely with exit code $code.'); 78 }); 79 final Future<void> collectionComplete = collect(observatoryUri, libraryPredicate) 80 .then<void>((Map<String, dynamic> result) { 81 if (result == null) 82 throw Exception('Failed to collect coverage.'); 83 data = result; 84 }); 85 await Future.any<void>(<Future<void>>[ processComplete, collectionComplete ]); 86 assert(data != null); 87 88 printTrace('pid $pid ($observatoryUri): collected coverage data; merging...'); 89 _addHitmap(coverage.createHitmap(data['coverage'])); 90 printTrace('pid $pid ($observatoryUri): done merging coverage data into global coverage map.'); 91 } 92 93 /// Returns a future that will complete with the formatted coverage data 94 /// (using [formatter]) once all coverage data has been collected. 95 /// 96 /// This will not start any collection tasks. It us up to the caller of to 97 /// call [collectCoverage] for each process first. 98 Future<String> finalizeCoverage({ 99 coverage.Formatter formatter, 100 Directory coverageDirectory, 101 }) async { 102 if (_globalHitmap == null) { 103 return null; 104 } 105 if (formatter == null) { 106 final coverage.Resolver resolver = coverage.Resolver(packagesPath: PackageMap.globalPackagesPath); 107 final String packagePath = fs.currentDirectory.path; 108 final List<String> reportOn = coverageDirectory == null 109 ? <String>[fs.path.join(packagePath, 'lib')] 110 : <String>[coverageDirectory.path]; 111 formatter = coverage.LcovFormatter(resolver, reportOn: reportOn, basePath: packagePath); 112 } 113 final String result = await formatter.format(_globalHitmap); 114 _globalHitmap = null; 115 return result; 116 } 117 118 Future<bool> collectCoverageData(String coveragePath, { bool mergeCoverageData = false, Directory coverageDirectory }) async { 119 final Status status = logger.startProgress('Collecting coverage information...', timeout: timeoutConfiguration.fastOperation); 120 final String coverageData = await finalizeCoverage( 121 coverageDirectory: coverageDirectory, 122 ); 123 status.stop(); 124 printTrace('coverage information collection complete'); 125 if (coverageData == null) 126 return false; 127 128 final File coverageFile = fs.file(coveragePath) 129 ..createSync(recursive: true) 130 ..writeAsStringSync(coverageData, flush: true); 131 printTrace('wrote coverage data to $coveragePath (size=${coverageData.length})'); 132 133 const String baseCoverageData = 'coverage/lcov.base.info'; 134 if (mergeCoverageData) { 135 if (!fs.isFileSync(baseCoverageData)) { 136 printError('Missing "$baseCoverageData". Unable to merge coverage data.'); 137 return false; 138 } 139 140 if (os.which('lcov') == null) { 141 String installMessage = 'Please install lcov.'; 142 if (platform.isLinux) 143 installMessage = 'Consider running "sudo apt-get install lcov".'; 144 else if (platform.isMacOS) 145 installMessage = 'Consider running "brew install lcov".'; 146 printError('Missing "lcov" tool. Unable to merge coverage data.\n$installMessage'); 147 return false; 148 } 149 150 final Directory tempDir = fs.systemTempDirectory.createTempSync('flutter_tools_test_coverage.'); 151 try { 152 final File sourceFile = coverageFile.copySync(fs.path.join(tempDir.path, 'lcov.source.info')); 153 final ProcessResult result = processManager.runSync(<String>[ 154 'lcov', 155 '--add-tracefile', baseCoverageData, 156 '--add-tracefile', sourceFile.path, 157 '--output-file', coverageFile.path, 158 ]); 159 if (result.exitCode != 0) 160 return false; 161 } finally { 162 tempDir.deleteSync(recursive: true); 163 } 164 } 165 return true; 166 } 167} 168 169Future<VMService> _defaultConnect(Uri serviceUri) { 170 return VMService.connect( 171 serviceUri, compression: CompressionOptions.compressionOff); 172} 173 174Future<Map<String, dynamic>> collect(Uri serviceUri, bool Function(String) libraryPredicate, { 175 bool waitPaused = false, 176 String debugName, 177 Future<VMService> Function(Uri) connector = _defaultConnect, 178}) async { 179 final VMService vmService = await connector(serviceUri); 180 await vmService.getVM(); 181 return _getAllCoverage(vmService, libraryPredicate); 182} 183 184Future<Map<String, dynamic>> _getAllCoverage(VMService service, bool Function(String) libraryPredicate) async { 185 await service.getVM(); 186 final List<Map<String, dynamic>> coverage = <Map<String, dynamic>>[]; 187 for (Isolate isolateRef in service.vm.isolates) { 188 await isolateRef.load(); 189 final Map<String, dynamic> scriptList = await isolateRef.invokeRpcRaw('getScripts', params: <String, dynamic>{'isolateId': isolateRef.id}); 190 final List<Future<void>> futures = <Future<void>>[]; 191 192 final Map<String, Map<String, dynamic>> scripts = <String, Map<String, dynamic>>{}; 193 final Map<String, Map<String, dynamic>> sourceReports = <String, Map<String, dynamic>>{}; 194 // For each ScriptRef loaded into the VM, load the corresponding Script and 195 // SourceReport object. 196 197 // We may receive such objects as 198 // {type: Sentinel, kind: Collected, valueAsString: <collected>} 199 // that need to be skipped. 200 if (scriptList['scripts'] == null) { 201 continue; 202 } 203 for (Map<String, dynamic> script in scriptList['scripts']) { 204 if (!libraryPredicate(script['uri'])) { 205 continue; 206 } 207 final String scriptId = script['id']; 208 futures.add( 209 isolateRef.invokeRpcRaw('getSourceReport', params: <String, dynamic>{ 210 'forceCompile': true, 211 'scriptId': scriptId, 212 'isolateId': isolateRef.id, 213 'reports': <String>['Coverage'], 214 }) 215 .then((Map<String, dynamic> report) { 216 sourceReports[scriptId] = report; 217 }) 218 ); 219 futures.add( 220 isolateRef.invokeRpcRaw('getObject', params: <String, dynamic>{ 221 'isolateId': isolateRef.id, 222 'objectId': scriptId, 223 }) 224 .then((Map<String, dynamic> script) { 225 scripts[scriptId] = script; 226 }) 227 ); 228 } 229 await Future.wait(futures); 230 _buildCoverageMap(scripts, sourceReports, coverage); 231 } 232 return <String, dynamic>{'type': 'CodeCoverage', 'coverage': coverage}; 233} 234 235// Build a hitmap of Uri -> Line -> Hit Count for each script object. 236void _buildCoverageMap( 237 Map<String, Map<String, dynamic>> scripts, 238 Map<String, Map<String, dynamic>> sourceReports, 239 List<Map<String, dynamic>> coverage, 240) { 241 final Map<String, Map<int, int>> hitMaps = <String, Map<int, int>>{}; 242 for (String scriptId in scripts.keys) { 243 final Map<String, dynamic> sourceReport = sourceReports[scriptId]; 244 for (Map<String, dynamic> range in sourceReport['ranges']) { 245 final Map<String, dynamic> coverage = range['coverage']; 246 // Coverage reports may sometimes be null for a Script. 247 if (coverage == null) { 248 continue; 249 } 250 final Map<String, dynamic> scriptRef = sourceReport['scripts'][range['scriptIndex']]; 251 final String uri = scriptRef['uri']; 252 253 hitMaps[uri] ??= <int, int>{}; 254 final Map<int, int> hitMap = hitMaps[uri]; 255 final List<dynamic> hits = coverage['hits']; 256 final List<dynamic> misses = coverage['misses']; 257 final List<dynamic> tokenPositions = scripts[scriptRef['id']]['tokenPosTable']; 258 // The token positions can be null if the script has no coverable lines. 259 if (tokenPositions == null) { 260 continue; 261 } 262 if (hits != null) { 263 for (int hit in hits) { 264 final int line = _lineAndColumn(hit, tokenPositions)[0]; 265 final int current = hitMap[line] ?? 0; 266 hitMap[line] = current + 1; 267 } 268 } 269 if (misses != null) { 270 for (int miss in misses) { 271 final int line = _lineAndColumn(miss, tokenPositions)[0]; 272 hitMap[line] ??= 0; 273 } 274 } 275 } 276 } 277 hitMaps.forEach((String uri, Map<int, int> hitMap) { 278 coverage.add(_toScriptCoverageJson(uri, hitMap)); 279 }); 280} 281 282// Binary search the token position table for the line and column which 283// corresponds to each token position. 284// The format of this table is described in https://github.com/dart-lang/sdk/blob/master/runtime/vm/service/service.md#script 285List<int> _lineAndColumn(int position, List<dynamic> tokenPositions) { 286 int min = 0; 287 int max = tokenPositions.length; 288 while (min < max) { 289 final int mid = min + ((max - min) >> 1); 290 final List<dynamic> row = tokenPositions[mid]; 291 if (row[1] > position) { 292 max = mid; 293 } else { 294 for (int i = 1; i < row.length; i += 2) { 295 if (row[i] == position) { 296 return <int>[row.first, row[i + 1]]; 297 } 298 } 299 min = mid + 1; 300 } 301 } 302 throw StateError('Unreachable'); 303} 304 305// Returns a JSON hit map backward-compatible with pre-1.16.0 SDKs. 306Map<String, dynamic> _toScriptCoverageJson(String scriptUri, Map<int, int> hitMap) { 307 final Map<String, dynamic> json = <String, dynamic>{}; 308 final List<int> hits = <int>[]; 309 hitMap.forEach((int line, int hitCount) { 310 hits.add(line); 311 hits.add(hitCount); 312 }); 313 json['source'] = scriptUri; 314 json['script'] = <String, dynamic>{ 315 'type': '@Script', 316 'fixedId': true, 317 'id': 'libraries/1/scripts/${Uri.encodeComponent(scriptUri)}', 318 'uri': scriptUri, 319 '_kind': 'library', 320 }; 321 json['hits'] = hits; 322 return json; 323} 324