Add support for --coverage to flutter test (#4679)

We need https://github.com/dart-lang/coverage/issues/100 to be fixed before
this will be useful.

Fixes #2342
diff --git a/packages/flutter_tools/lib/src/commands/test.dart b/packages/flutter_tools/lib/src/commands/test.dart
index ca3c81e..d94b7a4 100644
--- a/packages/flutter_tools/lib/src/commands/test.dart
+++ b/packages/flutter_tools/lib/src/commands/test.dart
@@ -13,12 +13,21 @@
 import '../dart/package_map.dart';
 import '../globals.dart';
 import '../runner/flutter_command.dart';
+import '../test/coverage_collector.dart';
 import '../test/flutter_platform.dart' as loader;
 import '../toolchain.dart';
 
 class TestCommand extends FlutterCommand {
   TestCommand() {
     usesPubOption();
+    argParser.addFlag('coverage',
+      defaultsTo: false,
+      help: 'Whether to collect coverage information.'
+    );
+    argParser.addOption('coverage-path',
+      defaultsTo: 'coverage/lcov.info',
+      help: 'Where to store coverage information (if coverage is enabled).'
+    );
   }
 
   @override
@@ -67,6 +76,7 @@
       printTrace('running test package with arguments: $testArgs');
       await executable.main(testArgs);
       printTrace('test package returned with exit code $exitCode');
+
       return exitCode;
     } finally {
       Directory.current = currentDirectory;
@@ -96,6 +106,9 @@
     if (!terminal.supportsColor)
       testArgs.insert(0, '--no-color');
 
+    if (argResults['coverage'])
+      testArgs.insert(0, '--concurrency=1');
+
     loader.installHook();
     loader.shellPath = tools.getHostToolPath(HostTool.SkyShell);
     if (!FileSystemEntity.isFileSync(loader.shellPath)) {
@@ -105,6 +118,23 @@
 
     Cache.releaseLockEarly();
 
-    return await _runTests(testArgs, testDir);
+    CoverageCollector collector = CoverageCollector.instance;
+    collector.enabled = argResults['coverage'];
+
+    int result = await _runTests(testArgs, testDir);
+
+    if (collector.enabled) {
+      Status status = logger.startProgress("Collecting coverage information...");
+      String coverageData = await collector.finalizeCoverage();
+      status.stop(showElapsedTime: true);
+
+      String coveragePath = argResults['coverage-path'];
+      new File(coveragePath)
+        ..createSync(recursive: true)
+        ..writeAsStringSync(coverageData, flush: true);
+      printTrace('wrote coverage data to $coveragePath (size=${coverageData.length})');
+    }
+
+    return result;
   }
 }
diff --git a/packages/flutter_tools/lib/src/test/coverage_collector.dart b/packages/flutter_tools/lib/src/test/coverage_collector.dart
new file mode 100644
index 0000000..64e67a6
--- /dev/null
+++ b/packages/flutter_tools/lib/src/test/coverage_collector.dart
@@ -0,0 +1,71 @@
+// Copyright 2016 The Chromium Authors. 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:coverage/coverage.dart';
+import 'package:path/path.dart' as path;
+
+import '../globals.dart';
+
+class CoverageCollector {
+  static final CoverageCollector instance = new CoverageCollector();
+
+  bool enabled = false;
+
+  void collectCoverage({
+    String host,
+    int port,
+    Process processToKill
+  }) {
+    if (enabled) {
+      assert(_jobs != null);
+      _jobs.add(_startJob(
+        host: host,
+        port: port,
+        processToKill: processToKill
+      ));
+    } else {
+      processToKill.kill();
+    }
+  }
+
+  Future<Null> _startJob({
+    String host,
+    int port,
+    Process processToKill
+  }) async {
+    int pid = processToKill.pid;
+    printTrace('collecting coverage data from pid $pid on port $port');
+    Map<String, dynamic> data = await collect(host, port, false, false);
+    printTrace('done collecting coverage data from pid $pid');
+    processToKill.kill();
+    Map<String, dynamic> hitmap = createHitmap(data['coverage']);
+    if (_globalHitmap == null)
+      _globalHitmap = hitmap;
+    else
+      mergeHitmaps(hitmap, _globalHitmap);
+    printTrace('done merging data from pid $pid into global coverage map');
+  }
+
+  Future<Null> finishPendingJobs() async {
+    await Future.wait(_jobs.toList(), eagerError: true);
+  }
+
+  List<Future<Null>> _jobs = <Future<Null>>[];
+  Map<String, dynamic> _globalHitmap;
+
+  Future<String> finalizeCoverage() async {
+    assert(enabled);
+    await finishPendingJobs();
+    printTrace('formating coverage data');
+    // TODO(abarth): Use PackageMap.globalPackagesPath once
+    // https://github.com/dart-lang/coverage/issues/100 is fixed.
+    Resolver resolver = new Resolver(packageRoot: path.absolute('packages'));
+    Formatter formater = new LcovFormatter(resolver);
+    List<String> reportOn = <String>[path.join(Directory.current.path, 'lib')];
+    return await formater.format(_globalHitmap, reportOn: reportOn);
+  }
+}
diff --git a/packages/flutter_tools/lib/src/test/flutter_platform.dart b/packages/flutter_tools/lib/src/test/flutter_platform.dart
index 28b1671..58a6a82 100644
--- a/packages/flutter_tools/lib/src/test/flutter_platform.dart
+++ b/packages/flutter_tools/lib/src/test/flutter_platform.dart
@@ -5,6 +5,7 @@
 import 'dart:async';
 import 'dart:convert';
 import 'dart:io';
+import 'dart:math' as math;
 
 import 'package:async/async.dart';
 import 'package:path/path.dart' as path;
@@ -16,6 +17,7 @@
 
 import '../dart/package_map.dart';
 import '../globals.dart';
+import 'coverage_collector.dart';
 
 final String _kSkyShell = Platform.environment['SKY_SHELL'];
 const String _kHost = '127.0.0.1';
@@ -45,15 +47,20 @@
   return new _ServerInfo(server, 'ws://$_kHost:${server.port}$_kPath', socket.future);
 }
 
-Future<Process> _startProcess(String mainPath, { String packages }) {
+Future<Process> _startProcess(String mainPath, { String packages, int observatoryPort }) {
   assert(shellPath != null || _kSkyShell != null); // Please provide the path to the shell in the SKY_SHELL environment variable.
   String executable = shellPath ?? _kSkyShell;
-  List<String> arguments = <String>[
+  List<String> arguments = <String>[];
+  if (observatoryPort != null) {
+    arguments.add('--observatory-port=$observatoryPort');
+  } else {
+    arguments.add('--non-interactive');
+  }
+  arguments.addAll(<String>[
     '--enable-checked-mode',
-    '--non-interactive',
     '--packages=$packages',
     mainPath
-  ];
+  ]);
   printTrace('$executable ${arguments.join(' ')}');
   return Process.start(executable, arguments, environment: <String, String>{ 'FLUTTER_TEST': 'true' });
 }
@@ -105,8 +112,16 @@
 }
 ''');
 
+    int observatoryPort;
+    if (CoverageCollector.instance.enabled) {
+      observatoryPort = new math.Random().nextInt(30000) + 2000;
+      await CoverageCollector.instance.finishPendingJobs();
+    }
+
     Process process = await _startProcess(
-      listenerFile.path, packages: PackageMap.globalPackagesPath
+      listenerFile.path,
+      packages: PackageMap.globalPackagesPath,
+      observatoryPort: observatoryPort
     );
 
     _attachStandardStreams(process);
@@ -115,7 +130,11 @@
       if (process != null) {
         Process processToKill = process;
         process = null;
-        processToKill.kill();
+        CoverageCollector.instance.collectCoverage(
+          host: _kHost,
+          port: observatoryPort,
+          processToKill: processToKill
+        );
       }
       if (tempDir != null) {
         Directory dirToDelete = tempDir;
diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml
index fd2fd2a..cbc6747 100644
--- a/packages/flutter_tools/pubspec.yaml
+++ b/packages/flutter_tools/pubspec.yaml
@@ -10,6 +10,7 @@
 dependencies:
   archive: ^1.0.20
   args: ^0.13.4
+  coverage: ^0.7.7
   crypto: '>=1.1.1 <3.0.0'
   file: ^0.1.0
   http: ^0.11.3