Support filtering coverage by isolate ID (#270)

Adds an `isolateIDs` parameter to `collect()` that, when specified limits coverage collection to the specified isolate IDs. Defaults to collecting coverage for all IDs.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 015311d..558bb47 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.13.4
+ * Added a new named argument to `collect` for filtering the
+   coverage results by a set of VM isolate IDs.
+
 ## 0.13.3
 
  * Migrates implementation of VM service protocol library from
diff --git a/lib/src/collect.dart b/lib/src/collect.dart
index ee44388..e886824 100644
--- a/lib/src/collect.dart
+++ b/lib/src/collect.dart
@@ -31,9 +31,12 @@
 ///
 /// 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.
 Future<Map<String, dynamic>> collect(Uri serviceUri, bool resume,
     bool waitPaused, bool includeDart, Set<String> scopedOutput,
-    {Duration timeout}) async {
+    {Set<String> isolateIds, Duration timeout}) async {
   scopedOutput ??= Set<String>();
   if (serviceUri == null) throw ArgumentError('serviceUri must not be null');
 
@@ -63,7 +66,8 @@
       await _waitIsolatesPaused(service, timeout: timeout);
     }
 
-    return await _getAllCoverage(service, includeDart, scopedOutput);
+    return await _getAllCoverage(
+        service, includeDart, scopedOutput, isolateIds);
   } finally {
     if (resume) {
       await _resumeIsolates(service);
@@ -72,13 +76,14 @@
   }
 }
 
-Future<Map<String, dynamic>> _getAllCoverage(
-    VmService service, bool includeDart, Set<String> scopedOutput) async {
+Future<Map<String, dynamic>> _getAllCoverage(VmService service,
+    bool includeDart, Set<String> scopedOutput, Set<String> isolateIds) async {
   scopedOutput ??= Set<String>();
   final vm = await service.getVM();
   final allCoverage = <Map<String, dynamic>>[];
 
   for (var isolateRef in vm.isolates) {
+    if (isolateIds != null && !isolateIds.contains(isolateRef.id)) continue;
     if (scopedOutput.isNotEmpty) {
       final scripts = await service.getScripts(isolateRef.id);
       for (var script in scripts.scripts) {
diff --git a/pubspec.yaml b/pubspec.yaml
index 067288b..596a97e 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,5 +1,5 @@
 name: coverage
-version: 0.13.3-dev
+version: 0.13.4-dev
 author: Dart Team <misc@dartlang.org>
 description: Coverage data manipulation and formatting
 homepage: https://github.com/dart-lang/coverage
diff --git a/test/collect_coverage_api_test.dart b/test/collect_coverage_api_test.dart
index cf9f3d2..701fa5c 100644
--- a/test/collect_coverage_api_test.dart
+++ b/test/collect_coverage_api_test.dart
@@ -68,10 +68,31 @@
       expect(uri.path.startsWith('coverage'), isTrue);
     }
   });
+
+  test('collect_coverage_api with isolateIds', () async {
+    final Map<String, dynamic> json = await _collectCoverage(isolateIds: true);
+    expect(json.keys, unorderedEquals(<String>['type', 'coverage']));
+    expect(json, containsPair('type', 'CodeCoverage'));
+
+    final List coverage = json['coverage'];
+    expect(coverage, isNotEmpty);
+
+    final Map<String, dynamic> testAppCoverage =
+        _getScriptCoverage(coverage, 'test_app.dart');
+    List<int> hits = testAppCoverage['hits'];
+    _expectHitCount(hits, 44, 0);
+    _expectHitCount(hits, 48, 0);
+
+    final Map<String, dynamic> isolateCoverage =
+        _getScriptCoverage(coverage, 'test_app_isolate.dart');
+    hits = isolateCoverage['hits'];
+    _expectHitCount(hits, 9, 1);
+    _expectHitCount(hits, 16, 1);
+  });
 }
 
 Future<Map<String, dynamic>> _collectCoverage(
-    {Set<String> scopedOutput}) async {
+    {Set<String> scopedOutput, bool isolateIds = false}) async {
   scopedOutput ??= Set<String>();
   final openPort = await getOpenPort();
 
@@ -80,6 +101,7 @@
 
   // Capture the VM service URI.
   final serviceUriCompleter = Completer<Uri>();
+  final isolateIdCompleter = Completer<String>();
   sampleProcess.stdout
       .transform(utf8.decoder)
       .transform(LineSplitter())
@@ -90,8 +112,40 @@
         serviceUriCompleter.complete(serviceUri);
       }
     }
+    if (line.contains('isolateId = ')) {
+      isolateIdCompleter.complete(line.split(' = ')[1]);
+    }
   });
-  final Uri serviceUri = await serviceUriCompleter.future;
 
-  return collect(serviceUri, true, true, false, scopedOutput, timeout: timeout);
+  final Uri serviceUri = await serviceUriCompleter.future;
+  final String isolateId = await isolateIdCompleter.future;
+  final Set<String> isolateIdSet = isolateIds ? Set.of([isolateId]) : null;
+
+  return collect(serviceUri, true, true, false, scopedOutput,
+      timeout: timeout, isolateIds: isolateIdSet);
+}
+
+// Returns the first coverage hitmap for the script with with the specified
+// script filename, ignoring leading path.
+Map<String, dynamic> _getScriptCoverage(
+    List<Map<String, dynamic>> coverage, String filename) {
+  for (Map<String, dynamic> isolateCoverage in coverage) {
+    final Uri script = Uri.parse(isolateCoverage['script']['uri']);
+    if (script.pathSegments.last == filename) {
+      return isolateCoverage;
+    }
+  }
+  return null;
+}
+
+/// Tests that the specified hitmap has the specified hit count for the
+/// specified line.
+void _expectHitCount(List<int> hits, int line, int hitCount) {
+  final int hitIndex = hits.indexOf(line);
+  if (hitIndex < 0) {
+    fail('No hit count for line $line');
+  }
+  final int actual = hits[hitIndex + 1];
+  expect(actual, equals(hitCount),
+      reason: 'Expected line $line to have $hitCount hits, but found $actual.');
 }
diff --git a/test/lcov_test.dart b/test/lcov_test.dart
index 1c7e79f..d4bddd8 100644
--- a/test/lcov_test.dart
+++ b/test/lcov_test.dart
@@ -27,11 +27,11 @@
 
     final Map<int, int> sampleAppHitMap = hitmap[_sampleAppFileUri];
 
-    expect(sampleAppHitMap, containsPair(40, greaterThanOrEqualTo(1)),
+    expect(sampleAppHitMap, containsPair(44, greaterThanOrEqualTo(1)),
         reason: 'be careful if you modify the test file');
-    expect(sampleAppHitMap, containsPair(44, 0),
+    expect(sampleAppHitMap, containsPair(48, 0),
         reason: 'be careful if you modify the test file');
-    expect(sampleAppHitMap, isNot(contains(29)),
+    expect(sampleAppHitMap, isNot(contains(31)),
         reason: 'be careful if you modify the test file');
   });
 
diff --git a/test/test_files/test_app.dart b/test/test_files/test_app.dart
index 22204fe..3d7b79c 100644
--- a/test/test_files/test_app.dart
+++ b/test/test_files/test_app.dart
@@ -3,6 +3,7 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:developer';
 import 'dart:isolate';
 
 // explicitly using a package import to validate hitmap coverage of packages
@@ -24,6 +25,10 @@
 
   final Isolate isolate =
       await Isolate.spawn(isolateTask, [port.sendPort, 1, 2], paused: true);
+  await Service.controlWebServer(enable: true);
+  final isolateID = Service.getIsolateID(isolate);
+  print('isolateId = $isolateID');
+
   isolate.addOnExitListener(port.sendPort);
   isolate.resume(isolate.pauseCapability);