Chrome coverage support (#1155)

Support coverage collection for the Chrome platform. Coverage information is output to `.chrome.json` in a format suitable for consumption by `package:coverage`.

Closes https://github.com/dart-lang/test/issues/36
diff --git a/pkgs/test/CHANGELOG.md b/pkgs/test/CHANGELOG.md
index f493135..3f2c7b2 100644
--- a/pkgs/test/CHANGELOG.md
+++ b/pkgs/test/CHANGELOG.md
@@ -1,8 +1,10 @@
-## 1.11.2-dev
+## 1.12.0-dev
 
 * Bump minimum SDK to `2.4.0` for safer usage of for-loop elements.
 * Deprecate `PhantomJS` and provide warning when used. Support for `PhantomJS`
   will be removed in version `2.0.0`.
+* Support coverage collection for the Chrome platform. See `README.md` for usage
+  details.
 
 ## 1.11.1
 
diff --git a/pkgs/test/README.md b/pkgs/test/README.md
index a661323..6e17409 100644
--- a/pkgs/test/README.md
+++ b/pkgs/test/README.md
@@ -186,7 +186,7 @@
 
 ### Collecting Code Coverage
 To collect code coverage, you can run tests with the `--coverage <directory>`
-argument. The directory specified can be an absolute or relative path. 
+argument. The directory specified can be an absolute or relative path.
 If a directory does not exist at the path specified, a directory will be
 created. If a directory does exist, files may be overwritten with the latest
 coverage data, if they conflict.
@@ -196,7 +196,8 @@
 The files can then be formatted using the `package:coverage`
 `format_coverage` executable.
 
-Coverage gathering is currently only implemented for tests run in the Dart VM.
+Coverage gathering is currently only implemented for tests run on the Dart VM or
+Chrome.
 
 ### Restricting Tests to Certain Platforms
 
@@ -765,7 +766,7 @@
 
 Tests can be debugged interactively using platforms' built-in development tools.
 Tests running on browsers can use those browsers' development consoles to inspect
-the document, set breakpoints, and step through code. Those running on the Dart 
+the document, set breakpoints, and step through code. Those running on the Dart
 VM use [the Dart Observatory][observatory]'s .
 
 [observatory]: https://dart-lang.github.io/observatory/
diff --git a/pkgs/test/lib/src/runner/browser/browser_manager.dart b/pkgs/test/lib/src/runner/browser/browser_manager.dart
index ce8b74d..2339908 100644
--- a/pkgs/test/lib/src/runner/browser/browser_manager.dart
+++ b/pkgs/test/lib/src/runner/browser/browser_manager.dart
@@ -8,17 +8,15 @@
 import 'package:async/async.dart';
 import 'package:pool/pool.dart';
 import 'package:stream_channel/stream_channel.dart';
-import 'package:web_socket_channel/web_socket_channel.dart';
-
 import 'package:test_api/src/backend/runtime.dart'; // ignore: implementation_imports
 import 'package:test_api/src/util/stack_trace_mapper.dart'; // ignore: implementation_imports
-import 'package:test_core/src/runner/runner_suite.dart'; // ignore: implementation_imports
-import 'package:test_core/src/runner/environment.dart'; // ignore: implementation_imports
-import 'package:test_core/src/runner/suite.dart'; // ignore: implementation_imports
-
 import 'package:test_core/src/runner/application_exception.dart'; // ignore: implementation_imports
+import 'package:test_core/src/runner/environment.dart'; // ignore: implementation_imports
 import 'package:test_core/src/runner/plugin/platform_helpers.dart'; // ignore: implementation_imports
+import 'package:test_core/src/runner/runner_suite.dart'; // ignore: implementation_imports
+import 'package:test_core/src/runner/suite.dart'; // ignore: implementation_imports
 import 'package:test_core/src/util/io.dart'; // ignore: implementation_imports
+import 'package:web_socket_channel/web_socket_channel.dart';
 
 import '../executable_settings.dart';
 import 'browser.dart';
@@ -238,8 +236,17 @@
       });
 
       try {
-        controller = deserializeSuite(path, currentPlatform(_runtime),
-            suiteConfig, await _environment, suiteChannel, message);
+        controller = deserializeSuite(
+            path,
+            currentPlatform(_runtime),
+            suiteConfig,
+            await _environment,
+            suiteChannel,
+            message, gatherCoverage: () async {
+          var browser = _browser;
+          if (browser is Chrome) return browser.gatherCoverage();
+          return {};
+        });
 
         controller.channel('test.browser.mapper').sink.add(mapper?.serialize());
 
diff --git a/pkgs/test/lib/src/runner/browser/chrome.dart b/pkgs/test/lib/src/runner/browser/chrome.dart
index de76d35..624e2b5 100644
--- a/pkgs/test/lib/src/runner/browser/chrome.dart
+++ b/pkgs/test/lib/src/runner/browser/chrome.dart
@@ -3,17 +3,21 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'dart:async';
+import 'dart:convert';
 import 'dart:io';
 
+import 'package:coverage/coverage.dart';
+import 'package:http/http.dart' as http;
+import 'package:path/path.dart' as p;
 import 'package:pedantic/pedantic.dart';
 import 'package:test_api/src/backend/runtime.dart'; // ignore: implementation_imports
 import 'package:test_core/src/util/io.dart'; // ignore: implementation_imports
+import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart';
 
 import '../executable_settings.dart';
 import 'browser.dart';
 import 'default_settings.dart';
 
-// TODO(nweiz): move this into its own package?
 /// A class for running an instance of Chrome.
 ///
 /// Most of the communication with the browser is expected to happen via HTTP,
@@ -28,11 +32,16 @@
   @override
   final Future<Uri> remoteDebuggerUrl;
 
+  final Future<WipConnection> _tabConnection;
+  final Map<String, String> _idToUrl;
+
   /// Starts a new instance of Chrome open to the given [url], which may be a
   /// [Uri] or a [String].
   factory Chrome(Uri url, {ExecutableSettings settings, bool debug = false}) {
     settings ??= defaultSettings[Runtime.chrome];
     var remoteDebuggerCompleter = Completer<Uri>.sync();
+    var connectionCompleter = Completer<WipConnection>();
+    var idToUrl = <String, String>{};
     return Chrome._(() async {
       var tryPort = ([int port]) async {
         var dir = createTempDir();
@@ -73,6 +82,8 @@
         if (port != null) {
           remoteDebuggerCompleter.complete(
               getRemoteDebuggerUrl(Uri.parse('http://localhost:$port')));
+
+          connectionCompleter.complete(_connect(process, port, idToUrl));
         } else {
           remoteDebuggerCompleter.complete(null);
         }
@@ -85,9 +96,83 @@
 
       if (!debug) return tryPort();
       return getUnusedPort<Process>(tryPort);
-    }, remoteDebuggerCompleter.future);
+    }, remoteDebuggerCompleter.future, connectionCompleter.future, idToUrl);
   }
 
-  Chrome._(Future<Process> Function() startBrowser, this.remoteDebuggerUrl)
+  /// Returns a Dart based hit-map containing coverage report, suitable for use
+  /// with `package:coverage`.
+  Future<Map<String, dynamic>> gatherCoverage() async {
+    var tabConnection = await _tabConnection;
+    var response = await tabConnection.debugger.connection
+        .sendCommand('Profiler.takePreciseCoverage', {});
+    var result = response.result['result'];
+    var coverage = await parseChromeCoverage(
+      (result as List).cast(),
+      _sourceProvider,
+      _sourceMapProvider,
+      _sourceUriProvider,
+    );
+    return coverage;
+  }
+
+  Chrome._(Future<Process> Function() startBrowser, this.remoteDebuggerUrl,
+      this._tabConnection, this._idToUrl)
       : super(startBrowser);
+
+  Future<Uri> _sourceUriProvider(String sourceUrl, String scriptId) async {
+    var script = _idToUrl[scriptId];
+    if (script == null) return null;
+    var uri = Uri.parse(script);
+    var path = p.join(
+        p.joinAll(uri.pathSegments.sublist(1, uri.pathSegments.length - 1)),
+        sourceUrl);
+    return path.contains('/packages/')
+        ? Uri(scheme: 'package', path: path.split('/packages/').last)
+        : Uri.file(p.absolute(path));
+  }
+
+  Future<String> _sourceMapProvider(String scriptId) async {
+    var script = _idToUrl[scriptId];
+    if (script == null) return null;
+    var mapResponse = await http.get('$script.map');
+    if (mapResponse.statusCode != HttpStatus.ok) return null;
+    return mapResponse.body;
+  }
+
+  Future<String> _sourceProvider(String scriptId) async {
+    var script = _idToUrl[scriptId];
+    if (script == null) return null;
+    var scriptResponse = await http.get(script);
+    if (scriptResponse.statusCode != HttpStatus.ok) return null;
+    return scriptResponse.body;
+  }
+}
+
+Future<WipConnection> _connect(
+    Process process, int port, Map<String, String> idToUrl) async {
+  // Wait for Chrome to be in a ready state.
+  await process.stderr
+      .transform(utf8.decoder)
+      .transform(LineSplitter())
+      .firstWhere((line) => line.startsWith('DevTools listening'));
+
+  var chromeConnection = ChromeConnection('localhost', port);
+  var tab = (await chromeConnection.getTabs()).first;
+  var tabConnection = await tab.connect();
+
+  // Enable debugging.
+  await tabConnection.debugger.enable();
+
+  // Coverage reports are in terms of scriptIds so keep note of URLs.
+  tabConnection.debugger.onScriptParsed.listen((data) {
+    var script = data.script;
+    if (script.url.isNotEmpty) idToUrl[script.scriptId] = script.url;
+  });
+
+  // Enable coverage collection.
+  await tabConnection.debugger.connection.sendCommand('Profiler.enable', {});
+  await tabConnection.debugger.connection.sendCommand(
+      'Profiler.startPreciseCoverage', {'detailed': true, 'callCount': false});
+
+  return tabConnection;
 }
diff --git a/pkgs/test/pubspec.yaml b/pkgs/test/pubspec.yaml
index feb3f4b..789b4a4 100644
--- a/pkgs/test/pubspec.yaml
+++ b/pkgs/test/pubspec.yaml
@@ -1,5 +1,5 @@
 name: test
-version: 1.11.2-dev
+version: 1.12.0-dev
 description: A full featured library for writing and running Dart tests.
 homepage: https://github.com/dart-lang/test/blob/master/pkgs/test
 
@@ -10,6 +10,7 @@
   analyzer: ">=0.36.0 <0.40.0"
   async: ^2.0.0
   boolean_selector: ">=1.0.0 <3.0.0"
+  coverage: ^0.13.4
   http_multi_server: ^2.0.0
   io: ^0.3.0
   js: ^0.6.0
@@ -28,6 +29,7 @@
   stream_channel: ">=1.7.0 <3.0.0"
   typed_data: ^1.0.0
   web_socket_channel: ^1.0.0
+  webkit_inspection_protocol: ^0.5.0
   yaml: ^2.0.0
   # Use an exact version until the test_api and test_core package are stable.
   test_api: 0.2.14
diff --git a/pkgs/test/test/runner/coverage_test.dart b/pkgs/test/test/runner/coverage_test.dart
index edd1c03..36177e4 100644
--- a/pkgs/test/test/runner/coverage_test.dart
+++ b/pkgs/test/test/runner/coverage_test.dart
@@ -11,12 +11,26 @@
 
 import 'package:path/path.dart' as p;
 import 'package:test/test.dart';
+import 'package:test_process/test_process.dart';
 
 import '../io.dart';
 
 void main() {
   group('with the --coverage flag,', () {
-    test('gathers coverage for VM tests', () async {
+    Directory coverageDirectory;
+
+    Future<void> _validateCoverage(
+        TestProcess test, String coveragePath) async {
+      expect(test.stdout, emitsThrough(contains('+1: All tests passed!')));
+      await test.shouldExit(0);
+
+      final coverageFile = File(p.join(coverageDirectory.path, coveragePath));
+      final coverage = await coverageFile.readAsString();
+      final jsonCoverage = json.decode(coverage);
+      expect(jsonCoverage['coverage'], isNotEmpty);
+    }
+
+    setUp(() async {
       await d.file('test.dart', '''
         import 'package:test/test.dart';
 
@@ -27,24 +41,24 @@
         }
       ''').create();
 
-      final coverageDirectory =
-          Directory(p.join(Directory.current.path, 'test_coverage'));
-      expect(await coverageDirectory.exists(), isFalse,
-          reason:
-              'Coverage directory exists, cannot safely run coverage tests. Delete the ${coverageDirectory.path} directory to fix.');
+      coverageDirectory =
+          await Directory.systemTemp.createTemp('test_coverage');
+    });
 
+    tearDown(() async {
+      await coverageDirectory.delete(recursive: true);
+    });
+
+    test('gathers coverage for VM tests', () async {
       var test =
           await runTest(['--coverage', coverageDirectory.path, 'test.dart']);
-      expect(test.stdout, emitsThrough(contains('+1: All tests passed!')));
-      await test.shouldExit(0);
+      await _validateCoverage(test, 'test.dart.vm.json');
+    });
 
-      final coverageFile =
-          File(p.join(coverageDirectory.path, 'test.dart.vm.json'));
-      final coverage = await coverageFile.readAsString();
-      final jsonCoverage = json.decode(coverage);
-      expect(jsonCoverage['coverage'], isNotEmpty);
-
-      await coverageDirectory.delete(recursive: true);
+    test('gathers coverage for Chrome tests', () async {
+      var test = await runTest(
+          ['--coverage', coverageDirectory.path, 'test.dart', '-p', 'chrome']);
+      await _validateCoverage(test, 'test.dart.chrome.json');
     });
   });
 }