coverage_collector.dart 12.2 KB
Newer Older
Ian Hickson's avatar
Ian Hickson committed
1
// Copyright 2014 The Flutter Authors. All rights reserved.
2 3 4 5 6
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import 'dart:async';

7
import 'package:coverage/coverage.dart' as coverage;
8

9 10
import '../base/file_system.dart';
import '../base/io.dart';
11
import '../base/logger.dart';
12
import '../base/process.dart';
13
import '../base/utils.dart';
14
import '../dart/package_map.dart';
15
import '../globals.dart' as globals;
16
import '../vmservice.dart';
17

18 19
import 'watcher.dart';

20
/// A class that's used to collect coverage data during tests.
21
class CoverageCollector extends TestWatcher {
22
  CoverageCollector({this.libraryPredicate});
23

24
  Map<String, Map<int, int>> _globalHitmap;
25
  bool Function(String) libraryPredicate;
26

27
  @override
28
  Future<void> handleFinishedTest(ProcessEvent event) async {
29
    globals.printTrace('test ${event.childIndex}: collecting coverage');
30 31 32
    await collectCoverage(event.process, event.observatoryUri);
  }

33
  void _addHitmap(Map<String, Map<int, int>> hitmap) {
34
    if (_globalHitmap == null) {
35
      _globalHitmap = hitmap;
36
    } else {
37
      coverage.mergeHitmaps(hitmap, _globalHitmap);
38
    }
39 40
  }

41 42 43 44 45 46
  /// 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.
47
  Future<void> collectCoverageIsolate(Uri observatoryUri) async {
48
    assert(observatoryUri != null);
49
    print('collecting coverage data from $observatoryUri...');
50
    final Map<String, dynamic> data = await collect(observatoryUri, libraryPredicate);
51 52 53 54 55 56
    if (data == null) {
      throw Exception('Failed to collect coverage.');
    }
    assert(data != null);

    print('($observatoryUri): collected coverage data; merging...');
57
    _addHitmap(coverage.createHitmap(data['coverage'] as List<dynamic>));
58 59 60
    print('($observatoryUri): done merging coverage data into global coverage map.');
  }

61
  /// Collects coverage for the given [Process] using the given `port`.
62
  ///
63 64 65 66
  /// 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.
67
  Future<void> collectCoverage(Process process, Uri observatoryUri) async {
68
    assert(process != null);
69
    assert(observatoryUri != null);
70
    final int pid = process.pid;
71
    globals.printTrace('pid $pid: collecting coverage data from $observatoryUri...');
72 73 74 75

    Map<String, dynamic> data;
    final Future<void> processComplete = process.exitCode
      .then<void>((int code) {
76
        throw Exception('Failed to collect coverage, process terminated prematurely with exit code $code.');
77
      });
78
    final Future<void> collectionComplete = collect(observatoryUri, libraryPredicate)
79
      .then<void>((Map<String, dynamic> result) {
80
        if (result == null) {
81
          throw Exception('Failed to collect coverage.');
82
        }
83
        data = result;
84
      });
85 86 87
    await Future.any<void>(<Future<void>>[ processComplete, collectionComplete ]);
    assert(data != null);

88
    globals.printTrace('pid $pid ($observatoryUri): collected coverage data; merging...');
89
    _addHitmap(coverage.createHitmap(data['coverage'] as List<dynamic>));
90
    globals.printTrace('pid $pid ($observatoryUri): done merging coverage data into global coverage map.');
91 92
  }

93 94 95
  /// Returns a future that will complete with the formatted coverage data
  /// (using [formatter]) once all coverage data has been collected.
  ///
96 97
  /// This will not start any collection tasks. It us up to the caller of to
  /// 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 = globals.fs.currentDirectory.path;
108
      final List<String> reportOn = coverageDirectory == null
109
        ? <String>[globals.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 115
    _globalHitmap = null;
    return result;
116
  }
117

118
  Future<bool> collectCoverageData(String coveragePath, { bool mergeCoverageData = false, Directory coverageDirectory }) async {
119
    final Status status = globals.logger.startProgress('Collecting coverage information...', timeout: timeoutConfiguration.fastOperation);
120
    final String coverageData = await finalizeCoverage(
121
      coverageDirectory: coverageDirectory,
122 123
    );
    status.stop();
124
    globals.printTrace('coverage information collection complete');
125
    if (coverageData == null) {
126
      return false;
127
    }
128

129
    final File coverageFile = globals.fs.file(coveragePath)
130 131
      ..createSync(recursive: true)
      ..writeAsStringSync(coverageData, flush: true);
132
    globals.printTrace('wrote coverage data to $coveragePath (size=${coverageData.length})');
133 134 135

    const String baseCoverageData = 'coverage/lcov.base.info';
    if (mergeCoverageData) {
136 137
      if (!globals.fs.isFileSync(baseCoverageData)) {
        globals.printError('Missing "$baseCoverageData". Unable to merge coverage data.');
138 139 140
        return false;
      }

141
      if (globals.os.which('lcov') == null) {
142
        String installMessage = 'Please install lcov.';
143
        if (globals.platform.isLinux) {
144
          installMessage = 'Consider running "sudo apt-get install lcov".';
145
        } else if (globals.platform.isMacOS) {
146
          installMessage = 'Consider running "brew install lcov".';
147
        }
148
        globals.printError('Missing "lcov" tool. Unable to merge coverage data.\n$installMessage');
149 150 151
        return false;
      }

152
      final Directory tempDir = globals.fs.systemTempDirectory.createTempSync('flutter_tools_test_coverage.');
153
      try {
154
        final File sourceFile = coverageFile.copySync(globals.fs.path.join(tempDir.path, 'lcov.source.info'));
155
        final RunResult result = processUtils.runSync(<String>[
156 157 158 159 160
          'lcov',
          '--add-tracefile', baseCoverageData,
          '--add-tracefile', sourceFile.path,
          '--output-file', coverageFile.path,
        ]);
161
        if (result.exitCode != 0) {
162
          return false;
163
        }
164 165 166 167 168 169
      } finally {
        tempDir.deleteSync(recursive: true);
      }
    }
    return true;
  }
170
}
171

172
Future<VMService> _defaultConnect(Uri serviceUri) {
173 174
  return VMService.connect(
      serviceUri, compression: CompressionOptions.compressionOff);
175 176
}

177 178 179 180 181
Future<Map<String, dynamic>> collect(Uri serviceUri, bool Function(String) libraryPredicate, {
  bool waitPaused = false,
  String debugName,
  Future<VMService> Function(Uri) connector = _defaultConnect,
}) async {
182
  final VMService vmService = await connector(serviceUri);
183 184 185 186 187 188 189
  await vmService.getVM();
  return _getAllCoverage(vmService, libraryPredicate);
}

Future<Map<String, dynamic>> _getAllCoverage(VMService service, bool Function(String) libraryPredicate) async {
  await service.getVM();
  final List<Map<String, dynamic>> coverage = <Map<String, dynamic>>[];
190
  for (final Isolate isolateRef in service.vm.isolates) {
191 192 193 194 195 196 197 198
    await isolateRef.load();
    final Map<String, dynamic> scriptList = await isolateRef.invokeRpcRaw('getScripts', params: <String, dynamic>{'isolateId': isolateRef.id});
    final List<Future<void>> futures = <Future<void>>[];

    final Map<String, Map<String, dynamic>> scripts = <String, Map<String, dynamic>>{};
    final Map<String, Map<String, dynamic>> sourceReports = <String, Map<String, dynamic>>{};
    // For each ScriptRef loaded into the VM, load the corresponding Script and
    // SourceReport object.
199 200 201 202 203 204 205

    // We may receive such objects as
    // {type: Sentinel, kind: Collected, valueAsString: <collected>}
    // that need to be skipped.
    if (scriptList['scripts'] == null) {
      continue;
    }
206
    for (final Map<String, dynamic> script in scriptList['scripts']) {
207
      if (!libraryPredicate(script['uri'] as String)) {
208 209
        continue;
      }
210
      final String scriptId = script['id'] as String;
211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244
      futures.add(
        isolateRef.invokeRpcRaw('getSourceReport', params: <String, dynamic>{
          'forceCompile': true,
          'scriptId': scriptId,
          'isolateId': isolateRef.id,
          'reports': <String>['Coverage'],
        })
        .then((Map<String, dynamic> report) {
          sourceReports[scriptId] = report;
        })
      );
      futures.add(
        isolateRef.invokeRpcRaw('getObject', params: <String, dynamic>{
          'isolateId': isolateRef.id,
          'objectId': scriptId,
        })
        .then((Map<String, dynamic> script) {
          scripts[scriptId] = script;
        })
      );
    }
    await Future.wait(futures);
    _buildCoverageMap(scripts, sourceReports, coverage);
  }
  return <String, dynamic>{'type': 'CodeCoverage', 'coverage': coverage};
}

// Build a hitmap of Uri -> Line -> Hit Count for each script object.
void _buildCoverageMap(
  Map<String, Map<String, dynamic>> scripts,
  Map<String, Map<String, dynamic>> sourceReports,
  List<Map<String, dynamic>> coverage,
) {
  final Map<String, Map<int, int>> hitMaps = <String, Map<int, int>>{};
245
  for (final String scriptId in scripts.keys) {
246
    final Map<String, dynamic> sourceReport = sourceReports[scriptId];
247
    for (final Map<String, dynamic> range in sourceReport['ranges']) {
248
      final Map<String, dynamic> coverage = castStringKeyedMap(range['coverage']);
249
      // Coverage reports may sometimes be null for a Script.
250
      if (coverage == null) {
251 252
        continue;
      }
253 254
      final Map<String, dynamic> scriptRef = castStringKeyedMap(sourceReport['scripts'][range['scriptIndex']]);
      final String uri = scriptRef['uri'] as String;
255

256 257
      hitMaps[uri] ??= <int, int>{};
      final Map<int, int> hitMap = hitMaps[uri];
258 259 260
      final List<int> hits = (coverage['hits'] as List<dynamic>).cast<int>();
      final List<int> misses = (coverage['misses'] as List<dynamic>).cast<int>();
      final List<dynamic> tokenPositions = scripts[scriptRef['id']]['tokenPosTable'] as List<dynamic>;
261 262 263 264
      // The token positions can be null if the script has no coverable lines.
      if (tokenPositions == null) {
        continue;
      }
265
      if (hits != null) {
266
        for (final int hit in hits) {
267 268 269 270 271 272
          final int line = _lineAndColumn(hit, tokenPositions)[0];
          final int current = hitMap[line] ?? 0;
          hitMap[line] = current + 1;
        }
      }
      if (misses != null) {
273
        for (final int miss in misses) {
274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292
          final int line = _lineAndColumn(miss, tokenPositions)[0];
          hitMap[line] ??= 0;
        }
      }
    }
  }
  hitMaps.forEach((String uri, Map<int, int> 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<int> _lineAndColumn(int position, List<dynamic> tokenPositions) {
  int min = 0;
  int max = tokenPositions.length;
  while (min < max) {
    final int mid = min + ((max - min) >> 1);
293
    final List<int> row = (tokenPositions[mid] as List<dynamic>).cast<int>();
294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326
    if (row[1] > position) {
      max = mid;
    } else {
      for (int i = 1; i < row.length; i += 2) {
        if (row[i] == position) {
          return <int>[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<String, dynamic> _toScriptCoverageJson(String scriptUri, Map<int, int> hitMap) {
  final Map<String, dynamic> json = <String, dynamic>{};
  final List<int> hits = <int>[];
  hitMap.forEach((int line, int hitCount) {
    hits.add(line);
    hits.add(hitCount);
  });
  json['source'] = scriptUri;
  json['script'] = <String, dynamic>{
    'type': '@Script',
    'fixedId': true,
    'id': 'libraries/1/scripts/${Uri.encodeComponent(scriptUri)}',
    'uri': scriptUri,
    '_kind': 'library',
  };
  json['hits'] = hits;
  return json;
}