coverage_collector.dart 11.9 KB
Newer Older
1 2 3 4 5 6
// 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';

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

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

19 20
import 'watcher.dart';

21
/// A class that's used to collect coverage data during tests.
22
class 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 31 32 33
    printTrace('test ${event.childIndex}: collecting coverage');
    await collectCoverage(event.process, event.observatoryUri);
  }

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 43 44 45 46 47
  /// 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.
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 53 54 55 56 57 58 59 60 61
    if (data == null) {
      throw Exception('Failed to collect coverage.');
    }
    assert(data != null);

    print('($observatoryUri): collected coverage data; merging...');
    _addHitmap(coverage.createHitmap(data['coverage']));
    print('($observatoryUri): done merging coverage data into global coverage map.');
  }

62
  /// Collects coverage for the given [Process] using the given `port`.
63
  ///
64 65 66 67
  /// 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.
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 75 76

    Map<String, dynamic> data;
    final Future<void> processComplete = process.exitCode
      .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
        }
84
        data = result;
85
      });
86 87 88 89
    await Future.any<void>(<Future<void>>[ processComplete, collectionComplete ]);
    assert(data != null);

    printTrace('pid $pid ($observatoryUri): collected coverage data; merging...');
90
    _addHitmap(coverage.createHitmap(data['coverage']));
91
    printTrace('pid $pid ($observatoryUri): done merging coverage data into global coverage map.');
92 93
  }

94 95 96
  /// Returns a future that will complete with the formatted coverage data
  /// (using [formatter]) once all coverage data has been collected.
  ///
97 98
  /// This will not start any collection tasks. It us up to the caller of to
  /// call [collectCoverage] for each process first.
99
  Future<String> finalizeCoverage({
100
    coverage.Formatter formatter,
101
    Directory coverageDirectory,
102
  }) async {
103
    if (_globalHitmap == null) {
104
      return null;
105
    }
106
    if (formatter == null) {
107
      final coverage.Resolver resolver = coverage.Resolver(packagesPath: PackageMap.globalPackagesPath);
108
      final String packagePath = fs.currentDirectory.path;
109 110 111
      final List<String> reportOn = coverageDirectory == null
        ? <String>[fs.path.join(packagePath, 'lib')]
        : <String>[coverageDirectory.path];
112
      formatter = coverage.LcovFormatter(resolver, reportOn: reportOn, basePath: packagePath);
113
    }
114
    final String result = await formatter.format(_globalHitmap);
115 116
    _globalHitmap = null;
    return result;
117
  }
118

119
  Future<bool> collectCoverageData(String coveragePath, { bool mergeCoverageData = false, Directory coverageDirectory }) async {
120
    final Status status = logger.startProgress('Collecting coverage information...', timeout: timeoutConfiguration.fastOperation);
121
    final String coverageData = await finalizeCoverage(
122
      coverageDirectory: coverageDirectory,
123 124 125
    );
    status.stop();
    printTrace('coverage information collection complete');
126
    if (coverageData == null) {
127
      return false;
128
    }
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143

    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.';
144
        if (platform.isLinux) {
145
          installMessage = 'Consider running "sudo apt-get install lcov".';
146
        } else if (platform.isMacOS) {
147
          installMessage = 'Consider running "brew install lcov".';
148
        }
149 150 151 152
        printError('Missing "lcov" tool. Unable to merge coverage data.\n$installMessage');
        return false;
      }

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

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

178 179 180 181 182
Future<Map<String, dynamic>> collect(Uri serviceUri, bool Function(String) libraryPredicate, {
  bool waitPaused = false,
  String debugName,
  Future<VMService> Function(Uri) connector = _defaultConnect,
}) async {
183
  final VMService vmService = await connector(serviceUri);
184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
  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>>[];
  for (Isolate isolateRef in service.vm.isolates) {
    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.
200 201 202 203 204 205 206

    // We may receive such objects as
    // {type: Sentinel, kind: Collected, valueAsString: <collected>}
    // that need to be skipped.
    if (scriptList['scripts'] == null) {
      continue;
    }
207 208 209 210 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 245 246 247 248 249
    for (Map<String, dynamic> script in scriptList['scripts']) {
      if (!libraryPredicate(script['uri'])) {
        continue;
      }
      final String scriptId = script['id'];
      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>>{};
  for (String scriptId in scripts.keys) {
    final Map<String, dynamic> sourceReport = sourceReports[scriptId];
    for (Map<String, dynamic> range in sourceReport['ranges']) {
      final Map<String, dynamic> coverage = range['coverage'];
250 251 252 253
      // Coverage reports may sometimes be null for a Script.
      if (coverage == null) {
        continue;
      }
254 255 256 257 258 259 260 261
      final Map<String, dynamic> scriptRef = sourceReport['scripts'][range['scriptIndex']];
      final String uri = scriptRef['uri'];

      hitMaps[uri] ??= <int, int>{};
      final Map<int, int> hitMap = hitMaps[uri];
      final List<dynamic> hits = coverage['hits'];
      final List<dynamic> misses = coverage['misses'];
      final List<dynamic> tokenPositions = scripts[scriptRef['id']]['tokenPosTable'];
262 263 264 265
      // The token positions can be null if the script has no coverable lines.
      if (tokenPositions == null) {
        continue;
      }
266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 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 327
      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<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);
    final List<dynamic> row = tokenPositions[mid];
    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;
}