Use the new `IsolateRef.isolateGroupId` to speed up isolate deduping (#422)

* Use isolateRef.isolateGroupId to skip some RPCs

* Update the mock test

* Fix analysis

* Debugging the vm_service version of github CI

* Remove debugging
diff --git a/lib/src/collect.dart b/lib/src/collect.dart
index b0df843..f16f3f6 100644
--- a/lib/src/collect.dart
+++ b/lib/src/collect.dart
@@ -123,6 +123,7 @@
   final reportLines = _versionCheck(version, 3, 51);
   final branchCoverageSupported = _versionCheck(version, 3, 56);
   final libraryFilters = _versionCheck(version, 3, 57);
+  final fastIsoGroups = _versionCheck(version, 3, 61);
   if (branchCoverage && !branchCoverageSupported) {
     branchCoverage = false;
     stderr.writeln('Branch coverage was requested, but is not supported'
@@ -138,16 +139,20 @@
   // group, otherwise we'll double count the hits.
   final isolateOwnerGroup = <String, String>{};
   final coveredIsolateGroups = <String>{};
-  for (var isolateGroupRef in vm.isolateGroups!) {
-    final isolateGroup = await service.getIsolateGroup(isolateGroupRef.id!);
-    for (var isolateRef in isolateGroup.isolates!) {
-      isolateOwnerGroup[isolateRef.id!] = isolateGroupRef.id!;
+  if (!fastIsoGroups) {
+    for (var isolateGroupRef in vm.isolateGroups!) {
+      final isolateGroup = await service.getIsolateGroup(isolateGroupRef.id!);
+      for (var isolateRef in isolateGroup.isolates!) {
+        isolateOwnerGroup[isolateRef.id!] = isolateGroupRef.id!;
+      }
     }
   }
 
   for (var isolateRef in vm.isolates!) {
     if (isolateIds != null && !isolateIds.contains(isolateRef.id)) continue;
-    final isolateGroupId = isolateOwnerGroup[isolateRef.id];
+    final isolateGroupId = fastIsoGroups
+        ? isolateRef.isolateGroupId
+        : isolateOwnerGroup[isolateRef.id];
     if (isolateGroupId != null) {
       if (coveredIsolateGroups.contains(isolateGroupId)) continue;
       coveredIsolateGroups.add(isolateGroupId);
diff --git a/pubspec.yaml b/pubspec.yaml
index c40c923..fc340ee 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -13,7 +13,7 @@
   path: ^1.8.0
   source_maps: ^0.10.10
   stack_trace: ^1.10.0
-  vm_service: ^9.2.0
+  vm_service: ^9.4.0
 
 dev_dependencies:
   benchmark_harness: ^2.2.0
diff --git a/test/README.md b/test/README.md
new file mode 100644
index 0000000..8fa00df
--- /dev/null
+++ b/test/README.md
@@ -0,0 +1,9 @@
+## Regenerating mocks
+
+Some of the tests use a mock VmService that is automatically generated by
+Mockito. If the VmService changes, run this command in the root directory of
+this repo to regenerate that mock:
+
+```bash
+dart run build_runner build
+```
diff --git a/test/collect_coverage_mock_test.dart b/test/collect_coverage_mock_test.dart
index 162ed9d..3e02e9e 100644
--- a/test/collect_coverage_mock_test.dart
+++ b/test/collect_coverage_mock_test.dart
@@ -10,70 +10,51 @@
 
 import 'collect_coverage_mock_test.mocks.dart';
 
+@GenerateMocks([VmService])
 SourceReportRange _range(int scriptIndex, SourceReportCoverage coverage) =>
-    SourceReportRange(
-      scriptIndex: scriptIndex,
-      startPos: null,
-      endPos: null,
-      compiled: null,
-      error: null,
-      coverage: coverage,
-      possibleBreakpoints: null,
-      branchCoverage: null,
-    );
+    SourceReportRange(scriptIndex: scriptIndex, coverage: coverage);
 
-Script _script(List<List<int>> tokenPosTable) => Script(
-      uri: null,
-      library: null,
-      id: '',
-      lineOffset: null,
-      columnOffset: null,
-      source: null,
-      tokenPosTable: tokenPosTable,
-    );
+Script _script(List<List<int>> tokenPosTable) =>
+    Script(id: 'script', tokenPosTable: tokenPosTable);
 
-MockVmService _mockService(int majorVersion, int minorVersion) {
+IsolateRef _isoRef(String id, String isoGroupId) =>
+    IsolateRef(id: id, isolateGroupId: isoGroupId);
+
+IsolateGroupRef _isoGroupRef(String id) => IsolateGroupRef(id: id);
+
+IsolateGroup _isoGroup(String id, List<IsolateRef> isolates) =>
+    IsolateGroup(id: id, isolates: isolates);
+
+MockVmService _mockService(
+  int majorVersion,
+  int minorVersion, {
+  Map<String, List<String>> isolateGroups = const {
+    'isolateGroup': ['isolate'],
+  },
+}) {
   final service = MockVmService();
-  final isoRef = IsolateRef(
-    id: 'isolate',
-    number: null,
-    name: null,
-    isSystemIsolate: null,
-  );
-  final isoGroupRef = IsolateGroupRef(
-    id: 'isolateGroup',
-    number: null,
-    name: null,
-    isSystemIsolateGroup: null,
-  );
-  when(service.getVM()).thenAnswer((_) async => VM(
-        name: null,
-        architectureBits: null,
-        hostCPU: null,
-        operatingSystem: null,
-        targetCPU: null,
-        version: null,
-        pid: null,
-        startTime: null,
-        isolates: [isoRef],
-        isolateGroups: [isoGroupRef],
-        systemIsolates: null,
-        systemIsolateGroups: null,
-      ));
-  when(service.getIsolateGroup('isolateGroup'))
-      .thenAnswer((_) async => IsolateGroup(
-            id: 'isolateGroup',
-            number: null,
-            name: null,
-            isSystemIsolateGroup: null,
-            isolates: [isoRef],
-          ));
+  final isoRefs = <IsolateRef>[];
+  final isoGroupRefs = <IsolateGroupRef>[];
+  final isoGroups = <IsolateGroup>[];
+  for (final group in isolateGroups.entries) {
+    isoGroupRefs.add(_isoGroupRef(group.key));
+    final isosOfGroup = <IsolateRef>[];
+    for (final isoId in group.value) {
+      isosOfGroup.add(_isoRef(isoId, group.key));
+    }
+    isoGroups.add(_isoGroup(group.key, isosOfGroup));
+    isoRefs.addAll(isosOfGroup);
+  }
+  when(service.getVM()).thenAnswer(
+      (_) async => VM(isolates: isoRefs, isolateGroups: isoGroupRefs));
+  for (final group in isoGroups) {
+    when(service.getIsolateGroup(group.id)).thenAnswer((_) async => group);
+  }
   when(service.getVersion()).thenAnswer(
       (_) async => Version(major: majorVersion, minor: minorVersion));
   return service;
 }
 
-@GenerateMocks([VmService])
 void main() {
   group('Mock VM Service', () {
     test('Collect coverage', () async {
@@ -251,5 +232,145 @@
       expect(result.length, 1);
       expect(result['package:foo/foo.dart']?.lineHits, {12: 1, 47: 0});
     });
+
+    test('Collect coverage, old isolate group deduping', () async {
+      final service = _mockService(3, 60, isolateGroups: {
+        'isolateGroupA': ['isolate1', 'isolate2'],
+        'isolateGroupB': ['isolate3'],
+      });
+      when(service.getSourceReport('isolate1', ['Coverage'],
+              forceCompile: true, reportLines: true))
+          .thenAnswer((_) async => SourceReport(
+                ranges: [
+                  _range(
+                    0,
+                    SourceReportCoverage(
+                      hits: [12],
+                      misses: [47],
+                    ),
+                  ),
+                  _range(
+                    1,
+                    SourceReportCoverage(
+                      hits: [95],
+                      misses: [52],
+                    ),
+                  ),
+                ],
+                scripts: [
+                  ScriptRef(
+                    uri: 'package:foo/foo.dart',
+                    id: 'foo',
+                  ),
+                  ScriptRef(
+                    uri: 'package:bar/bar.dart',
+                    id: 'bar',
+                  ),
+                ],
+              ));
+      when(service.getSourceReport('isolate3', ['Coverage'],
+              forceCompile: true, reportLines: true))
+          .thenAnswer((_) async => SourceReport(
+                ranges: [
+                  _range(
+                    0,
+                    SourceReportCoverage(
+                      hits: [34],
+                      misses: [61],
+                    ),
+                  ),
+                ],
+                scripts: [
+                  ScriptRef(
+                    uri: 'package:baz/baz.dart',
+                    id: 'baz',
+                  ),
+                ],
+              ));
+
+      final jsonResult = await collect(Uri(), false, false, false, null,
+          serviceOverrideForTesting: service);
+      final result = await HitMap.parseJson(
+          jsonResult['coverage'] as List<Map<String, dynamic>>);
+
+      expect(result.length, 3);
+      expect(result['package:foo/foo.dart']?.lineHits, {12: 1, 47: 0});
+      expect(result['package:bar/bar.dart']?.lineHits, {95: 1, 52: 0});
+      expect(result['package:baz/baz.dart']?.lineHits, {34: 1, 61: 0});
+      verifyNever(service.getSourceReport('isolate2', ['Coverage'],
+          forceCompile: true, reportLines: true));
+      verify(service.getIsolateGroup('isolateGroupA'));
+      verify(service.getIsolateGroup('isolateGroupB'));
+    });
+
+    test('Collect coverage, fast isolate group deduping', () async {
+      final service = _mockService(3, 61, isolateGroups: {
+        'isolateGroupA': ['isolate1', 'isolate2'],
+        'isolateGroupB': ['isolate3'],
+      });
+      when(service.getSourceReport('isolate1', ['Coverage'],
+              forceCompile: true, reportLines: true))
+          .thenAnswer((_) async => SourceReport(
+                ranges: [
+                  _range(
+                    0,
+                    SourceReportCoverage(
+                      hits: [12],
+                      misses: [47],
+                    ),
+                  ),
+                  _range(
+                    1,
+                    SourceReportCoverage(
+                      hits: [95],
+                      misses: [52],
+                    ),
+                  ),
+                ],
+                scripts: [
+                  ScriptRef(
+                    uri: 'package:foo/foo.dart',
+                    id: 'foo',
+                  ),
+                  ScriptRef(
+                    uri: 'package:bar/bar.dart',
+                    id: 'bar',
+                  ),
+                ],
+              ));
+      when(service.getSourceReport('isolate3', ['Coverage'],
+              forceCompile: true, reportLines: true))
+          .thenAnswer((_) async => SourceReport(
+                ranges: [
+                  _range(
+                    0,
+                    SourceReportCoverage(
+                      hits: [34],
+                      misses: [61],
+                    ),
+                  ),
+                ],
+                scripts: [
+                  ScriptRef(
+                    uri: 'package:baz/baz.dart',
+                    id: 'baz',
+                  ),
+                ],
+              ));
+
+      final jsonResult = await collect(Uri(), false, false, false, null,
+          serviceOverrideForTesting: service);
+      final result = await HitMap.parseJson(
+          jsonResult['coverage'] as List<Map<String, dynamic>>);
+
+      expect(result.length, 3);
+      expect(result['package:foo/foo.dart']?.lineHits, {12: 1, 47: 0});
+      expect(result['package:bar/bar.dart']?.lineHits, {95: 1, 52: 0});
+      expect(result['package:baz/baz.dart']?.lineHits, {34: 1, 61: 0});
+      verifyNever(service.getSourceReport('isolate2', ['Coverage'],
+          forceCompile: true, reportLines: true));
+      verifyNever(service.getIsolateGroup('isolateGroupA'));
+      verifyNever(service.getIsolateGroup('isolateGroupB'));
+    });
   });
 }
diff --git a/test/collect_coverage_mock_test.mocks.dart b/test/collect_coverage_mock_test.mocks.dart
index 0338186..4ee0971 100644
--- a/test/collect_coverage_mock_test.mocks.dart
+++ b/test/collect_coverage_mock_test.mocks.dart
@@ -1,4 +1,4 @@
-// Mocks generated by Mockito 5.1.0 from annotations
+// Mocks generated by Mockito 5.2.0 from annotations
 // in coverage/test/collect_coverage_mock_test.dart.
 // Do not manually edit this file.