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

5 6
// @dart = 2.8

7
import 'package:coverage/coverage.dart' as coverage;
8
import 'package:meta/meta.dart';
9
import 'package:vm_service/vm_service.dart' as vm_service;
10

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

18
import 'test_device.dart';
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, this.verbose = true, @required this.packagesPath});
24

25
  final bool verbose;
26
  final String packagesPath;
27
  Map<String, Map<int, int>> _globalHitmap;
28
  bool Function(String) libraryPredicate;
29

30
  @override
31 32 33
  Future<void> handleFinishedTest(TestDevice testDevice) async {
    _logMessage('Starting coverage collection');
    await collectCoverage(testDevice);
34 35
  }

36 37 38 39 40 41 42 43 44 45 46
  void _logMessage(String line, { bool error = false }) {
    if (!verbose) {
      return;
    }
    if (error) {
      globals.printError(line);
    } else {
      globals.printTrace(line);
    }
  }

47
  void _addHitmap(Map<String, Map<int, int>> hitmap) {
48
    if (_globalHitmap == null) {
49
      _globalHitmap = hitmap;
50
    } else {
51
      coverage.mergeHitmaps(hitmap, _globalHitmap);
52
    }
53 54
  }

55 56 57 58 59 60
  /// 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.
61
  Future<void> collectCoverageIsolate(Uri observatoryUri) async {
62
    assert(observatoryUri != null);
63
    _logMessage('collecting coverage data from $observatoryUri...');
64
    final Map<String, dynamic> data = await collect(observatoryUri, libraryPredicate);
65 66 67 68 69
    if (data == null) {
      throw Exception('Failed to collect coverage.');
    }
    assert(data != null);

70
    _logMessage('($observatoryUri): collected coverage data; merging...');
71 72
    _addHitmap(await coverage.createHitmap(
      data['coverage'] as List<Map<String, dynamic>>,
73
      packagesPath: packagesPath,
74 75
      checkIgnoredLines: true,
    ));
76
    _logMessage('($observatoryUri): done merging coverage data into global coverage map.');
77 78
  }

79
  /// Collects coverage for the given [Process] using the given `port`.
80
  ///
81 82 83 84
  /// 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.
85 86
  Future<void> collectCoverage(TestDevice testDevice) async {
    assert(testDevice != null);
87 88

    Map<String, dynamic> data;
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107

    final Future<void> 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<void> collectionComplete = testDevice.observatoryUri
      .then((Uri observatoryUri) {
        _logMessage('collecting coverage data from $testDevice at $observatoryUri...');
        return collect(observatoryUri, libraryPredicate)
          .then<void>((Map<String, dynamic> result) {
            if (result == null) {
              throw Exception('Failed to collect coverage.');
            }
            _logMessage('Collected coverage data.');
            data = result;
          });
108
      });
109

110 111 112
    await Future.any<void>(<Future<void>>[ processComplete, collectionComplete ]);
    assert(data != null);

113
    _logMessage('Merging coverage data...');
114 115
    _addHitmap(await coverage.createHitmap(
      data['coverage'] as List<Map<String, dynamic>>,
116
      packagesPath: packagesPath,
117 118
      checkIgnoredLines: true,
    ));
119
    _logMessage('Done merging coverage data into global coverage map.');
120 121
  }

122 123 124
  /// Returns a future that will complete with the formatted coverage data
  /// (using [formatter]) once all coverage data has been collected.
  ///
125 126
  /// This will not start any collection tasks. It us up to the caller of to
  /// call [collectCoverage] for each process first.
127
  Future<String> finalizeCoverage({
128
    coverage.Formatter formatter,
129
    Directory coverageDirectory,
130
  }) async {
131
    if (_globalHitmap == null) {
132
      return null;
133
    }
134
    if (formatter == null) {
135
      final coverage.Resolver resolver = coverage.Resolver(packagesPath: packagesPath);
136
      final String packagePath = globals.fs.currentDirectory.path;
137
      final List<String> reportOn = coverageDirectory == null
138
        ? <String>[globals.fs.path.join(packagePath, 'lib')]
139
        : <String>[coverageDirectory.path];
140
      formatter = coverage.LcovFormatter(resolver, reportOn: reportOn, basePath: packagePath);
141
    }
142
    final String result = await formatter.format(_globalHitmap);
143 144
    _globalHitmap = null;
    return result;
145
  }
146

147
  Future<bool> collectCoverageData(String coveragePath, { bool mergeCoverageData = false, Directory coverageDirectory }) async {
148
    final String coverageData = await finalizeCoverage(
149
      coverageDirectory: coverageDirectory,
150
    );
151
    _logMessage('coverage information collection complete');
152
    if (coverageData == null) {
153
      return false;
154
    }
155

156
    final File coverageFile = globals.fs.file(coveragePath)
157 158
      ..createSync(recursive: true)
      ..writeAsStringSync(coverageData, flush: true);
159
    _logMessage('wrote coverage data to $coveragePath (size=${coverageData.length})');
160 161 162

    const String baseCoverageData = 'coverage/lcov.base.info';
    if (mergeCoverageData) {
163
      if (!globals.fs.isFileSync(baseCoverageData)) {
164
        _logMessage('Missing "$baseCoverageData". Unable to merge coverage data.', error: true);
165 166 167
        return false;
      }

168
      if (globals.os.which('lcov') == null) {
169
        String installMessage = 'Please install lcov.';
170
        if (globals.platform.isLinux) {
171
          installMessage = 'Consider running "sudo apt-get install lcov".';
172
        } else if (globals.platform.isMacOS) {
173
          installMessage = 'Consider running "brew install lcov".';
174
        }
175
        _logMessage('Missing "lcov" tool. Unable to merge coverage data.\n$installMessage', error: true);
176 177 178
        return false;
      }

179
      final Directory tempDir = globals.fs.systemTempDirectory.createTempSync('flutter_tools_test_coverage.');
180
      try {
181
        final File sourceFile = coverageFile.copySync(globals.fs.path.join(tempDir.path, 'lcov.source.info'));
182
        final RunResult result = globals.processUtils.runSync(<String>[
183 184 185 186 187
          'lcov',
          '--add-tracefile', baseCoverageData,
          '--add-tracefile', sourceFile.path,
          '--output-file', coverageFile.path,
        ]);
188
        if (result.exitCode != 0) {
189
          return false;
190
        }
191 192 193 194 195 196
      } finally {
        tempDir.deleteSync(recursive: true);
      }
    }
    return true;
  }
197 198

  @override
199
  Future<void> handleTestCrashed(TestDevice testDevice) async { }
200 201

  @override
202
  Future<void> handleTestTimedOut(TestDevice testDevice) async { }
203
}
204

205
Future<FlutterVmService> _defaultConnect(Uri serviceUri) {
206
  return connectToVmService(
207
      serviceUri, compression: CompressionOptions.compressionOff, logger: globals.logger,);
208 209
}

210 211 212
Future<Map<String, dynamic>> collect(Uri serviceUri, bool Function(String) libraryPredicate, {
  bool waitPaused = false,
  String debugName,
213
  Future<FlutterVmService> Function(Uri) connector = _defaultConnect,
214
}) async {
215 216
  final FlutterVmService vmService = await connector(serviceUri);
  final Map<String, dynamic> result = await _getAllCoverage(vmService.service, libraryPredicate);
217
  await vmService.dispose();
218
  return result;
219 220
}

221 222
Future<Map<String, dynamic>> _getAllCoverage(vm_service.VmService service, bool Function(String) libraryPredicate) async {
  final vm_service.VM vm = await service.getVM();
223
  final List<Map<String, dynamic>> coverage = <Map<String, dynamic>>[];
224
  for (final vm_service.IsolateRef isolateRef in vm.isolates) {
225 226 227 228 229 230 231
    Map<String, Object> scriptList;
    try {
      final vm_service.ScriptList actualScriptList = await service.getScripts(isolateRef.id);
      scriptList = actualScriptList.json;
    } on vm_service.SentinelException {
      continue;
    }
232 233 234 235 236 237
    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.
238

239
    for (final Map<String, dynamic> script in (scriptList['scripts'] as List<dynamic>).cast<Map<String, dynamic>>()) {
240
      if (!libraryPredicate(script['uri'] as String)) {
241 242
        continue;
      }
243
      final String scriptId = script['id'] as String;
244
      futures.add(
245 246 247 248 249 250 251 252
        service.getSourceReport(
          isolateRef.id,
          <String>['Coverage'],
          scriptId: scriptId,
          forceCompile: true,
        )
        .then((vm_service.SourceReport report) {
          sourceReports[scriptId] = report.json;
253 254 255
        })
      );
      futures.add(
256 257 258 259 260
        service
          .getObject(isolateRef.id, scriptId)
          .then((vm_service.Obj script) {
            scripts[scriptId] = script.json;
          })
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275
      );
    }
    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>>{};
276
  for (final String scriptId in scripts.keys) {
277
    final Map<String, dynamic> sourceReport = sourceReports[scriptId];
278
    for (final Map<String, dynamic> range in (sourceReport['ranges'] as List<dynamic>).cast<Map<String, dynamic>>()) {
279
      final Map<String, dynamic> coverage = castStringKeyedMap(range['coverage']);
280
      // Coverage reports may sometimes be null for a Script.
281
      if (coverage == null) {
282 283
        continue;
      }
284 285
      final Map<String, dynamic> scriptRef = castStringKeyedMap(sourceReport['scripts'][range['scriptIndex']]);
      final String uri = scriptRef['uri'] as String;
286

287 288
      hitMaps[uri] ??= <int, int>{};
      final Map<int, int> hitMap = hitMaps[uri];
289 290 291
      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>;
292
      // The token positions can be null if the script has no lines that may be covered.
293 294 295
      if (tokenPositions == null) {
        continue;
      }
296
      if (hits != null) {
297
        for (final int hit in hits) {
298 299 300 301 302 303
          final int line = _lineAndColumn(hit, tokenPositions)[0];
          final int current = hitMap[line] ?? 0;
          hitMap[line] = current + 1;
        }
      }
      if (misses != null) {
304
        for (final int miss in misses) {
305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323
          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);
324
    final List<int> row = (tokenPositions[mid] as List<dynamic>).cast<int>();
325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357
    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;
}