451 lines
14 KiB
Dart
451 lines
14 KiB
Dart
|
|
// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file
|
||
|
|
// for details. 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 'dart:io';
|
||
|
|
|
||
|
|
import 'package:vm_service/vm_service.dart';
|
||
|
|
|
||
|
|
import 'hitmap.dart';
|
||
|
|
import 'isolate_paused_listener.dart';
|
||
|
|
import 'util.dart';
|
||
|
|
|
||
|
|
const _retryInterval = Duration(milliseconds: 200);
|
||
|
|
const _debugTokenPositions = bool.fromEnvironment('DEBUG_COVERAGE');
|
||
|
|
|
||
|
|
/// Collects coverage for all isolates in the running VM.
|
||
|
|
///
|
||
|
|
/// Collects a hit-map containing merged coverage for all isolates in the Dart
|
||
|
|
/// VM associated with the specified [serviceUri]. Returns a map suitable for
|
||
|
|
/// input to the coverage formatters that ship with this package.
|
||
|
|
///
|
||
|
|
/// [serviceUri] must specify the http/https URI of the service port of a
|
||
|
|
/// running Dart VM and must not be null.
|
||
|
|
///
|
||
|
|
/// If [resume] is true, all isolates will be resumed once coverage collection
|
||
|
|
/// is complete.
|
||
|
|
///
|
||
|
|
/// If [waitPaused] is true, collection will not begin for an isolate until it
|
||
|
|
/// is in the paused state.
|
||
|
|
///
|
||
|
|
/// If [includeDart] is true, code coverage for core `dart:*` libraries will be
|
||
|
|
/// collected.
|
||
|
|
///
|
||
|
|
/// If [functionCoverage] is true, function coverage information will be
|
||
|
|
/// collected.
|
||
|
|
///
|
||
|
|
/// If [branchCoverage] is true, branch coverage information will be collected.
|
||
|
|
/// This will only work correctly if the target VM was run with the
|
||
|
|
/// --branch-coverage flag.
|
||
|
|
///
|
||
|
|
/// If [scopedOutput] is non-empty, coverage will be restricted so that only
|
||
|
|
/// scripts that start with any of the provided paths are considered.
|
||
|
|
///
|
||
|
|
/// If [isolateIds] is set, the coverage gathering will be restricted to only
|
||
|
|
/// those VM isolates.
|
||
|
|
///
|
||
|
|
/// If [coverableLineCache] is set, the collector will avoid recompiling
|
||
|
|
/// libraries it has already seen (see VmService.getSourceReport's
|
||
|
|
/// librariesAlreadyCompiled parameter). This is only useful when doing more
|
||
|
|
/// than one [collect] call over the same libraries. Pass an empty map to the
|
||
|
|
/// first call, and then pass the same map to all subsequent calls.
|
||
|
|
///
|
||
|
|
/// [serviceOverrideForTesting] is for internal testing only, and should not be
|
||
|
|
/// set by users.
|
||
|
|
Future<Map<String, dynamic>> collect(Uri serviceUri, bool resume,
|
||
|
|
bool waitPaused, bool includeDart, Set<String>? scopedOutput,
|
||
|
|
{Set<String>? isolateIds,
|
||
|
|
Duration? timeout,
|
||
|
|
bool functionCoverage = false,
|
||
|
|
bool branchCoverage = false,
|
||
|
|
Map<String, Set<int>>? coverableLineCache,
|
||
|
|
VmService? serviceOverrideForTesting}) async {
|
||
|
|
scopedOutput ??= <String>{};
|
||
|
|
|
||
|
|
late VmService service;
|
||
|
|
if (serviceOverrideForTesting != null) {
|
||
|
|
service = serviceOverrideForTesting;
|
||
|
|
} else {
|
||
|
|
// Create websocket URI. Handle any trailing slashes.
|
||
|
|
final pathSegments =
|
||
|
|
serviceUri.pathSegments.where((c) => c.isNotEmpty).toList()..add('ws');
|
||
|
|
final uri = serviceUri.replace(scheme: 'ws', pathSegments: pathSegments);
|
||
|
|
|
||
|
|
await retry(() async {
|
||
|
|
try {
|
||
|
|
final options = const CompressionOptions(enabled: false);
|
||
|
|
final socket = await WebSocket.connect('$uri', compression: options);
|
||
|
|
final controller = StreamController<String>();
|
||
|
|
socket.listen((data) => controller.add(data as String), onDone: () {
|
||
|
|
controller.close();
|
||
|
|
service.dispose();
|
||
|
|
});
|
||
|
|
service = VmService(controller.stream, socket.add,
|
||
|
|
log: StdoutLog(), disposeHandler: socket.close);
|
||
|
|
await service.getVM().timeout(_retryInterval);
|
||
|
|
} on TimeoutException {
|
||
|
|
// The signature changed in vm_service version 6.0.0.
|
||
|
|
// ignore: await_only_futures
|
||
|
|
await service.dispose();
|
||
|
|
rethrow;
|
||
|
|
}
|
||
|
|
}, _retryInterval, timeout: timeout);
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
return await _getAllCoverage(
|
||
|
|
service,
|
||
|
|
includeDart,
|
||
|
|
functionCoverage,
|
||
|
|
branchCoverage,
|
||
|
|
scopedOutput,
|
||
|
|
isolateIds,
|
||
|
|
coverableLineCache,
|
||
|
|
waitPaused);
|
||
|
|
} finally {
|
||
|
|
if (resume && !waitPaused) {
|
||
|
|
await _resumeIsolates(service);
|
||
|
|
}
|
||
|
|
// The signature changed in vm_service version 6.0.0.
|
||
|
|
// ignore: await_only_futures
|
||
|
|
await service.dispose();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<Map<String, dynamic>> _getAllCoverage(
|
||
|
|
VmService service,
|
||
|
|
bool includeDart,
|
||
|
|
bool functionCoverage,
|
||
|
|
bool branchCoverage,
|
||
|
|
Set<String> scopedOutput,
|
||
|
|
Set<String>? isolateIds,
|
||
|
|
Map<String, Set<int>>? coverableLineCache,
|
||
|
|
bool waitPaused) async {
|
||
|
|
final allCoverage = <Map<String, dynamic>>[];
|
||
|
|
|
||
|
|
final sourceReportKinds = [
|
||
|
|
SourceReportKind.kCoverage,
|
||
|
|
if (branchCoverage) SourceReportKind.kBranchCoverage,
|
||
|
|
];
|
||
|
|
|
||
|
|
final librariesAlreadyCompiled = coverableLineCache?.keys.toList();
|
||
|
|
|
||
|
|
// Program counters are shared between isolates in the same group. So we need
|
||
|
|
// to make sure we're only gathering coverage data for one isolate in each
|
||
|
|
// group, otherwise we'll double count the hits.
|
||
|
|
final coveredIsolateGroups = <String>{};
|
||
|
|
|
||
|
|
Future<void> collectIsolate(IsolateRef isolateRef) async {
|
||
|
|
if (!(isolateIds?.contains(isolateRef.id) ?? true)) return;
|
||
|
|
|
||
|
|
// coveredIsolateGroups is only relevant for the !waitPaused flow. The
|
||
|
|
// waitPaused flow achieves the same once-per-group behavior using the
|
||
|
|
// isLastIsolateInGroup flag.
|
||
|
|
final isolateGroupId = isolateRef.isolateGroupId;
|
||
|
|
if (isolateGroupId != null) {
|
||
|
|
if (coveredIsolateGroups.contains(isolateGroupId)) return;
|
||
|
|
coveredIsolateGroups.add(isolateGroupId);
|
||
|
|
}
|
||
|
|
|
||
|
|
late final SourceReport isolateReport;
|
||
|
|
try {
|
||
|
|
isolateReport = await service.getSourceReport(
|
||
|
|
isolateRef.id!,
|
||
|
|
sourceReportKinds,
|
||
|
|
forceCompile: true,
|
||
|
|
reportLines: true,
|
||
|
|
libraryFilters: scopedOutput.isNotEmpty
|
||
|
|
? List.from(scopedOutput.map((filter) => 'package:$filter/'))
|
||
|
|
: null,
|
||
|
|
librariesAlreadyCompiled: librariesAlreadyCompiled,
|
||
|
|
);
|
||
|
|
} on SentinelException {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
final coverage = await _processSourceReport(
|
||
|
|
service,
|
||
|
|
isolateRef,
|
||
|
|
isolateReport,
|
||
|
|
includeDart,
|
||
|
|
functionCoverage,
|
||
|
|
branchCoverage,
|
||
|
|
coverableLineCache,
|
||
|
|
scopedOutput);
|
||
|
|
allCoverage.addAll(coverage);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (waitPaused) {
|
||
|
|
await IsolatePausedListener(service,
|
||
|
|
(IsolateRef isolateRef, bool isLastIsolateInGroup) async {
|
||
|
|
if (isLastIsolateInGroup) {
|
||
|
|
await collectIsolate(isolateRef);
|
||
|
|
}
|
||
|
|
}, stderr.writeln)
|
||
|
|
.waitUntilAllExited();
|
||
|
|
} else {
|
||
|
|
for (final isolateRef in await getAllIsolates(service)) {
|
||
|
|
await collectIsolate(isolateRef);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return <String, dynamic>{'type': 'CodeCoverage', 'coverage': allCoverage};
|
||
|
|
}
|
||
|
|
|
||
|
|
Future _resumeIsolates(VmService service) async {
|
||
|
|
final vm = await service.getVM();
|
||
|
|
final futures = <Future>[];
|
||
|
|
for (var isolateRef in vm.isolates!) {
|
||
|
|
// Guard against sync as well as async errors: sync - when we are writing
|
||
|
|
// message to the socket, the socket might be closed; async - when we are
|
||
|
|
// waiting for the response, the socket again closes.
|
||
|
|
futures.add(Future.sync(() async {
|
||
|
|
final isolate = await service.getIsolate(isolateRef.id!);
|
||
|
|
if (isolate.pauseEvent!.kind != EventKind.kResume) {
|
||
|
|
await service.resume(isolateRef.id!);
|
||
|
|
}
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await Future.wait(futures);
|
||
|
|
} catch (_) {
|
||
|
|
// Ignore resume isolate failures
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Returns the line number to which the specified token position maps.
|
||
|
|
///
|
||
|
|
/// Performs a binary search within the script's token position table to locate
|
||
|
|
/// the line in question.
|
||
|
|
int? _getLineFromTokenPos(Script script, int tokenPos) {
|
||
|
|
// TODO(cbracken): investigate whether caching this lookup results in
|
||
|
|
// significant performance gains.
|
||
|
|
var min = 0;
|
||
|
|
var max = script.tokenPosTable!.length;
|
||
|
|
while (min < max) {
|
||
|
|
final mid = min + ((max - min) >> 1);
|
||
|
|
final row = script.tokenPosTable![mid];
|
||
|
|
if (row[1] > tokenPos) {
|
||
|
|
max = mid;
|
||
|
|
} else {
|
||
|
|
for (var i = 1; i < row.length; i += 2) {
|
||
|
|
if (row[i] == tokenPos) return row.first;
|
||
|
|
}
|
||
|
|
min = mid + 1;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Returns a JSON coverage list backward-compatible with pre-1.16.0 SDKs.
|
||
|
|
Future<List<Map<String, dynamic>>> _processSourceReport(
|
||
|
|
VmService service,
|
||
|
|
IsolateRef isolateRef,
|
||
|
|
SourceReport report,
|
||
|
|
bool includeDart,
|
||
|
|
bool functionCoverage,
|
||
|
|
bool branchCoverage,
|
||
|
|
Map<String, Set<int>>? coverableLineCache,
|
||
|
|
Set<String> scopedOutput) async {
|
||
|
|
final hitMaps = <Uri, HitMap>{};
|
||
|
|
final scripts = <ScriptRef, Script>{};
|
||
|
|
final libraries = <LibraryRef>{};
|
||
|
|
final needScripts = functionCoverage;
|
||
|
|
|
||
|
|
Future<Script?> getScript(ScriptRef? scriptRef) async {
|
||
|
|
if (scriptRef == null) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
if (!scripts.containsKey(scriptRef)) {
|
||
|
|
scripts[scriptRef] =
|
||
|
|
await service.getObject(isolateRef.id!, scriptRef.id!) as Script;
|
||
|
|
}
|
||
|
|
return scripts[scriptRef];
|
||
|
|
}
|
||
|
|
|
||
|
|
HitMap getHitMap(Uri scriptUri) => hitMaps.putIfAbsent(
|
||
|
|
scriptUri,
|
||
|
|
() => HitMap.empty(
|
||
|
|
functionCoverage: functionCoverage, branchCoverage: branchCoverage));
|
||
|
|
|
||
|
|
Future<void> processFunction(FuncRef funcRef) async {
|
||
|
|
final func = await service.getObject(isolateRef.id!, funcRef.id!) as Func;
|
||
|
|
if ((func.implicit ?? false) || (func.isAbstract ?? false)) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
final location = func.location;
|
||
|
|
if (location == null) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
final script = await getScript(location.script);
|
||
|
|
if (script == null) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
final funcName = await _getFuncName(service, isolateRef, func);
|
||
|
|
// TODO(liama): Is this still necessary, or is location.line valid?
|
||
|
|
final tokenPos = location.tokenPos!;
|
||
|
|
final line = _getLineFromTokenPos(script, tokenPos);
|
||
|
|
if (line == null) {
|
||
|
|
if (_debugTokenPositions) {
|
||
|
|
stderr.writeln(
|
||
|
|
'tokenPos $tokenPos in function ${funcRef.name} has no line '
|
||
|
|
'mapping for script ${script.uri!}');
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
final hits = getHitMap(Uri.parse(script.uri!));
|
||
|
|
hits.funcNames![line] = funcName;
|
||
|
|
}
|
||
|
|
|
||
|
|
for (var range in report.ranges!) {
|
||
|
|
final scriptRef = report.scripts![range.scriptIndex!];
|
||
|
|
final scriptUriString = scriptRef.uri;
|
||
|
|
if (!scopedOutput.includesScript(scriptUriString)) {
|
||
|
|
// Sometimes a range's script can be different to the function's script
|
||
|
|
// (eg mixins), so we have to re-check the scope filter.
|
||
|
|
// See https://github.com/dart-lang/tools/issues/530
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
final scriptUri = Uri.parse(scriptUriString!);
|
||
|
|
|
||
|
|
// If we have a coverableLineCache, use it in the same way we use
|
||
|
|
// SourceReportCoverage.misses: to add zeros to the coverage result for all
|
||
|
|
// the lines that don't have a hit. Afterwards, add all the lines that were
|
||
|
|
// hit or missed to the cache, so that the next coverage collection won't
|
||
|
|
// need to compile this library.
|
||
|
|
final coverableLines =
|
||
|
|
coverableLineCache?.putIfAbsent(scriptUriString, () => <int>{});
|
||
|
|
|
||
|
|
// Not returned in scripts section of source report.
|
||
|
|
if (scriptUri.scheme == 'evaluate') continue;
|
||
|
|
|
||
|
|
// Skip scripts from dart:.
|
||
|
|
if (!includeDart && scriptUri.scheme == 'dart') continue;
|
||
|
|
|
||
|
|
// Look up the hit maps for this script (shared across isolates).
|
||
|
|
final hits = getHitMap(scriptUri);
|
||
|
|
|
||
|
|
Script? script;
|
||
|
|
if (needScripts) {
|
||
|
|
script = await getScript(scriptRef);
|
||
|
|
if (script == null) continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// If the script's library isn't loaded, load it then look up all its funcs.
|
||
|
|
final libRef = script?.library;
|
||
|
|
if (functionCoverage && libRef != null && !libraries.contains(libRef)) {
|
||
|
|
libraries.add(libRef);
|
||
|
|
final library =
|
||
|
|
await service.getObject(isolateRef.id!, libRef.id!) as Library;
|
||
|
|
if (library.functions != null) {
|
||
|
|
for (var funcRef in library.functions!) {
|
||
|
|
await processFunction(funcRef);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (library.classes != null) {
|
||
|
|
for (var classRef in library.classes!) {
|
||
|
|
final clazz =
|
||
|
|
await service.getObject(isolateRef.id!, classRef.id!) as Class;
|
||
|
|
if (clazz.functions != null) {
|
||
|
|
for (var funcRef in clazz.functions!) {
|
||
|
|
await processFunction(funcRef);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Collect hits and misses.
|
||
|
|
final coverage = range.coverage;
|
||
|
|
|
||
|
|
if (coverage == null) continue;
|
||
|
|
|
||
|
|
void forEachLine(List<int>? tokenPositions, void Function(int line) body) {
|
||
|
|
if (tokenPositions == null) return;
|
||
|
|
for (final line in tokenPositions) {
|
||
|
|
body(line);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (coverableLines != null) {
|
||
|
|
for (final line in coverableLines) {
|
||
|
|
hits.lineHits.putIfAbsent(line, () => 0);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
forEachLine(coverage.hits, (line) {
|
||
|
|
hits.lineHits.increment(line);
|
||
|
|
coverableLines?.add(line);
|
||
|
|
if (hits.funcNames != null && hits.funcNames!.containsKey(line)) {
|
||
|
|
hits.funcHits!.increment(line);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
forEachLine(coverage.misses, (line) {
|
||
|
|
hits.lineHits.putIfAbsent(line, () => 0);
|
||
|
|
coverableLines?.add(line);
|
||
|
|
});
|
||
|
|
hits.funcNames?.forEach((line, funcName) {
|
||
|
|
hits.funcHits?.putIfAbsent(line, () => 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
final branches = range.branchCoverage;
|
||
|
|
if (branchCoverage && branches != null) {
|
||
|
|
forEachLine(branches.hits, (line) {
|
||
|
|
hits.branchHits!.increment(line);
|
||
|
|
});
|
||
|
|
forEachLine(branches.misses, (line) {
|
||
|
|
hits.branchHits!.putIfAbsent(line, () => 0);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Output JSON
|
||
|
|
final coverage = <Map<String, dynamic>>[];
|
||
|
|
hitMaps.forEach((uri, hits) {
|
||
|
|
coverage.add(hitmapToJson(hits, uri));
|
||
|
|
});
|
||
|
|
return coverage;
|
||
|
|
}
|
||
|
|
|
||
|
|
extension _MapExtension<T> on Map<T, int> {
|
||
|
|
void increment(T key) => this[key] = (this[key] ?? 0) + 1;
|
||
|
|
}
|
||
|
|
|
||
|
|
Future<String> _getFuncName(
|
||
|
|
VmService service, IsolateRef isolateRef, Func func) async {
|
||
|
|
if (func.name == null) {
|
||
|
|
return '${func.type}:${func.location!.tokenPos}';
|
||
|
|
}
|
||
|
|
final owner = func.owner;
|
||
|
|
if (owner is ClassRef) {
|
||
|
|
final cls = await service.getObject(isolateRef.id!, owner.id!) as Class;
|
||
|
|
if (cls.name != null) return '${cls.name}.${func.name}';
|
||
|
|
}
|
||
|
|
return func.name!;
|
||
|
|
}
|
||
|
|
|
||
|
|
class StdoutLog extends Log {
|
||
|
|
@override
|
||
|
|
void warning(String message) => print(message);
|
||
|
|
|
||
|
|
@override
|
||
|
|
void severe(String message) => print(message);
|
||
|
|
}
|
||
|
|
|
||
|
|
extension _ScopedOutput on Set<String> {
|
||
|
|
bool includesScript(String? scriptUriString) {
|
||
|
|
if (scriptUriString == null) return false;
|
||
|
|
|
||
|
|
// If the set is empty, it means the user didn't specify a --scope-output
|
||
|
|
// flag, so allow everything.
|
||
|
|
if (isEmpty) return true;
|
||
|
|
|
||
|
|
final scriptUri = Uri.parse(scriptUriString);
|
||
|
|
if (scriptUri.scheme != 'package') return false;
|
||
|
|
|
||
|
|
final scope = scriptUri.pathSegments.first;
|
||
|
|
return contains(scope);
|
||
|
|
}
|
||
|
|
}
|