Enhance coverage to cover all appropriate binary-level tests (#1879)

* Cleanup and move test-only code into the test utils library

* Fix coverage for binary-level tests

* dartfmt

* Adjust timeout factor

* Unconditionally add coverage path environment variable

* Move main up to the top; coverage runs take a bit more time
diff --git a/.travis.yml b/.travis.yml
index 4e638d3..5493fca 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -4,9 +4,9 @@
   - stable
   - "dev/raw/latest"
 env:
+  - DARTDOC_BOT=main
   - DARTDOC_BOT=flutter
   - DARTDOC_BOT=sdk-analyzer
-  - DARTDOC_BOT=main
   - DARTDOC_BOT=packages
   - DARTDOC_BOT=sdk-docs
 script: ./tool/travis.sh
diff --git a/lib/src/io_utils.dart b/lib/src/io_utils.dart
index 3c99b3a..9a79602 100644
--- a/lib/src/io_utils.dart
+++ b/lib/src/io_utils.dart
@@ -80,7 +80,6 @@
 final partOfRegexp = new RegExp('part of ');
 final newLinePartOfRegexp = new RegExp('\npart of ');
 
-
 /// Best used with Future<void>.
 class MultiFutureTracker<T> {
   /// Approximate maximum number of simultaneous active Futures.
@@ -113,5 +112,3 @@
   /// Wait until all futures added so far have completed.
   Future<void> wait() async => await _waitUntil(0);
 }
-
-
diff --git a/lib/src/model.dart b/lib/src/model.dart
index dbbce53..13bace5 100644
--- a/lib/src/model.dart
+++ b/lib/src/model.dart
@@ -464,8 +464,8 @@
   @override
   void warn(PackageWarning kind,
       {String message,
-        Iterable<Locatable> referredFrom,
-        Iterable<String> extendedDebug}) {
+      Iterable<Locatable> referredFrom,
+      Iterable<String> extendedDebug}) {
     enclosingCombo.warn(kind,
         message: message,
         referredFrom: referredFrom,
@@ -2031,7 +2031,8 @@
     if (writeOnly) return r'&#8592;';
     // ↔
     if (readWrite) return r'&#8596;';
-    throw UnsupportedError('GetterSetterCombo must be one of readOnly, writeOnly, or readWrite');
+    throw UnsupportedError(
+        'GetterSetterCombo must be one of readOnly, writeOnly, or readWrite');
   }
 
   bool get readOnly => hasPublicGetter && !hasPublicSetter;
diff --git a/test/compare_output_test.dart b/test/compare_output_test.dart
index 301a95b..4f80922 100644
--- a/test/compare_output_test.dart
+++ b/test/compare_output_test.dart
@@ -12,6 +12,8 @@
 import 'package:path/path.dart' as pathLib;
 import 'package:test/test.dart';
 
+import 'src/utils.dart';
+
 const List<String> _filesToIgnore = const <String>['.DS_Store'];
 
 const String gitBinName = 'git';
@@ -40,10 +42,20 @@
 void main() {
   group('compare outputs', () {
     Directory tempDir;
+    CoverageSubprocessLauncher subprocessLauncher;
 
     var dartdocBin =
         pathLib.fromUri(_currentFileUri.resolve('../bin/dartdoc.dart'));
 
+    setUpAll(() {
+      subprocessLauncher =
+          new CoverageSubprocessLauncher('compare_output_test-subprocesses');
+    });
+
+    tearDownAll(() async {
+      await Future.wait(CoverageSubprocessLauncher.coverageResults);
+    });
+
     setUp(() {
       tempDir = Directory.systemTemp.createTempSync('dartdoc.test.');
     });
@@ -55,27 +67,38 @@
     });
 
     test('Validate missing FLUTTER_ROOT exception is clean', () async {
+      StringBuffer output = new StringBuffer();
       var args = <String>[dartdocBin];
-      var result = Process.runSync(Platform.resolvedExecutable, args,
+      Future run = subprocessLauncher.runStreamed(
+          Platform.resolvedExecutable, args,
           environment: new Map.from(Platform.environment)
             ..remove('FLUTTER_ROOT'),
           includeParentEnvironment: false,
-          workingDirectory: _testPackageFlutterPluginPath);
+          workingDirectory: _testPackageFlutterPluginPath, perLine: (s) {
+        output.writeln(s);
+      });
+      // Asynchronous exception, but we still need the output, too.
+      expect(run, throwsA(new TypeMatcher<ProcessException>()));
+      try {
+        await run;
+      } on ProcessException catch (_) {}
+
       expect(
-          result.stderr,
+          output.toString(),
           contains(new RegExp(
               'Top level package requires Flutter but FLUTTER_ROOT environment variable not set|test_package_flutter_plugin requires the Flutter SDK, version solving failed')));
-      expect(result.stderr, isNot(contains('asynchronous gap')));
-      expect(result.exitCode, isNot(0));
+      expect(output.toString(), isNot(contains('asynchronous gap')));
     });
 
     test("Validate --version works", () async {
+      StringBuffer output = new StringBuffer();
       var args = <String>[dartdocBin, '--version'];
-      var result = Process.runSync(Platform.resolvedExecutable, args,
-          workingDirectory: _testPackagePath);
+      await subprocessLauncher.runStreamed(Platform.resolvedExecutable, args,
+          workingDirectory: _testPackagePath,
+          perLine: (s) => output.writeln(s));
       PackageMeta dartdocMeta = new PackageMeta.fromFilename(dartdocBin);
-      expect(
-          result.stdout, equals('dartdoc version: ${dartdocMeta.version}\n'));
+      expect(output.toString(),
+          endsWith('dartdoc version: ${dartdocMeta.version}\n'));
     });
 
     test("Validate html output of test_package", () async {
@@ -100,6 +123,8 @@
         '--pretty-index-json',
       ];
 
+      // Deliberately use runSync here to avoid counting this test as
+      // "test coverage".
       var result = Process.runSync(Platform.resolvedExecutable, args,
           workingDirectory: _testPackagePath);
 
@@ -157,7 +182,8 @@
       fail(message.join('\n'));
     });
 
-    test('Check for sample code in examples', () {
+    test('Check for sample code in examples', () async {
+      StringBuffer output = new StringBuffer();
       var args = <String>[
         dartdocBin,
         '--include',
@@ -167,28 +193,21 @@
         tempDir.path
       ];
 
-      var result = Process.runSync(Platform.resolvedExecutable, args,
-          workingDirectory: _testPackagePath);
-
-      if (result.exitCode != 0) {
-        print(result.exitCode);
-        print(result.stdout);
-        print(result.stderr);
-        fail('dartdoc failed');
-      }
+      await subprocessLauncher.runStreamed(Platform.resolvedExecutable, args,
+          workingDirectory: _testPackagePath,
+          perLine: (s) => output.writeln(s));
 
       // Examples are reported as unfound because we (purposefully)
       // did not use --example-path-prefix above.
       final sep = '.'; // We don't care what the path separator character is
       final firstUnfoundExample = new RegExp('warning: lib${sep}example.dart: '
           '@example file not found.*test_package${sep}dog${sep}food.md');
-      if (!result.stderr.contains(firstUnfoundExample)) {
-        fail('Should warn about unfound @example files: \n'
-            'stdout:\n${result.stdout}\nstderr:\n${result.stderr}');
+      if (!output.toString().contains(firstUnfoundExample)) {
+        fail('Should warn about unfound @example files');
       }
     });
 
-    test('Validate JSON output', () {
+    test('Validate JSON output', () async {
       var args = <String>[
         dartdocBin,
         '--include',
@@ -199,28 +218,15 @@
         '--json'
       ];
 
-      var result = Process.runSync(Platform.resolvedExecutable, args,
+      Iterable<Map> jsonValues = await subprocessLauncher.runStreamed(
+          Platform.resolvedExecutable, args,
           workingDirectory: _testPackagePath);
 
-      if (result.exitCode != 0) {
-        print(result.exitCode);
-        print(result.stdout);
-        print(result.stderr);
-        fail('dartdoc failed');
-      }
-
-      var jsonValues = LineSplitter.split(result.stdout)
-          .map((j) => json.decode(j) as Map<String, dynamic>)
-          .toList();
-
       expect(jsonValues, isNotEmpty,
           reason: 'All STDOUT lines should be JSON-encoded maps.');
+    }, timeout: new Timeout.factor(2));
 
-      expect(result.stderr as String, isEmpty,
-          reason: 'STDERR should be empty.');
-    });
-
-    test('--footer-text includes text', () {
+    test('--footer-text includes text', () async {
       String footerTextPath =
           pathLib.join(Directory.systemTemp.path, 'footer.txt');
       new File(footerTextPath).writeAsStringSync(' footer text include ');
@@ -234,20 +240,15 @@
         tempDir.path
       ];
 
-      var result = Process.runSync(Platform.resolvedExecutable, args,
+      await subprocessLauncher.runStreamed(Platform.resolvedExecutable, args,
           workingDirectory: _testPackagePath);
 
-      if (result.exitCode != 0) {
-        print(result.exitCode);
-        print(result.stdout);
-        print(result.stderr);
-        fail('dartdoc failed');
-      }
-
       File outFile = new File(pathLib.join(tempDir.path, 'index.html'));
       expect(outFile.readAsStringSync(), contains('footer text include'));
     });
-  }, onPlatform: {'windows': new Skip('Avoiding parsing git output')});
+  },
+      onPlatform: {'windows': new Skip('Avoiding parsing git output')},
+      timeout: new Timeout.factor(3));
 }
 
 Map<String, String> _parseOutput(
diff --git a/test/dartdoc_test.dart b/test/dartdoc_test.dart
index d0caa67..2ccee81 100644
--- a/test/dartdoc_test.dart
+++ b/test/dartdoc_test.dart
@@ -27,7 +27,11 @@
   group('dartdoc with generators', () {
     Directory tempDir;
     List<String> outputParam;
+    CoverageSubprocessLauncher subprocessLauncher;
+
     setUpAll(() async {
+      subprocessLauncher =
+          new CoverageSubprocessLauncher('dartdoc_test-subprocesses');
       tempDir = Directory.systemTemp.createTempSync('dartdoc.test.');
       outputParam = ['--output', tempDir.path];
       DartdocOptionSet optionSet = await DartdocOptionSet.fromOptionGenerators(
@@ -38,6 +42,7 @@
     });
 
     tearDown(() async {
+      await Future.wait(CoverageSubprocessLauncher.coverageResults);
       tempDir.listSync().forEach((FileSystemEntity f) {
         f.deleteSync(recursive: true);
       });
@@ -112,21 +117,24 @@
       String dartdocPath = pathLib.join('bin', 'dartdoc.dart');
 
       test('errors cause non-zero exit when warnings are off', () async {
-        ProcessResult result = Process.runSync(Platform.resolvedExecutable, [
-          dartdocPath,
-          '--input=$testPackageToolError',
-          '--output=${pathLib.join(tempDir.absolute.path, 'test_package_tool_error')}',
-        ]);
-        expect(result.exitCode, isNonZero);
+        expect(
+            () => subprocessLauncher.runStreamed(Platform.resolvedExecutable, [
+                  dartdocPath,
+                  '--input=${testPackageToolError.path}',
+                  '--output=${pathLib.join(tempDir.absolute.path, 'test_package_tool_error')}'
+                ]),
+            throwsA(const TypeMatcher<ProcessException>()));
       });
+
       test('errors cause non-zero exit when warnings are on', () async {
-        ProcessResult result = Process.runSync(Platform.resolvedExecutable, [
-          dartdocPath,
-          '--input=$testPackageToolError',
-          '--output=${pathLib.join(tempDir.absolute.path, 'test_package_tool_error')}',
-          '--show-warnings',
-        ]);
-        expect(result.exitCode, isNonZero);
+        expect(
+            () => subprocessLauncher.runStreamed(Platform.resolvedExecutable, [
+                  dartdocPath,
+                  '--input=${testPackageToolError.path}',
+                  '--output=${pathLib.join(tempDir.absolute.path, 'test_package_tool_error')}',
+                  '--show-warnings',
+                ]),
+            throwsA(const TypeMatcher<ProcessException>()));
       });
     });
 
diff --git a/test/src/utils.dart b/test/src/utils.dart
index 17ae32e..3cf9b4a 100644
--- a/test/src/utils.dart
+++ b/test/src/utils.dart
@@ -12,10 +12,11 @@
 import 'package:dartdoc/src/html/html_generator.dart';
 import 'package:dartdoc/src/model.dart';
 import 'package:dartdoc/src/package_meta.dart';
-import 'package:dartdoc/src/tuple.dart';
 import 'package:path/path.dart' as pathLib;
 
 final RegExp quotables = new RegExp(r'[ "\r\n\$]');
+final RegExp observatoryPortRegexp =
+    new RegExp(r'^Observatory listening on http://.*:(\d+)');
 
 Directory sdkDir;
 PackageMeta sdkPackageMeta;
@@ -114,27 +115,34 @@
 /// [CoverageSubprocessLauncher].  Requires that these be dart processes.
 class CoverageSubprocessLauncher extends SubprocessLauncher {
   CoverageSubprocessLauncher(String context, [Map<String, String> environment])
-      : super(context, environment);
+      : super(context, environment) {
+    environment ??= {};
+    environment['DARTDOC_COVERAGE_DATA'] = tempDir.path;
+  }
 
-  static int nextObservatoryPort = 9292;
+  static int nextRun = 0;
 
   /// Set this to true to enable coverage runs.
-  static bool coverageEnabled = false;
+  static bool get coverageEnabled =>
+      Platform.environment.containsKey('COVERAGE_TOKEN');
 
   /// A list of all coverage results picked up by all launchers.
-  static List<Tuple2<String, Future<Iterable<Map>>>> coverageResults = [];
+  static List<Future<Iterable<Map>>> coverageResults = [];
 
   static Directory _tempDir;
-  static Directory get tempDir =>
-      _tempDir ??= Directory.systemTemp.createTempSync('dartdoc_coverage_data');
+  static Directory get tempDir {
+    if (_tempDir == null) {
+      if (Platform.environment['DARTDOC_COVERAGE_DATA'] != null) {
+        _tempDir = new Directory(Platform.environment['DARTDOC_COVERAGE_DATA']);
+      } else {
+        _tempDir = Directory.systemTemp.createTempSync('dartdoc_coverage_data');
+      }
+    }
+    return _tempDir;
+  }
 
-  int _observatoryPort;
-  // TODO(jcollins-g): use ephemeral ports
-  int get observatoryPort => _observatoryPort ??= nextObservatoryPort++;
-
-  String _outCoverageFilename;
-  String get outCoverageFilename => _outCoverageFilename ??=
-      pathLib.join(tempDir.path, 'dart-cov-0-${observatoryPort}.json');
+  static String buildNextCoverageFilename() =>
+      pathLib.join(tempDir.path, 'dart-cov-${pid}-${nextRun++}.json');
 
   /// Call once all coverage runs have been generated by calling runStreamed
   /// on all [CoverageSubprocessLaunchers].
@@ -145,7 +153,7 @@
     var launcher = SubprocessLauncher('format_coverage');
 
     /// Wait for all coverage runs to finish.
-    await Future.wait(currentCoverageResults.map((t) => t.item2));
+    await Future.wait(currentCoverageResults);
 
     return launcher.runStreamed(Platform.executable, [
       'tool/format_coverage.dart', // TODO(jcollins-g): use pub after dart-lang/coverage#240 is landed
@@ -162,31 +170,54 @@
 
   @override
   Future<Iterable<Map>> runStreamed(String executable, List<String> arguments,
-      {String workingDirectory}) {
-    assert(executable == Platform.executable,
-    'Must use dart executable for tracking coverage');
+      {String workingDirectory,
+      Map<String, String> environment,
+      bool includeParentEnvironment = true,
+      void Function(String) perLine}) async {
+    environment ??= {};
+    assert(
+        executable == Platform.executable ||
+            executable == Platform.resolvedExecutable,
+        'Must use dart executable for tracking coverage');
 
-    if (coverageEnabled) {
-      arguments = [
-        '--enable-vm-service=${observatoryPort}',
-        '--pause-isolates-on-exit'
-      ]..addAll(arguments);
+    Completer<String> portAsString = new Completer();
+    void parsePortAsString(String line) {
+      if (perLine != null) perLine(line);
+      if (!portAsString.isCompleted) {
+        Match m = observatoryPortRegexp.matchAsPrefix(line);
+        if (m?.group(1) != null) portAsString.complete(m.group(1));
+      }
     }
 
-    Future<Iterable<Map>> results = super
-        .runStreamed(executable, arguments, workingDirectory: workingDirectory);
+    Completer<Iterable<Map>> coverageResult;
 
     if (coverageEnabled) {
-      coverageResults.add(new Tuple2(
-          outCoverageFilename,
-          super.runStreamed('pub', [
-            'run',
-            'coverage:collect_coverage',
-            '--wait-paused',
-            '--resume-isolates',
-            '--port=${observatoryPort}',
-            '--out=${outCoverageFilename}',
-          ])));
+      coverageResult = new Completer();
+      // This must be added before awaiting in this method.
+      coverageResults.add(coverageResult.future);
+      arguments = ['--enable-vm-service:0', '--pause-isolates-on-exit']
+        ..addAll(arguments);
+      if (!environment.containsKey('DARTDOC_COVERAGE_DATA')) {
+        environment['DARTDOC_COVERAGE_DATA'] = tempDir.path;
+      }
+    }
+
+    Future<Iterable<Map>> results = super.runStreamed(executable, arguments,
+        environment: environment,
+        includeParentEnvironment: includeParentEnvironment,
+        workingDirectory: workingDirectory,
+        perLine: parsePortAsString);
+
+    if (coverageEnabled) {
+      // ignore: unawaited_futures
+      super.runStreamed('pub', [
+        'run',
+        'coverage:collect_coverage',
+        '--wait-paused',
+        '--resume-isolates',
+        '--port=${await portAsString.future}',
+        '--out=${buildNextCoverageFilename()}',
+      ]).then((r) => coverageResult.complete(r));
     }
     return results;
   }
@@ -194,7 +225,7 @@
 
 class SubprocessLauncher {
   final String context;
-  final Map<String, String> environment;
+  final Map<String, String> environmentDefaults;
 
   String get prefix => context.isNotEmpty ? '$context: ' : '';
 
@@ -216,7 +247,7 @@
   }
 
   SubprocessLauncher(this.context, [Map<String, String> environment])
-      : this.environment = environment ?? <String, String>{};
+      : this.environmentDefaults = environment ?? <String, String>{};
 
   /// A wrapper around start/await process.exitCode that will display the
   /// output of the executable continuously and fail on non-zero exit codes.
@@ -226,15 +257,22 @@
   ///
   /// Makes running programs in grinder similar to set -ex for bash, even on
   /// Windows (though some of the bashisms will no longer make sense).
-  /// TODO(jcollins-g): move this to grinder?
+  /// TODO(jcollins-g): refactor to return a stream of stderr/stdout lines
+  ///                   and their associated JSON objects.
   Future<Iterable<Map>> runStreamed(String executable, List<String> arguments,
-      {String workingDirectory}) async {
+      {String workingDirectory,
+      Map<String, String> environment,
+      bool includeParentEnvironment: true,
+      void Function(String) perLine}) async {
+    environment ??= {};
+    environment.addAll(environmentDefaults);
     List<Map> jsonObjects;
 
     /// Allow us to pretend we didn't pass the JSON flag in to dartdoc by
     /// printing what dartdoc would have printed without it, yet storing
     /// json objects into [jsonObjects].
     Iterable<String> jsonCallback(String line) {
+      if (perLine != null) perLine(line);
       Map result;
       try {
         result = json.decoder.convert(line);
@@ -296,7 +334,9 @@
     realArguments.addAll(arguments);
 
     Process process = await Process.start(realExecutable, realArguments,
-        workingDirectory: workingDirectory, environment: environment);
+        workingDirectory: workingDirectory,
+        environment: environment,
+        includeParentEnvironment: includeParentEnvironment);
     Future<void> stdoutFuture = _printStream(process.stdout, stdout,
         prefix: prefix, filter: jsonCallback);
     Future<void> stderrFuture = _printStream(process.stderr, stderr,
@@ -310,4 +350,4 @@
     }
     return jsonObjects;
   }
-}
\ No newline at end of file
+}
diff --git a/tool/grind.dart b/tool/grind.dart
index d07ebd6..46fc1c9 100644
--- a/tool/grind.dart
+++ b/tool/grind.dart
@@ -856,8 +856,6 @@
 Future<void> testDart2() async {
   List<String> parameters = ['--enable-asserts'];
 
-  CoverageSubprocessLauncher.coverageEnabled =
-      Platform.environment.containsKey('COVERAGE_TOKEN');
   for (File dartFile in testFiles) {
     await testFutures.addFutureFromClosure(() => new CoverageSubprocessLauncher(
             'dart2-${pathLib.basename(dartFile.path)}')