Merge `package:test_process` (#2425)

- [x] Move and fix workflow files, labeler.yaml, and badges in the
README.md
- [x] Rev the version of the package, so that pub.dev points to the
correct site
- [x] Add a line to the changelog:
```
* Move to `dart-lang/test` monorepo.
```

- [x] Add the package to the top-level readme of the monorepo:
```
| [test_process](pkgs/test_process/) | Test processes: starting; validating stdout and stderr; checking exit code | [![package issues](https://img.shields.io/badge/package:test_process-4774bc)](https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Atest_process) | [![pub package](https://img.shields.io/pub/v/test_process.svg)](https://pub.dev/packages/test_process) |
```

- [ ] **Important!** Merge the PR with 'Create a merge commit' (enabling
then disabling the `Allow merge commits` admin setting)
- [x] Update the auto-publishing settings on
https://pub.dev/packages/test_process/admin
- [x] Add the following text to
https://github.com/dart-lang/test_process/:'

```
> [!IMPORTANT]  
> This repo has moved to https://github.com/dart-lang/test/tree/master/pkgs/test_process
```

- [ ] Publish using the autopublish workflow
- [ ] Push tags to GitHub using
```
git tag --list 'test_process*' | xargs git push origin
```

- [ ] Close open PRs in dart-lang/test_process with the following
message:

```
Closing as the [dart-lang/test_process](https://github.com/dart-lang/test_process) repository is merged into the [dart-lang/test](https://github.com/dart-lang/test) monorepo. Please re-open this PR there!
```
      
- [ ] Transfer issues by running
```
dart run pkgs/repo_manage/bin/report.dart transfer-issues --source-repo dart-lang/test_process --target-repo dart-lang/test --add-label package:test_process --apply-changes
```

- [ ] Archive https://github.com/dart-lang/test_process/


---

- [x] I’ve reviewed the contributor guide and applied the relevant
portions to this PR.

<details>
  <summary>Contribution guidelines:</summary><br>

- See our [contributor
guide](https://github.com/dart-lang/.github/blob/main/CONTRIBUTING.md)
for general expectations for PRs.
- Larger or significant changes should be discussed in an issue before
creating a PR.
- Contributions to our repos should follow the [Dart style
guide](https://dart.dev/guides/language/effective-dart) and use `dart
format`.
- Most changes should add an entry to the changelog and may need to [rev
the pubspec package
version](https://github.com/dart-lang/sdk/blob/main/docs/External-Package-Maintenance.md#making-a-change).
- Changes to packages require [corresponding
tests](https://github.com/dart-lang/.github/blob/main/CONTRIBUTING.md#Testing).

Note that many Dart repos have a weekly cadence for reviewing PRs -
please allow for some latency before initial review feedback.
</details>
diff --git a/.github/ISSUE_TEMPLATE/test_process.md b/.github/ISSUE_TEMPLATE/test_process.md
new file mode 100644
index 0000000..9f492b8
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/test_process.md
@@ -0,0 +1,5 @@
+---
+name: "package:test_process"
+about: "Create a bug or file a feature request against package:test_process."
+labels: "package:test_process"
+---
\ No newline at end of file
diff --git a/.github/workflows/test_process.yaml b/.github/workflows/test_process.yaml
new file mode 100644
index 0000000..2c0da6d
--- /dev/null
+++ b/.github/workflows/test_process.yaml
@@ -0,0 +1,72 @@
+name: package:test_process
+
+on:
+  # Run on PRs and pushes to the default branch.
+  push:
+    branches: [ master ]
+    paths:
+      - '.github/workflows/test_process.yaml'
+      - 'pkgs/test_process/**'
+  pull_request:
+    branches: [ master ]
+    paths:
+      - '.github/workflows/test_process.yaml'
+      - 'pkgs/test_process/**'
+  schedule:
+    - cron: "0 0 * * 0"
+
+env:
+  PUB_ENVIRONMENT: bot.github
+
+
+defaults:
+  run:
+    working-directory: pkgs/test_process/
+
+jobs:
+  # Check code formatting and static analysis on a single OS (linux)
+  # against Dart dev.
+  analyze:
+    runs-on: ubuntu-latest
+    strategy:
+      fail-fast: false
+      matrix:
+        sdk: [dev]
+    steps:
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94
+        with:
+          sdk: ${{ matrix.sdk }}
+      - id: install
+        name: Install dependencies
+        run: dart pub get
+      - name: Check formatting
+        run: dart format --output=none --set-exit-if-changed .
+        if: always() && steps.install.outcome == 'success'
+      - name: Analyze code
+        run: dart analyze --fatal-infos
+        if: always() && steps.install.outcome == 'success'
+
+  # Run tests on a matrix consisting of two dimensions:
+  # 1. OS: ubuntu-latest, (macos-latest, windows-latest)
+  # 2. release: dev
+  test:
+    needs: analyze
+    runs-on: ${{ matrix.os }}
+    strategy:
+      fail-fast: false
+      matrix:
+        # Add macos-latest and/or windows-latest if relevant for this package.
+        os: [ubuntu-latest]
+        sdk: [3.1, dev]
+    steps:
+      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
+      - uses: dart-lang/setup-dart@e630b99d28a3b71860378cafdc2a067c71107f94
+        with:
+          sdk: ${{ matrix.sdk }}
+      - id: install
+        name: Install dependencies
+        run: dart pub get
+      - name: Run VM tests
+        run: dart test --platform vm
+        if: always() && steps.install.outcome == 'success'
diff --git a/README.md b/README.md
index 25b50aa..5b80a28 100644
--- a/README.md
+++ b/README.md
@@ -22,3 +22,4 @@
 | [test](pkgs/test/) | A full featured library for writing and running Dart tests across platforms. | [![pub package](https://img.shields.io/pub/v/test.svg)](https://pub.dev/packages/test) |
 | [test_api](pkgs/test_api/) |  | [![pub package](https://img.shields.io/pub/v/test_api.svg)](https://pub.dev/packages/test_api) |
 | [test_core](pkgs/test_core/) |  | [![pub package](https://img.shields.io/pub/v/test_core.svg)](https://pub.dev/packages/test_core) |
+| [test_process](pkgs/test_process/) | Test processes: starting; validating stdout and stderr; checking exit code | [![pub package](https://img.shields.io/pub/v/test_process.svg)](https://pub.dev/packages/test_process) |
diff --git a/pkgs/test_process/.gitignore b/pkgs/test_process/.gitignore
new file mode 100644
index 0000000..0659a33
--- /dev/null
+++ b/pkgs/test_process/.gitignore
@@ -0,0 +1,9 @@
+.buildlog
+.DS_Store
+.idea
+.settings/
+build/
+packages
+.packages
+pubspec.lock
+.dart_tool/
diff --git a/pkgs/test_process/AUTHORS b/pkgs/test_process/AUTHORS
new file mode 100644
index 0000000..e8063a8
--- /dev/null
+++ b/pkgs/test_process/AUTHORS
@@ -0,0 +1,6 @@
+# Below is a list of people and organizations that have contributed
+# to the project. Names should be added to the list like so:
+#
+#   Name/Organization <email address>
+
+Google Inc.
diff --git a/pkgs/test_process/CHANGELOG.md b/pkgs/test_process/CHANGELOG.md
new file mode 100644
index 0000000..b267290
--- /dev/null
+++ b/pkgs/test_process/CHANGELOG.md
@@ -0,0 +1,67 @@
+## 2.1.1
+
+* Require Dart 3.1.
+* Move to `dart-lang/test` monorepo.
+
+## 2.1.0
+
+- Remove the expectation that the process exits during the normal test body.
+  The process will still be killed during teardown if it has not exited. The
+  check can be manually restored with `shouldExit()`.
+
+## 2.0.3
+
+- Populate the pubspec `repository` field.
+- Fixed examples in `readme.md`.
+- Added `example/example.dart`
+- Require Dart >=2.17
+
+## 2.0.2
+
+- Reverted `meta` constraint to `^1.3.0`.
+
+## 2.0.1
+
+- Update `meta` constraint to `>=1.3.0 <3.0.0`.
+
+## 2.0.0
+
+- Migrate to null safety.
+
+## 1.0.6
+
+- Require Dart >=2.1
+
+## 1.0.5
+
+- Don't allow the test to time out as long as the process is emitting output.
+
+## 1.0.4
+
+- Set max SDK version to `<3.0.0`, and adjust other dependencies.
+
+## 1.0.3
+
+- Support test `1.x.x`.
+
+## 1.0.2
+
+- Update SDK version to 2.0.0-dev.17.0
+
+## 1.0.1
+
+- Declare support for `async` 2.0.0.
+
+## 1.0.0
+
+- Added `pid` and `exitCode` getters to `TestProcess`.
+
+## 1.0.0-rc.2
+
+- Subclassed `TestProcess`es now emit log output based on the superclass's
+  standard IO streams rather than the subclass's. This matches the documented
+  behavior.
+
+## 1.0.0-rc.1
+
+- Initial release candidate.
diff --git a/pkgs/test_process/LICENSE b/pkgs/test_process/LICENSE
new file mode 100644
index 0000000..aa86769
--- /dev/null
+++ b/pkgs/test_process/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2017, the Dart project authors.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+    * Redistributions of source code must retain the above copyright
+      notice, this list of conditions and the following disclaimer.
+    * Redistributions in binary form must reproduce the above
+      copyright notice, this list of conditions and the following
+      disclaimer in the documentation and/or other materials provided
+      with the distribution.
+    * Neither the name of Google LLC nor the names of its
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/pkgs/test_process/README.md b/pkgs/test_process/README.md
new file mode 100644
index 0000000..0d4e5f5
--- /dev/null
+++ b/pkgs/test_process/README.md
@@ -0,0 +1,123 @@
+[![Build Status](https://github.com/dart-lang/test/actions/workflows/test_process.yaml/badge.svg)](https://github.com/dart-lang/test/actions/workflows/test_process.yaml)
+[![pub package](https://img.shields.io/pub/v/test_process.svg)](https://pub.dev/packages/test_process)
+[![package publisher](https://img.shields.io/pub/publisher/test_process.svg)](https://pub.dev/packages/test_process/publisher)
+
+A package for testing subprocesses.
+
+This exposes a [`TestProcess`][TestProcess] class that wraps `dart:io`'s
+[`Process`][Process] class and makes it easy to read standard output
+line-by-line. `TestProcess` works the same as `Process` in many ways, but there
+are a few major differences.
+
+[TestProcess]: https://pub.dev/documentation/test_process/latest/test_process/TestProcess-class.html
+[Process]: https://api.dart.dev/stable/dart-io/Process-class.html
+
+## Standard Output
+
+`Process.stdout` and `Process.stderr` are binary streams, which is the most
+general API but isn't the most helpful when working with a program that produces
+plain text. Instead, [`TestProcess.stdout`][stdout] and
+[`TestProcess.stderr`][stderr] emit a string for each line of output the process
+produces. What's more, they're [`StreamQueue`][StreamQueue]s, which means
+they provide a *pull-based API*. For example:
+
+[stdout]: https://pub.dev/documentation/test_process/latest/test_process/TestProcess/stdout.html
+[stderr]: https://pub.dev/documentation/test_process/latest/test_process/TestProcess/stderr.html
+[StreamQueue]: https://pub.dev/documentation/async/latest/async/StreamQueue-class.html
+
+```dart
+import 'package:test/test.dart';
+import 'package:test_process/test_process.dart';
+
+void main() {
+  test('pub get gets dependencies', () async {
+    // TestProcess.start() works just like Process.start() from dart:io.
+    var process = await TestProcess.start('dart', ['pub', 'get']);
+
+    // StreamQueue.next returns the next line emitted on standard out.
+    var firstLine = await process.stdout.next;
+    expect(firstLine, equals('Resolving dependencies...'));
+
+    // Each call to StreamQueue.next moves one line further.
+    String next;
+    do {
+      next = await process.stdout.next;
+    } while (next != 'Got dependencies!');
+
+    // Assert that the process exits with code 0.
+    await process.shouldExit(0);
+  });
+}
+```
+
+The `test` package's [stream matchers][] have built-in support for
+`StreamQueues`, which makes them perfect for making assertions about a process's
+output. We can use this to clean up the previous example:
+
+[stream matchers]: https://github.com/dart-lang/test#stream-matchers
+
+```dart
+import 'package:test/test.dart';
+import 'package:test_process/test_process.dart';
+
+void main() {
+  test('pub get gets dependencies', () async {
+    var process = await TestProcess.start('dart', ['pub', 'get']);
+
+    // Each stream matcher will consume as many lines as it matches from a
+    // StreamQueue, and no more, so it's safe to use them in sequence.
+    await expectLater(process.stdout, emits('Resolving dependencies...'));
+
+    // The emitsThrough matcher matches and consumes any number of lines, as
+    // long as they end with one matching the argument.
+    await expectLater(process.stdout, emitsThrough('Got dependencies!'));
+
+    await process.shouldExit(0);
+  });
+}
+```
+
+If you want to access the standard output streams without consuming any values
+from the queues, you can use the [`stdoutStream()`][stdoutStream] and
+[`stderrStream()`][stderrStream] methods. Each time you call one of these, it
+produces an entirely new stream that replays the corresponding output stream
+from the beginning, regardless of what's already been produced by `stdout`,
+`stderr`, or other calls to the stream method.
+
+[stdoutStream]: https://pub.dev/documentation/test_process/latest/test_process/TestProcess/stdoutStream.html
+[stderrStream]: https://pub.dev/documentation/test_process/latest/test_process/TestProcess/stderrStream.html
+
+## Signals and Termination
+
+The way signaling works is different from `dart:io` as well. `TestProcess` still
+has a [`kill()`][kill] method, but it defaults to `SIGKILL` on Mac OS and Linux
+to ensure (as best as possible) that processes die without leaving behind
+zombies. If you want to send a particular signal (which is unsupported on
+Windows), you can do so by explicitly calling [`signal()`][signal].
+
+[kill]: https://pub.dev/documentation/test_process/latest/test_process/TestProcess/kill.html
+[signal]: https://pub.dev/documentation/test_process/latest/test_process/TestProcess/signal.html
+
+In addition to [`exitCode`][exitCode], which works the same as in `dart:io`,
+`TestProcess` also adds a new method named [`shouldExit()`][shouldExit]. This
+lets tests wait for a process to exit, and (if desired) assert what particular
+exit code it produced.
+
+[exitCode]: https://pub.dev/documentation/test_process/latest/test_process/TestProcess/exitCode.html
+[shouldExit]: https://pub.dev/documentation/test_process/latest/test_process/TestProcess/shouldExit.html
+
+## Debugging Output
+
+When a test using `TestProcess` fails, it will print all the output produced by
+that process. This makes it much easier to figure out what went wrong and why.
+The debugging output uses a header based on the process's invocation by
+default, but you can pass in custom `description` parameters to
+[`TestProcess.start()`][start] to control the headers.
+
+[start]: https://pub.dev/documentation/test_process/latest/test_process/TestProcess/start.html
+
+`TestProcess` will also produce debugging output as the test runs if you pass
+`forwardStdio: true` to `TestProcess.start()`. This can be particularly useful
+when you're using an interactive debugger and you want to figure out what a
+process is doing before the test finishes and the normal debugging output is
+printed.
diff --git a/pkgs/test_process/analysis_options.yaml b/pkgs/test_process/analysis_options.yaml
new file mode 100644
index 0000000..5607754
--- /dev/null
+++ b/pkgs/test_process/analysis_options.yaml
@@ -0,0 +1,18 @@
+# https://dart.dev/tools/analysis#the-analysis-options-file
+include: package:dart_flutter_team_lints/analysis_options.yaml
+
+analyzer:
+  language:
+    strict-casts: true
+    strict-inference: true
+    strict-raw-types: true
+
+linter:
+  rules:
+    - avoid_unused_constructor_parameters
+    - cancel_subscriptions
+    - literal_only_boolean_expressions
+    - missing_whitespace_between_adjacent_strings
+    - no_adjacent_strings_in_list
+    - no_runtimeType_toString
+    - unnecessary_await_in_return
diff --git a/pkgs/test_process/example/example.dart b/pkgs/test_process/example/example.dart
new file mode 100644
index 0000000..22175f4
--- /dev/null
+++ b/pkgs/test_process/example/example.dart
@@ -0,0 +1,26 @@
+// 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 'package:test/test.dart';
+import 'package:test_process/test_process.dart';
+
+void main() {
+  test('pub get gets dependencies', () async {
+    // TestProcess.start() works just like Process.start() from dart:io.
+    var process = await TestProcess.start('dart', ['pub', 'get']);
+
+    // StreamQueue.next returns the next line emitted on standard out.
+    var firstLine = await process.stdout.next;
+    expect(firstLine, equals('Resolving dependencies...'));
+
+    // Each call to StreamQueue.next moves one line further.
+    String next;
+    do {
+      next = await process.stdout.next;
+    } while (next != 'Got dependencies!');
+
+    // Assert that the process exits with code 0.
+    await process.shouldExit(0);
+  });
+}
diff --git a/pkgs/test_process/lib/test_process.dart b/pkgs/test_process/lib/test_process.dart
new file mode 100644
index 0000000..0441fb1
--- /dev/null
+++ b/pkgs/test_process/lib/test_process.dart
@@ -0,0 +1,239 @@
+// Copyright (c) 2017, 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:convert';
+import 'dart:io';
+
+import 'package:async/async.dart';
+import 'package:meta/meta.dart';
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+
+/// A wrapper for [Process] that provides a convenient API for testing its
+/// standard IO and interacting with it from a test.
+///
+/// If the test fails, this will automatically print out any stdout and stderr
+/// from the process to aid debugging.
+///
+/// This may be extended to provide custom implementations of [stdoutStream] and
+/// [stderrStream]. These will automatically be picked up by the [stdout] and
+/// [stderr] queues, but the debug log will still contain the original output.
+class TestProcess {
+  /// The underlying process.
+  final Process _process;
+
+  /// A human-friendly description of this process.
+  final String description;
+
+  /// A [StreamQueue] that emits each line of stdout from the process.
+  ///
+  /// A copy of the underlying stream can be retrieved using [stdoutStream].
+  late final StreamQueue<String> stdout = StreamQueue(stdoutStream());
+
+  /// A [StreamQueue] that emits each line of stderr from the process.
+  ///
+  /// A copy of the underlying stream can be retrieved using [stderrStream].
+  late final StreamQueue<String> stderr = StreamQueue(stderrStream());
+
+  /// A splitter that can emit new copies of [stdout].
+  final StreamSplitter<String> _stdoutSplitter;
+
+  /// A splitter that can emit new copies of [stderr].
+  final StreamSplitter<String> _stderrSplitter;
+
+  /// The standard input sink for this process.
+  IOSink get stdin => _process.stdin;
+
+  /// A buffer of mixed stdout and stderr lines.
+  final List<String> _log = <String>[];
+
+  /// Whether [_log] has been passed to [printOnFailure] yet.
+  bool _loggedOutput = false;
+
+  /// Returns a [Future] which completes to the exit code of the process, once
+  /// it completes.
+  Future<int> get exitCode => _process.exitCode;
+
+  /// The process ID of the process.
+  int get pid => _process.pid;
+
+  /// Completes to [_process]'s exit code if it's exited, otherwise completes to
+  /// `null` immediately.
+  Future<int?> get _exitCodeOrNull => exitCode
+      .then<int?>((value) => value)
+      .timeout(Duration.zero, onTimeout: () => null);
+
+  /// Starts a process.
+  ///
+  /// [executable], [arguments], [workingDirectory], and [environment] have the
+  /// same meaning as for [Process.start].
+  ///
+  /// [description] is a string description of this process; it defaults to the
+  /// command-line invocation. [encoding] is the [Encoding] that will be used
+  /// for the process's input and output; it defaults to [utf8].
+  ///
+  /// If [forwardStdio] is `true`, the process's stdout and stderr will be
+  /// printed to the console as they appear. This is only intended to be set
+  /// temporarily to help when debugging test failures.
+  static Future<TestProcess> start(
+      String executable, Iterable<String> arguments,
+      {String? workingDirectory,
+      Map<String, String>? environment,
+      bool includeParentEnvironment = true,
+      bool runInShell = false,
+      String? description,
+      Encoding encoding = utf8,
+      bool forwardStdio = false}) async {
+    var process = await Process.start(executable, arguments.toList(),
+        workingDirectory: workingDirectory,
+        environment: environment,
+        includeParentEnvironment: includeParentEnvironment,
+        runInShell: runInShell);
+
+    if (description == null) {
+      var humanExecutable = p.isWithin(p.current, executable)
+          ? p.relative(executable)
+          : executable;
+      description = "$humanExecutable ${arguments.join(" ")}";
+    }
+
+    return TestProcess(process, description,
+        encoding: encoding, forwardStdio: forwardStdio);
+  }
+
+  /// Creates a [TestProcess] for [process].
+  ///
+  /// The [description], [encoding], and [forwardStdio] are the same as those to
+  /// [start].
+  ///
+  /// This is protected, which means it should only be called by subclasses.
+  @protected
+  TestProcess(Process process, this.description,
+      {Encoding encoding = utf8, bool forwardStdio = false})
+      : _process = process,
+        _stdoutSplitter = StreamSplitter(process.stdout
+            .transform(encoding.decoder)
+            .transform(const LineSplitter())),
+        _stderrSplitter = StreamSplitter(process.stderr
+            .transform(encoding.decoder)
+            .transform(const LineSplitter())) {
+    addTearDown(_tearDown);
+
+    _process.exitCode.whenComplete(_logOutput);
+
+    // Listen eagerly so that the lines are interleaved properly between the two
+    // streams.
+    //
+    // Call [split] explicitly because we don't want to log overridden
+    // [stdoutStream] or [stderrStream] output.
+    _stdoutSplitter.split().listen((line) {
+      _heartbeat();
+      if (forwardStdio) print(line);
+      _log.add('    $line');
+    });
+
+    _stderrSplitter.split().listen((line) {
+      _heartbeat();
+      if (forwardStdio) print(line);
+      _log.add('[e] $line');
+    });
+  }
+
+  /// A callback that's run when the test completes.
+  Future<void> _tearDown() async {
+    // If the process is already dead, do nothing.
+    if (await _exitCodeOrNull != null) return;
+
+    _process.kill(ProcessSignal.sigkill);
+
+    // Log output now rather than waiting for the exitCode callback so that
+    // it's visible even if we time out waiting for the process to die.
+    await _logOutput();
+  }
+
+  /// Formats the contents of [_log] and passes them to [printOnFailure].
+  Future<void> _logOutput() async {
+    if (_loggedOutput) return;
+    _loggedOutput = true;
+
+    var exitCodeOrNull = await _exitCodeOrNull;
+
+    // Wait a timer tick to ensure that all available lines have been flushed to
+    // [_log].
+    await Future<void>.delayed(Duration.zero);
+
+    var buffer = StringBuffer();
+    buffer.write('Process `$description` ');
+    if (exitCodeOrNull == null) {
+      buffer.writeln('was killed with SIGKILL in a tear-down. Output:');
+    } else {
+      buffer.writeln('exited with exitCode $exitCodeOrNull. Output:');
+    }
+
+    buffer.writeln(_log.join('\n'));
+    printOnFailure(buffer.toString());
+  }
+
+  /// Returns a copy of [stdout] as a single-subscriber stream.
+  ///
+  /// Each time this is called, it will return a separate copy that will start
+  /// from the beginning of the process.
+  ///
+  /// This can be overridden by subclasses to return a derived standard output
+  /// stream. This stream will then be used for [stdout].
+  Stream<String> stdoutStream() => _stdoutSplitter.split();
+
+  /// Returns a copy of [stderr] as a single-subscriber stream.
+  ///
+  /// Each time this is called, it will return a separate copy that will start
+  /// from the beginning of the process.
+  ///
+  /// This can be overridden by subclasses to return a derived standard output
+  /// stream. This stream will then be used for [stderr].
+  Stream<String> stderrStream() => _stderrSplitter.split();
+
+  /// Sends [signal] to the process.
+  ///
+  /// This is meant for sending specific signals. If you just want to kill the
+  /// process, use [kill] instead.
+  ///
+  /// Throws an [UnsupportedError] on Windows.
+  void signal(ProcessSignal signal) {
+    if (Platform.isWindows) {
+      throw UnsupportedError(
+          "TestProcess.signal() isn't supported on Windows.");
+    }
+
+    _process.kill(signal);
+  }
+
+  /// Kills the process (with SIGKILL on POSIX operating systems), and returns a
+  /// future that completes once it's dead.
+  ///
+  /// If this is called after the process is already dead, it does nothing.
+  Future<void> kill() async {
+    _process.kill(ProcessSignal.sigkill);
+    await exitCode;
+  }
+
+  /// Waits for the process to exit, and verifies that the exit code matches
+  /// [expectedExitCode] (if given).
+  ///
+  /// If this is called after the process is already dead, it verifies its
+  /// existing exit code.
+  Future<void> shouldExit([Object? expectedExitCode]) async {
+    var exitCode = await this.exitCode;
+    if (expectedExitCode == null) return;
+    expect(exitCode, expectedExitCode,
+        reason: 'Process `$description` had an unexpected exit code.');
+  }
+
+  /// Signal to the test runner that the test is still making progress and
+  /// shouldn't time out.
+  void _heartbeat() {
+    // Interacting with the test runner's asynchronous expectation logic will
+    // notify it that the test is alive.
+    expectAsync0(() {})();
+  }
+}
diff --git a/pkgs/test_process/pubspec.yaml b/pkgs/test_process/pubspec.yaml
new file mode 100644
index 0000000..60158d4
--- /dev/null
+++ b/pkgs/test_process/pubspec.yaml
@@ -0,0 +1,18 @@
+name: test_process
+version: 2.1.1
+description: |
+  Test processes: starting; validating stdout and stderr; checking exit code
+repository: https://github.com/dart-lang/test/tree/master/pkgs/test_process
+
+environment:
+  sdk: ^3.1.0
+
+dependencies:
+  async: ^2.5.0
+  meta: ^1.3.0
+  path: ^1.8.0
+  test: ^1.16.6
+
+dev_dependencies:
+  dart_flutter_team_lints: ^3.0.0
+  test_descriptor: ^2.0.0
diff --git a/pkgs/test_process/test/test_process_test.dart b/pkgs/test_process/test/test_process_test.dart
new file mode 100644
index 0000000..9cfb779
--- /dev/null
+++ b/pkgs/test_process/test/test_process_test.dart
@@ -0,0 +1,136 @@
+// Copyright (c) 2017, 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:io';
+
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+import 'package:test_process/test_process.dart';
+
+final throwsTestFailure = throwsA(isA<TestFailure>());
+
+void main() {
+  group('shouldExit()', () {
+    test('succeeds when the process exits with the given exit code', () async {
+      var process = await startDartProcess('exitCode = 42;');
+      expect(process.exitCode, completion(equals(42)));
+      await process.shouldExit(greaterThan(12));
+    });
+
+    test('fails when the process exits with a different exit code', () async {
+      var process = await startDartProcess('exitCode = 1;');
+      expect(process.exitCode, completion(equals(1)));
+      expect(process.shouldExit(greaterThan(12)), throwsTestFailure);
+    });
+
+    test('allows any exit code without an assertion', () async {
+      var process = await startDartProcess('exitCode = 1;');
+      expect(process.exitCode, completion(equals(1)));
+      await process.shouldExit();
+    });
+  });
+
+  test('kill() stops the process', () async {
+    var process = await startDartProcess('while (true);');
+
+    // Should terminate.
+    await process.kill();
+  });
+
+  group('stdout and stderr', () {
+    test("expose the process's standard io", () async {
+      var process = await startDartProcess(r'''
+        print("hello");
+        stderr.writeln("hi");
+        print("\nworld");
+      ''');
+
+      expect(process.stdout, emitsInOrder(['hello', '', 'world', emitsDone]));
+      expect(process.stderr, emitsInOrder(['hi', emitsDone]));
+      await process.shouldExit(0);
+    });
+
+    test('close when the process exits', () async {
+      var process = await startDartProcess('');
+      expect(expectLater(process.stdout, emits('hello')), throwsTestFailure);
+      expect(expectLater(process.stderr, emits('world')), throwsTestFailure);
+      await process.shouldExit(0);
+    });
+  });
+
+  test("stdoutStream() and stderrStream() copy the process's standard io",
+      () async {
+    var process = await startDartProcess(r'''
+      print("hello");
+      stderr.writeln("hi");
+      print("\nworld");
+    ''');
+
+    expect(process.stdoutStream(),
+        emitsInOrder(['hello', '', 'world', emitsDone]));
+    expect(process.stdoutStream(),
+        emitsInOrder(['hello', '', 'world', emitsDone]));
+
+    expect(process.stderrStream(), emitsInOrder(['hi', emitsDone]));
+    expect(process.stderrStream(), emitsInOrder(['hi', emitsDone]));
+
+    await process.shouldExit(0);
+
+    expect(process.stdoutStream(),
+        emitsInOrder(['hello', '', 'world', emitsDone]));
+    expect(process.stderrStream(), emitsInOrder(['hi', emitsDone]));
+  });
+
+  test('stdin writes to the process', () async {
+    var process = await startDartProcess(r'''
+      stdinLines.listen((line) => print("> $line"));
+    ''');
+
+    process.stdin.writeln('hello');
+    await expectLater(process.stdout, emits('> hello'));
+    process.stdin.writeln('world');
+    await expectLater(process.stdout, emits('> world'));
+    await process.kill();
+  });
+
+  test('signal sends a signal to the process', () async {
+    var process = await startDartProcess(r'''
+      ProcessSignal.sighup.watch().listen((_) => print("HUP"));
+      print("ready");
+    ''');
+
+    await expectLater(process.stdout, emits('ready'));
+    process.signal(ProcessSignal.sighup);
+    await expectLater(process.stdout, emits('HUP'));
+    await process.kill();
+  }, testOn: '!windows');
+
+  test('allows a long-running process', () async {
+    await startDartProcess(r'''
+      await Future.delayed(Duration(minutes: 10));
+    ''');
+    // Test should not time out.
+  });
+}
+
+/// Starts a Dart process running [script] in a main method.
+Future<TestProcess> startDartProcess(String script) {
+  var dartPath = p.join(d.sandbox, 'test.dart');
+  File(dartPath).writeAsStringSync('''
+    import 'dart:async';
+    import 'dart:convert';
+    import 'dart:io';
+
+    var stdinLines = stdin
+        .transform(utf8.decoder)
+        .transform(new LineSplitter());
+
+    void main() {
+      $script
+    }
+  ''');
+
+  return TestProcess.start(Platform.executable, ['--enable-asserts', dartPath]);
+}