Add test_with_coverage.dart (#375)

* Add test_with_coverage.dart

* Fix analysis

* Revert unnecessary change to collect.dart

* Remove timeout

* Improve logging for functions missing line numbers

* Trying to fix run_and_collect timeout

* Readme

* Fix signal handler

* Another attemped timeout fix

* Yet another attempted timeout fix

* Update README.md

Co-authored-by: Nate Bosch <nbosch@google.com>

* Address comments

Co-authored-by: Nate Bosch <nbosch@google.com>
diff --git a/.github/workflows/test-package.yml b/.github/workflows/test-package.yml
index c906867..5c89e93 100644
--- a/.github/workflows/test-package.yml
+++ b/.github/workflows/test-package.yml
@@ -72,9 +72,9 @@
         name: Install dependencies
         run: dart pub get
       - name: Collect and report coverage
-        run: ./tool/test_and_collect.sh
+        run: dart run bin/test_with_coverage.dart --port=9292
       - name: Upload coverage
         uses: coverallsapp/github-action@v1.1.2
         with:
           github-token: ${{ secrets.GITHUB_TOKEN }}
-          path-to-lcov: var/lcov.info
+          path-to-lcov: coverage/lcov.info
diff --git a/.gitignore b/.gitignore
index e540fed..bc117f9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -14,3 +14,4 @@
 
 # Temp files
 *~
+coverage/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4568c09..bc3aa24 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,9 @@
   the .package file. Deprecate the `--packages` flag.
 * Deprecate the packagesPath parameter and add packagePath instead, in
   `HitMap.parseJson`, `HitMap.parseFiles`, `createHitmap`, and `parseCoverage`.
+* Add a new executable to the package, `test_with_coverage`. This simplifies the
+  most common use case of coverage, running all the tests for a package, and
+  generating an lcov.info file.
 
 ## 1.2.0
 
diff --git a/README.md b/README.md
index 5269e64..a1a9578 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,30 @@
 See [Running a script from your PATH](https://dart.dev/tools/pub/cmd/pub-global#running-a-script-from-your-path)
 for more details.
 
+
+#### Running tests with coverage
+
+For the common use case where you just want to run all your tests, and generate
+an lcov.info file, you can use the test_with_coverage script:
+
+```
+dart pub global run coverage:test_with_coverage
+```
+
+By default, this script assumes it's being run from the root directory of a
+package, and outputs a coverage.json and lcov.info file to ./coverage/
+
+This script is essentially the same as running:
+
+```
+dart run --pause-isolates-on-exit --disable-service-auth-codes --enable-vm-service=8181 test &
+dart pub global run coverage:collect_coverage --wait-paused --uri=http://127.0.0.1:8181/ -o coverage/coverage.json --resume-isolates --scope-output=foo
+dart pub global run coverage:format_coverage --packages=.dart_tool/package_config.json --lcov -i coverage/coverage.json -o coverage/lcov.info
+```
+
+For more complicated use cases, where you want to control each of these stages,
+see the sections below.
+
 #### Collecting coverage from the VM
 
 ```
@@ -89,3 +113,9 @@
 dart --pause-isolates-on-exit --disable-service-auth-codes --enable-vm-service=NNNN --branch-coverage script.dart
 dart pub global run coverage:collect_coverage --uri=http://... -o coverage.json --resume-isolates --function-coverage --branch-coverage
 ```
+
+These flags can also be passed to test_with_coverage:
+
+```
+pub global run coverage:test_with_coverage --branch-coverage --function-coverage
+```
diff --git a/bin/test_with_coverage.dart b/bin/test_with_coverage.dart
new file mode 100644
index 0000000..38aed5e
--- /dev/null
+++ b/bin/test_with_coverage.dart
@@ -0,0 +1,204 @@
+// Copyright (c) 2022, the Dart project authors.  Please see the AUTHORS file
+// for details. 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:convert' show utf8, LineSplitter;
+import 'dart:io';
+
+import 'package:args/args.dart';
+import 'package:coverage/src/util.dart' show extractVMServiceUri;
+import 'package:package_config/package_config.dart';
+import 'package:path/path.dart' as path;
+
+final allProcesses = <Process>[];
+
+Future<void> dartRun(List<String> args,
+    {void Function(String)? onStdout, String? workingDir}) async {
+  final process = await Process.start(
+    Platform.executable,
+    args,
+    workingDirectory: workingDir,
+  );
+  allProcesses.add(process);
+  final broadStdout = process.stdout.asBroadcastStream();
+  broadStdout.listen(stdout.add);
+  if (onStdout != null) {
+    broadStdout
+        .transform(utf8.decoder)
+        .transform(const LineSplitter())
+        .listen(onStdout);
+  }
+  process.stderr.listen(stderr.add);
+  final result = await process.exitCode;
+  if (result != 0) {
+    throw ProcessException(Platform.executable, args, '', result);
+  }
+}
+
+Future<String?> packageNameFromConfig(String packageDir) async {
+  final config = await findPackageConfig(Directory(packageDir));
+  return config?.packageOf(Uri.directory(packageDir))?.name;
+}
+
+void watchExitSignal(ProcessSignal signal) {
+  signal.watch().listen((sig) {
+    for (final process in allProcesses) {
+      process.kill(sig);
+    }
+    exit(1);
+  });
+}
+
+ArgParser createArgParser() {
+  final parser = ArgParser();
+  parser.addOption(
+    'package',
+    help: 'Root directory of the package to test.',
+    defaultsTo: '.',
+  );
+  parser.addOption(
+    'package-name',
+    help: 'Name of the package to test. '
+        'Deduced from --package if not provided.',
+  );
+  parser.addOption('port', help: 'VM service port.', defaultsTo: '8181');
+  parser.addOption('out',
+      abbr: 'o', help: 'Output directory. Defaults to <package-dir>/coverage.');
+  parser.addOption('test', help: 'Test script to run.', defaultsTo: 'test');
+  parser.addFlag(
+    'function-coverage',
+    abbr: 'f',
+    defaultsTo: false,
+    help: 'Collect function coverage info.',
+  );
+  parser.addFlag(
+    'branch-coverage',
+    abbr: 'b',
+    defaultsTo: false,
+    help: 'Collect branch coverage info.',
+  );
+  parser.addFlag('help', abbr: 'h', negatable: false, help: 'Show this help.');
+  return parser;
+}
+
+class Flags {
+  Flags(this.packageDir, this.packageName, this.outDir, this.port,
+      this.testScript, this.functionCoverage, this.branchCoverage);
+
+  final String packageDir;
+  final String packageName;
+  final String outDir;
+  final String port;
+  final String testScript;
+  final bool functionCoverage;
+  final bool branchCoverage;
+}
+
+Future<Flags> parseArgs(List<String> arguments) async {
+  final parser = createArgParser();
+  final args = parser.parse(arguments);
+
+  void printUsage() {
+    print('Runs tests and collects coverage for a package. By default this '
+        "script assumes it's being run from the root directory of a package, and "
+        'outputs a coverage.json and lcov.info to ./coverage/');
+    print('Usage: dart test_with_coverage.dart [OPTIONS...]\n');
+    print(parser.usage);
+  }
+
+  Never fail(String msg) {
+    print('\n$msg\n');
+    printUsage();
+    exit(1);
+  }
+
+  if (args['help'] as bool) {
+    printUsage();
+    exit(0);
+  }
+
+  final packageDir = path.canonicalize(args['package'] as String);
+  if (!FileSystemEntity.isDirectorySync(packageDir)) {
+    fail('--package is not a valid directory.');
+  }
+
+  final packageName = (args['package-name'] as String?) ??
+      await packageNameFromConfig(packageDir);
+  if (packageName == null) {
+    fail(
+      "Couldn't figure out package name from --package. Make sure this is a "
+      'package directory, or try passing --package-name explicitly.',
+    );
+  }
+
+  return Flags(
+    packageDir,
+    packageName,
+    (args['out'] as String?) ?? path.join(packageDir, 'coverage'),
+    args['port'] as String,
+    args['test'] as String,
+    args['function-coverage'] as bool,
+    args['branch-coverage'] as bool,
+  );
+}
+
+Future<void> main(List<String> arguments) async {
+  final flags = await parseArgs(arguments);
+  final thisDir = path.dirname(Platform.script.path);
+  final outJson = path.join(flags.outDir, 'coverage.json');
+  final outLcov = path.join(flags.outDir, 'lcov.info');
+
+  if (!FileSystemEntity.isDirectorySync(flags.outDir)) {
+    await Directory(flags.outDir).create(recursive: true);
+  }
+
+  watchExitSignal(ProcessSignal.sighup);
+  watchExitSignal(ProcessSignal.sigint);
+  watchExitSignal(ProcessSignal.sigterm);
+
+  final serviceUriCompleter = Completer<Uri>();
+  final testProcess = dartRun([
+    if (flags.branchCoverage) '--branch-coverage',
+    'run',
+    '--pause-isolates-on-exit',
+    '--disable-service-auth-codes',
+    '--enable-vm-service=${flags.port}',
+    flags.testScript,
+  ], onStdout: (line) {
+    if (!serviceUriCompleter.isCompleted) {
+      final uri = extractVMServiceUri(line);
+      if (uri != null) {
+        serviceUriCompleter.complete(uri);
+      }
+    }
+  });
+  final serviceUri = await serviceUriCompleter.future;
+
+  await dartRun([
+    'run',
+    'collect_coverage.dart',
+    '--wait-paused',
+    '--resume-isolates',
+    '--uri=$serviceUri',
+    '--scope-output=${flags.packageName}',
+    if (flags.branchCoverage) '--branch-coverage',
+    if (flags.functionCoverage) '--function-coverage',
+    '-o',
+    outJson,
+  ], workingDir: thisDir);
+  await testProcess;
+
+  await dartRun([
+    'run',
+    'format_coverage.dart',
+    '--lcov',
+    '--check-ignore',
+    '--package=${flags.packageDir}',
+    '-i',
+    outJson,
+    '-o',
+    outLcov,
+  ], workingDir: thisDir);
+  exit(0);
+}
diff --git a/lib/src/collect.dart b/lib/src/collect.dart
index 0a5fc24..2f2cafc 100644
--- a/lib/src/collect.dart
+++ b/lib/src/collect.dart
@@ -114,7 +114,7 @@
   final branchCoverageSupported = _versionCheck(version, 3, 56);
   if (branchCoverage && !branchCoverageSupported) {
     branchCoverage = false;
-    stderr.write('Branch coverage was requested, but is not supported'
+    stderr.writeln('Branch coverage was requested, but is not supported'
         ' by the VM version. Try updating to a newer version of Dart');
   }
   final sourceReportKinds = [
@@ -251,8 +251,9 @@
     final line = _getLineFromTokenPos(script, tokenPos);
 
     if (line == null) {
-      stderr.write(
-          'tokenPos $tokenPos has no line mapping for script ${script.uri!}');
+      stderr.writeln(
+          'tokenPos $tokenPos in function ${funcRef.name} has no line mapping '
+          'for script ${script.uri!}');
       return;
     }
     hits.funcNames![line] = funcName;
diff --git a/lib/src/run_and_collect.dart b/lib/src/run_and_collect.dart
index b279f46..a448e8d 100644
--- a/lib/src/run_and_collect.dart
+++ b/lib/src/run_and_collect.dart
@@ -29,7 +29,7 @@
     dartArgs.addAll(scriptArgs);
   }
 
-  final process = await Process.start('dart', dartArgs);
+  final process = await Process.start(Platform.executable, dartArgs);
   final serviceUriCompleter = Completer<Uri>();
   process.stdout
       .transform(utf8.decoder)
diff --git a/pubspec.yaml b/pubspec.yaml
index 0abb08e..fc00d00 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -23,3 +23,4 @@
 executables:
   collect_coverage:
   format_coverage:
+  test_with_coverage:
diff --git a/test/test_all.dart b/test/test_all.dart
deleted file mode 100644
index 0b9e880..0000000
--- a/test/test_all.dart
+++ /dev/null
@@ -1,25 +0,0 @@
-// Copyright (c) 2015, the Dart project authors.  Please see the AUTHORS file
-// for details. All rights reserved. Use of this source code is governed by a
-// BSD-style license that can be found in the LICENSE file.
-
-import 'package:test/test.dart';
-
-import 'chrome_test.dart' as chrome;
-import 'collect_coverage_api_test.dart' as collect_coverage_api;
-import 'collect_coverage_test.dart' as collect_coverage;
-import 'format_coverage_test.dart' as format_coverage;
-import 'lcov_test.dart' as lcov;
-import 'resolver_test.dart' as resolver;
-import 'run_and_collect_test.dart' as run_and_collect;
-import 'util_test.dart' as util;
-
-void main() {
-  group('collect_coverage_api', collect_coverage_api.main);
-  group('collect_coverage', collect_coverage.main);
-  group('format_coverage', format_coverage.main);
-  group('lcov', lcov.main);
-  group('resolver', resolver.main);
-  group('run_and_collect', run_and_collect.main);
-  group('util', util.main);
-  group('chrome', chrome.main);
-}
diff --git a/tool/test_and_collect.sh b/tool/test_and_collect.sh
deleted file mode 100755
index ff0d396..0000000
--- a/tool/test_and_collect.sh
+++ /dev/null
@@ -1,35 +0,0 @@
-#!/bin/bash
-
-# Copyright (c) 2015, the Dart project authors. Please see the AUTHORS file
-# for details. All rights reserved. Use of this source code is governed by a
-# BSD-style license that can be found in the LICENSE file.
-
-# Fast fail the script on failures.
-set -e
-
-# Gather coverage
-if [[ $(dart --version 2>&1 ) =~ '(dev)' ]]; then
-  OBS_PORT=9292
-  echo "Collecting coverage on port $OBS_PORT..."
-
-  # Start tests in one VM.
-  dart --disable-service-auth-codes \
-    --enable-vm-service=$OBS_PORT \
-    --pause-isolates-on-exit \
-    test/test_all.dart &
-
-  # Run the coverage collector to generate the JSON coverage report.
-  dart bin/collect_coverage.dart \
-    --port=$OBS_PORT \
-    --out=var/coverage.json \
-    --wait-paused \
-    --resume-isolates
-
-  echo "Generating LCOV report..."
-  dart bin/format_coverage.dart \
-    --lcov \
-    --in=var/coverage.json \
-    --out=var/lcov.info \
-    --report-on=lib \
-    --check-ignore
-fi