Merge branch 'master' into merge-test_descriptor-package
diff --git a/.github/ISSUE_TEMPLATE/test_descriptor.md b/.github/ISSUE_TEMPLATE/test_descriptor.md
new file mode 100644
index 0000000..32de9a0
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/test_descriptor.md
@@ -0,0 +1,5 @@
+---
+name: "package:test_descriptor"
+about: "Create a bug or file a feature request against package:test_descriptor."
+labels: "package:test_descriptor"
+---
\ No newline at end of file
diff --git a/.github/workflows/test_descriptor.yaml b/.github/workflows/test_descriptor.yaml
new file mode 100644
index 0000000..928de57
--- /dev/null
+++ b/.github/workflows/test_descriptor.yaml
@@ -0,0 +1,72 @@
+name: package:test_descriptor
+
+on:
+  # Run on PRs and pushes to the default branch.
+  push:
+    branches: [ master ]
+    paths:
+      - '.github/workflows/test_descriptor.yaml'
+      - 'pkgs/test_descriptor/**'
+  pull_request:
+    branches: [ master ]
+    paths:
+      - '.github/workflows/test_descriptor.yaml'
+      - 'pkgs/test_descriptor/**'
+  schedule:
+    - cron: "0 0 * * 0"
+
+env:
+  PUB_ENVIRONMENT: bot.github
+
+
+defaults:
+  run:
+    working-directory: pkgs/test_descriptor/
+
+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 channel: 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 5b80a28..f178ccb 100644
--- a/README.md
+++ b/README.md
@@ -22,4 +22,5 @@
 | [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_descriptor](pkgs/test_descriptor/) | An API for defining and verifying files and directory structures. | [![pub package](https://img.shields.io/pub/v/test_descriptor.svg)](https://pub.dev/packages/test_descriptor) |
 | [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_descriptor/.gitignore b/pkgs/test_descriptor/.gitignore
new file mode 100644
index 0000000..0659a33
--- /dev/null
+++ b/pkgs/test_descriptor/.gitignore
@@ -0,0 +1,9 @@
+.buildlog
+.DS_Store
+.idea
+.settings/
+build/
+packages
+.packages
+pubspec.lock
+.dart_tool/
diff --git a/pkgs/test_descriptor/AUTHORS b/pkgs/test_descriptor/AUTHORS
new file mode 100644
index 0000000..e8063a8
--- /dev/null
+++ b/pkgs/test_descriptor/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_descriptor/CHANGELOG.md b/pkgs/test_descriptor/CHANGELOG.md
new file mode 100644
index 0000000..b9d8766
--- /dev/null
+++ b/pkgs/test_descriptor/CHANGELOG.md
@@ -0,0 +1,59 @@
+## 2.0.2
+
+* Require Dart 3.1 or later.
+* Move to `dart-lang/test` monorepo.
+
+## 2.0.1
+
+* Populate the pubspec `repository` field.
+* Migrate to `package:lints`.
+* Update the package's markdown badges.
+
+## 2.0.0
+
+* Null safety stable release.
+* BREAKING: Removed archive support.
+* BREAKING: `DirectoryDescriptor.load` only supports a `String` path instead of
+  also accepting relative `Uri` objects.
+* BREAKING: `DirectoryDescriptor.load` no longer has an optional `parents`
+  parameter - this was intended for internal use only.
+
+## 1.2.0
+
+* Add an `ArchiveDescriptor` class and a corresponding `archive()` function that
+  can create and validate Zip and TAR archives.
+
+## 1.1.1
+
+* Update to lowercase Dart core library constants.
+
+## 1.1.0
+
+* Add a `path()` function that returns the a path within the sandbox directory.
+
+* Add `io` getters to `FileDescriptor` and `DirectoryDescriptor` that returns
+  `dart:io` `File` and `Directory` objects, respectively, within the sandbox
+  directory.
+
+## 1.0.4
+
+* Support test `1.x.x'.
+
+## 1.0.3
+
+* Stop using comment-based generics.
+
+## 1.0.2
+
+* Declare support for `async` 2.0.0.
+
+## 1.0.1
+
+* `FileDescriptor.validate()` now allows invalid UTF-8 files.
+
+* Fix a bug where `DirectoryDescriptor.load()` would incorrectly report that
+  multiple versions of a file or directory existed.
+
+## 1.0.0
+
+* Initial version.
diff --git a/pkgs/test_descriptor/LICENSE b/pkgs/test_descriptor/LICENSE
new file mode 100644
index 0000000..2372431
--- /dev/null
+++ b/pkgs/test_descriptor/LICENSE
@@ -0,0 +1,27 @@
+Copyright 2016, 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_descriptor/README.md b/pkgs/test_descriptor/README.md
new file mode 100644
index 0000000..9a1d277
--- /dev/null
+++ b/pkgs/test_descriptor/README.md
@@ -0,0 +1,56 @@
+[![Build Status](https://github.com/dart-lang/test/actions/workflows/test_descriptor.yaml/badge.svg)](https://github.com/dart-lang/test/actions/workflows/test_descriptor.yaml)
+[![pub package](https://img.shields.io/pub/v/test_descriptor.svg)](https://pub.dev/packages/test_descriptor)
+[![package publisher](https://img.shields.io/pub/publisher/test_descriptor.svg)](https://pub.dev/packages/test_descriptor/publisher)
+
+The `test_descriptor` package provides a convenient, easy-to-read API for
+defining and verifying directory structures in tests.
+
+## Usage
+
+We recommend that you import this library with the `d` prefix. The
+[`d.dir()`][dir] and [`d.file()`][file] functions are the main entrypoints. They
+define a filesystem structure that can be created using
+[`Descriptor.create()`][create] and verified using
+[`Descriptor.validate()`][validate]. For example:
+
+[dir]: https://pub.dev/documentation/test_descriptor/latest/test_descriptor/dir.html
+[file]: https://pub.dev/documentation/test_descriptor/latest/test_descriptor/file.html
+[create]: https://pub.dev/documentation/test_descriptor/latest/test_descriptor/Descriptor/create.html
+[validate]: https://pub.dev/documentation/test_descriptor/latest/test_descriptor/Descriptor/validate.html
+
+```dart
+import 'dart:io';
+
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+
+void main() {
+  test('Directory.rename', () async {
+    await d.dir('parent', [
+      d.file('sibling', 'sibling-contents'),
+      d.dir('old-name', [d.file('child', 'child-contents')])
+    ]).create();
+
+    await Directory('${d.sandbox}/parent/old-name')
+        .rename('${d.sandbox}/parent/new-name');
+
+    await d.dir('parent', [
+      d.file('sibling', 'sibling-contents'),
+      d.dir('new-name', [d.file('child', 'child-contents')])
+    ]).validate();
+  });
+}
+```
+
+By default, descriptors create entries in a temporary sandbox directory,
+[`d.sandbox`][sandbox]. A new sandbox is automatically created the first time
+you create a descriptor in a given test, and automatically deleted once the test
+finishes running.
+
+[sandbox]: https://pub.dev/documentation/test_descriptor/latest/test_descriptor/sandbox.html
+
+This package is [`term_glyph`][term_glyph] aware. It will decide whether to use
+ASCII or Unicode glyphs based on the [`glyph.ascii`][ascii] attribute.
+
+[term_glyph]: https://pub.dev/packages/term_glyph
+[ascii]: https://pub.dev/documentation/term_glyph/latest/term_glyph/ascii.html
diff --git a/pkgs/test_descriptor/analysis_options.yaml b/pkgs/test_descriptor/analysis_options.yaml
new file mode 100644
index 0000000..e9a8c39
--- /dev/null
+++ b/pkgs/test_descriptor/analysis_options.yaml
@@ -0,0 +1,33 @@
+# https://dart.dev/guides/language/analysis-options
+include: package:dart_flutter_team_lints/analysis_options.yaml
+
+analyzer:
+  language:
+    strict-casts: true
+    strict-inference: true
+    strict-raw-types: true
+
+linter:
+  rules:
+    - avoid_bool_literals_in_conditional_expressions
+    - avoid_classes_with_only_static_members
+    - avoid_private_typedef_functions
+    - avoid_redundant_argument_values
+    - avoid_returning_this
+    - avoid_unused_constructor_parameters
+    - avoid_void_async
+    - cancel_subscriptions
+    - join_return_with_assignment
+    - literal_only_boolean_expressions
+    - missing_whitespace_between_adjacent_strings
+    - no_adjacent_strings_in_list
+    - no_runtimeType_toString
+    - prefer_const_declarations
+    - prefer_expression_function_bodies
+    - prefer_final_locals
+    - unnecessary_await_in_return
+    - unnecessary_raw_strings
+    - use_if_null_to_convert_nulls_to_bools
+    - use_raw_strings
+    - use_string_buffers
+    - require_trailing_commas
diff --git a/pkgs/test_descriptor/example/example.dart b/pkgs/test_descriptor/example/example.dart
new file mode 100644
index 0000000..4573832
--- /dev/null
+++ b/pkgs/test_descriptor/example/example.dart
@@ -0,0 +1,25 @@
+// 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:io';
+
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+
+void main() {
+  test('Directory.rename', () async {
+    await d.dir('parent', [
+      d.file('sibling', 'sibling-contents'),
+      d.dir('old-name', [d.file('child', 'child-contents')]),
+    ]).create();
+
+    await Directory('${d.sandbox}/parent/old-name')
+        .rename('${d.sandbox}/parent/new-name');
+
+    await d.dir('parent', [
+      d.file('sibling', 'sibling-contents'),
+      d.dir('new-name', [d.file('child', 'child-contents')]),
+    ]).validate();
+  });
+}
diff --git a/pkgs/test_descriptor/lib/src/descriptor.dart b/pkgs/test_descriptor/lib/src/descriptor.dart
new file mode 100644
index 0000000..0d6c30c
--- /dev/null
+++ b/pkgs/test_descriptor/lib/src/descriptor.dart
@@ -0,0 +1,26 @@
+// 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 'sandbox.dart';
+
+/// A declarative description of a filesystem entry.
+///
+/// This may be extended outside this package.
+abstract class Descriptor {
+  /// This entry's basename.
+  final String name;
+
+  Descriptor(this.name);
+
+  /// Creates this entry within the [parent] directory, which defaults to
+  /// [sandbox].
+  Future<void> create([String? parent]);
+
+  /// Validates that the physical file system under [parent] (which defaults to
+  /// [sandbox]) contains an entry that matches this descriptor.
+  Future<void> validate([String? parent]);
+
+  /// Returns a human-friendly tree-style description of this descriptor.
+  String describe();
+}
diff --git a/pkgs/test_descriptor/lib/src/directory_descriptor.dart b/pkgs/test_descriptor/lib/src/directory_descriptor.dart
new file mode 100644
index 0000000..c4f2694
--- /dev/null
+++ b/pkgs/test_descriptor/lib/src/directory_descriptor.dart
@@ -0,0 +1,132 @@
+// 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:async/async.dart';
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+
+import 'descriptor.dart';
+import 'file_descriptor.dart';
+import 'sandbox.dart';
+import 'utils.dart';
+
+/// A descriptor describing a directory that may contain nested descriptors.
+///
+/// In addition to the normal descriptor methods, this has a [load] method that
+/// allows it to be used as a virtual filesystem.
+///
+/// This may be extended outside this package.
+class DirectoryDescriptor extends Descriptor {
+  /// Descriptors for entries in this directory.
+  ///
+  /// This may be modified.
+  final List<Descriptor> contents;
+
+  /// Returns a `dart:io` [Directory] object that refers to this file within
+  /// [sandbox].
+  Directory get io => Directory(p.join(sandbox, name));
+
+  DirectoryDescriptor(super.name, Iterable<Descriptor> contents)
+      : contents = contents.toList();
+
+  /// Creates a directory descriptor named [name] that describes the physical
+  /// directory at [path].
+  factory DirectoryDescriptor.fromFilesystem(String name, String path) =>
+      DirectoryDescriptor(
+        name,
+        Directory(path).listSync().map((entity) {
+          // Ignore hidden files.
+          if (p.basename(entity.path).startsWith('.')) return null;
+
+          if (entity is Directory) {
+            return DirectoryDescriptor.fromFilesystem(
+              p.basename(entity.path),
+              entity.path,
+            );
+          } else if (entity is File) {
+            return FileDescriptor(
+              p.basename(entity.path),
+              entity.readAsBytesSync(),
+            );
+          }
+          // Ignore broken symlinks.
+          return null;
+        }).whereType<Descriptor>(),
+      );
+
+  @override
+  Future<void> create([String? parent]) async {
+    final fullPath = p.join(parent ?? sandbox, name);
+    await Directory(fullPath).create(recursive: true);
+    await Future.wait(contents.map((entry) => entry.create(fullPath)));
+  }
+
+  @override
+  Future<void> validate([String? parent]) async {
+    final fullPath = p.join(parent ?? sandbox, name);
+    if (!(await Directory(fullPath).exists())) {
+      fail('Directory not found: "${prettyPath(fullPath)}".');
+    }
+
+    await waitAndReportErrors(
+      contents.map((entry) => entry.validate(fullPath)),
+    );
+  }
+
+  /// Treats this descriptor as a virtual filesystem and loads the binary
+  /// contents of the [FileDescriptor] at the given relative [path].
+  Stream<List<int>> load(String path) => _load(path);
+
+  /// Implementation of [load], tracks parents through recursive calls.
+  Stream<List<int>> _load(String path, [String? parents]) {
+    if (!p.url.isWithin('.', path)) {
+      throw ArgumentError.value(
+        path,
+        'path',
+        'must be relative and beneath the base URL.',
+      );
+    }
+
+    return StreamCompleter.fromFuture(
+      Future.sync(() {
+        final split = p.url.split(p.url.normalize(path));
+        final file = split.length == 1;
+        final matchingEntries = contents
+            .where(
+              (entry) =>
+                  entry.name == split.first &&
+                  (file
+                      ? entry is FileDescriptor
+                      : entry is DirectoryDescriptor),
+            )
+            .toList();
+
+        final type = file ? 'file' : 'directory';
+        final parentsAndSelf =
+            parents == null ? name : p.url.join(parents, name);
+        if (matchingEntries.isEmpty) {
+          fail(
+              'Couldn\'t find a $type descriptor named "${split.first}" within '
+              '"$parentsAndSelf".');
+        } else if (matchingEntries.length > 1) {
+          fail('Found multiple $type descriptors named "${split.first}" within '
+              '"$parentsAndSelf".');
+        } else {
+          final remainingPath = split.sublist(1);
+          if (remainingPath.isEmpty) {
+            return (matchingEntries.first as FileDescriptor).readAsBytes();
+          } else {
+            return (matchingEntries.first as DirectoryDescriptor)
+                ._load(p.url.joinAll(remainingPath), parentsAndSelf);
+          }
+        }
+      }),
+    );
+  }
+
+  @override
+  String describe() => describeDirectory(name, contents);
+}
diff --git a/pkgs/test_descriptor/lib/src/file_descriptor.dart b/pkgs/test_descriptor/lib/src/file_descriptor.dart
new file mode 100644
index 0000000..2daedd5
--- /dev/null
+++ b/pkgs/test_descriptor/lib/src/file_descriptor.dart
@@ -0,0 +1,210 @@
+// 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:async';
+import 'dart:io';
+import 'dart:math' as math;
+
+import 'package:collection/collection.dart';
+import 'package:path/path.dart' as p;
+import 'package:term_glyph/term_glyph.dart' as glyph;
+import 'package:test/test.dart';
+
+import 'descriptor.dart';
+import 'sandbox.dart';
+import 'utils.dart';
+
+/// A descriptor describing a single file.
+///
+/// In addition to the normal descriptor methods, this has [read] and
+/// [readAsBytes] methods that allows its contents to be read.
+///
+/// This may be extended outside this package.
+abstract class FileDescriptor extends Descriptor {
+  /// Creates a new [FileDescriptor] with [name] and [contents].
+  ///
+  /// The [contents] may be a `String`, a `List<int>`, or a [Matcher]. If it's a
+  /// string, [create] creates a UTF-8 file and [validate] parses the physical
+  /// file as UTF-8. If it's a [Matcher], [validate] matches it against the
+  /// physical file's contents parsed as UTF-8, and [create], [read], and
+  /// [readAsBytes] are unsupported.
+  ///
+  /// If [contents] isn't passed, [create] creates an empty file and [validate]
+  /// verifies that the file is empty.
+  ///
+  /// To match a [Matcher] against a file's binary contents, use
+  /// [FileDescriptor.binaryMatcher] instead.
+  factory FileDescriptor(String name, Object? contents) {
+    if (contents is String) return _StringFileDescriptor(name, contents);
+    if (contents is List) {
+      return _BinaryFileDescriptor(name, contents.cast<int>());
+    }
+    if (contents == null) return _BinaryFileDescriptor(name, []);
+    return _MatcherFileDescriptor(name, contents as Matcher);
+  }
+
+  /// Returns a `dart:io` [File] object that refers to this file within
+  /// [sandbox].
+  File get io => File(p.join(sandbox, name));
+
+  /// Creates a new binary [FileDescriptor] with [name] that matches its binary
+  /// contents against [matcher].
+  ///
+  /// The [create], [read], and [readAsBytes] methods are unsupported for this
+  /// descriptor.
+  factory FileDescriptor.binaryMatcher(String name, Matcher matcher) =>
+      _MatcherFileDescriptor(name, matcher, isBinary: true);
+
+  /// A protected constructor that's only intended for subclasses.
+  FileDescriptor.protected(super.name);
+
+  @override
+  Future<void> create([String? parent]) async {
+    // Create the stream before we call [File.openWrite] because it may fail
+    // fast (e.g. if this is a matcher file).
+    final file = File(p.join(parent ?? sandbox, name)).openWrite();
+    try {
+      await readAsBytes().forEach(file.add);
+    } finally {
+      await file.close();
+    }
+  }
+
+  @override
+  Future<void> validate([String? parent]) async {
+    final fullPath = p.join(parent ?? sandbox, name);
+    final pretty = prettyPath(fullPath);
+    if (!(await File(fullPath).exists())) {
+      fail('File not found: "$pretty".');
+    }
+
+    await _validate(pretty, await File(fullPath).readAsBytes());
+  }
+
+  /// Validates that [binaryContents] matches the expected contents of
+  /// the descriptor.
+  ///
+  /// The [prettyPath] is a human-friendly representation of the path to the
+  /// descriptor.
+  FutureOr<void> _validate(String prettyPath, List<int> binaryContents);
+
+  /// Reads and decodes the contents of this descriptor as a UTF-8 string.
+  ///
+  /// This isn't supported for matcher descriptors.
+  Future<String> read() => utf8.decodeStream(readAsBytes());
+
+  /// Reads the contents of this descriptor as a byte stream.
+  ///
+  /// This isn't supported for matcher descriptors.
+  Stream<List<int>> readAsBytes();
+
+  @override
+  String describe() => name;
+}
+
+class _BinaryFileDescriptor extends FileDescriptor {
+  /// The contents of this descriptor's file.
+  final List<int> _contents;
+
+  _BinaryFileDescriptor(super.name, this._contents) : super.protected();
+
+  @override
+  Stream<List<int>> readAsBytes() => Stream.fromIterable([_contents]);
+
+  @override
+  Future<void> _validate(String prettPath, List<int> actualContents) async {
+    if (const IterableEquality<int>().equals(_contents, actualContents)) return;
+    // TODO(nweiz): show a hex dump here if the data is small enough.
+    fail('File "$prettPath" didn\'t contain the expected binary data.');
+  }
+}
+
+class _StringFileDescriptor extends FileDescriptor {
+  /// The contents of this descriptor's file.
+  final String _contents;
+
+  _StringFileDescriptor(super.name, this._contents) : super.protected();
+
+  @override
+  Future<String> read() async => _contents;
+
+  @override
+  Stream<List<int>> readAsBytes() =>
+      Stream.fromIterable([utf8.encode(_contents)]);
+
+  @override
+  void _validate(String prettyPath, List<int> actualContents) {
+    final actualContentsText = utf8.decode(actualContents);
+    if (_contents == actualContentsText) return;
+    fail(_textMismatchMessage(prettyPath, _contents, actualContentsText));
+  }
+
+  String _textMismatchMessage(
+    String prettyPath,
+    String expected,
+    String actual,
+  ) {
+    final expectedLines = expected.split('\n');
+    final actualLines = actual.split('\n');
+
+    final results = <String>[];
+
+    // Compare them line by line to see which ones match.
+    final length = math.max(expectedLines.length, actualLines.length);
+    for (var i = 0; i < length; i++) {
+      if (i >= actualLines.length) {
+        // Missing output.
+        results.add('? ${expectedLines[i]}');
+      } else if (i >= expectedLines.length) {
+        // Unexpected extra output.
+        results.add('X ${actualLines[i]}');
+      } else {
+        final expectedLine = expectedLines[i];
+        final actualLine = actualLines[i];
+
+        if (expectedLine != actualLine) {
+          // Mismatched lines.
+          results.add('X $actualLine');
+        } else {
+          // Matched lines.
+          results.add('${glyph.verticalLine} $actualLine');
+        }
+      }
+    }
+
+    return 'File "$prettyPath" should contain:\n'
+        '${addBar(expected)}\n'
+        'but actually contained:\n'
+        "${results.join('\n')}";
+  }
+}
+
+class _MatcherFileDescriptor extends FileDescriptor {
+  /// The matcher for this descriptor's contents.
+  final Matcher _matcher;
+
+  /// Whether [_matcher] should match against the file's string or byte
+  /// contents.
+  final bool _isBinary;
+
+  _MatcherFileDescriptor(super.name, this._matcher, {bool isBinary = false})
+      : _isBinary = isBinary,
+        super.protected();
+
+  @override
+  Stream<List<int>> readAsBytes() =>
+      throw UnsupportedError("Matcher files can't be created or read.");
+
+  @override
+  Future<void> _validate(String prettyPath, List<int> actualContents) async {
+    try {
+      expect(
+        _isBinary ? actualContents : utf8.decode(actualContents),
+        _matcher,
+      );
+    } on TestFailure catch (error) {
+      fail('Invalid contents for file "$prettyPath":\n${error.message}');
+    }
+  }
+}
diff --git a/pkgs/test_descriptor/lib/src/nothing_descriptor.dart b/pkgs/test_descriptor/lib/src/nothing_descriptor.dart
new file mode 100644
index 0000000..da9e2a2
--- /dev/null
+++ b/pkgs/test_descriptor/lib/src/nothing_descriptor.dart
@@ -0,0 +1,38 @@
+// 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 'descriptor.dart';
+import 'sandbox.dart';
+import 'utils.dart';
+
+/// A descriptor that validates that no file exists with the given name.
+///
+/// Calling [create] does nothing.
+class NothingDescriptor extends Descriptor {
+  NothingDescriptor(super.name);
+
+  @override
+  Future<void> create([String? parent]) async {}
+
+  @override
+  Future<void> validate([String? parent]) async {
+    final fullPath = p.join(parent ?? sandbox, name);
+    final pretty = prettyPath(fullPath);
+    if (File(fullPath).existsSync()) {
+      fail('Expected nothing to exist at "$pretty", but found a file.');
+    } else if (Directory(fullPath).existsSync()) {
+      fail('Expected nothing to exist at "$pretty", but found a directory.');
+    } else if (Link(fullPath).existsSync()) {
+      fail('Expected nothing to exist at "$pretty", but found a link.');
+    }
+  }
+
+  @override
+  String describe() => 'nothing at "$name"';
+}
diff --git a/pkgs/test_descriptor/lib/src/pattern_descriptor.dart b/pkgs/test_descriptor/lib/src/pattern_descriptor.dart
new file mode 100644
index 0000000..d55afc0
--- /dev/null
+++ b/pkgs/test_descriptor/lib/src/pattern_descriptor.dart
@@ -0,0 +1,105 @@
+// 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:async';
+import 'dart:io';
+
+import 'package:async/async.dart';
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+
+import 'descriptor.dart';
+import 'sandbox.dart';
+import 'utils.dart';
+
+/// A descriptor that matches filesystem entity names by [Pattern] rather than
+/// by exact [String].
+///
+/// This descriptor may only be used for validation.
+class PatternDescriptor extends Descriptor {
+  /// The [Pattern] this matches filenames against. Note that the pattern must
+  /// match the entire basename of the file.
+  final Pattern pattern;
+
+  /// The function used to generate the [Descriptor] for filesystem entities
+  /// matching [pattern].
+  final Descriptor Function(String) _fn;
+
+  PatternDescriptor(this.pattern, Descriptor Function(String basename) child)
+      : _fn = child,
+        super('$pattern');
+
+  /// Validates that there is some filesystem entity in [parent] that matches
+  /// [pattern] and the child entry. This finds all entities in [parent]
+  /// matching [pattern], then passes each of their names to `child` provided
+  /// in the constructor and validates the result. If exactly one succeeds,
+  /// `this` is considered valid.
+  @override
+  Future<void> validate([String? parent]) async {
+    final inSandbox = parent == null;
+    parent ??= sandbox;
+    final matchingEntries = await Directory(parent)
+        .list()
+        .map(
+          (entry) =>
+              entry is File ? entry.resolveSymbolicLinksSync() : entry.path,
+        )
+        .where((entry) => matchesAll(pattern, p.basename(entry)))
+        .toList();
+    matchingEntries.sort();
+
+    final location = inSandbox ? 'sandbox' : '"${prettyPath(parent)}"';
+    if (matchingEntries.isEmpty) {
+      fail('No entries found in $location matching $_patternDescription.');
+    }
+
+    final results = await Future.wait(
+      matchingEntries
+          .map((entry) {
+            final basename = p.basename(entry);
+            return runZonedGuarded(
+                () => Result.capture(
+                      Future.sync(() async {
+                        await _fn(basename).validate(parent);
+                        return basename;
+                      }),
+                    ), (_, __) {
+              // Validate may produce multiple errors, but we ignore all but the
+              // first to avoid cluttering the user with many different errors
+              // from many different un-matched entries.
+            });
+          })
+          .whereType<Future<Result<String>>>()
+          .toList(),
+    );
+
+    final successes = results.where((result) => result.isValue).toList();
+    if (successes.isEmpty) {
+      await waitAndReportErrors(results.map((result) => result.asFuture));
+    } else if (successes.length > 1) {
+      fail('Multiple valid entries found in $location matching '
+          '$_patternDescription:\n'
+          '${bullet(successes.map((result) => result.asValue!.value))}');
+    }
+  }
+
+  @override
+  String describe() => 'entry matching $_patternDescription';
+
+  String get _patternDescription {
+    if (pattern is String) return '"$pattern"';
+    if (pattern is! RegExp) return '$pattern';
+
+    final regExp = pattern as RegExp;
+    final flags = StringBuffer();
+    if (!regExp.isCaseSensitive) flags.write('i');
+    if (regExp.isMultiLine) flags.write('m');
+    return '/${regExp.pattern}/$flags';
+  }
+
+  @override
+  Future<void> create([String? parent]) {
+    throw UnsupportedError("Pattern descriptors don't support create().");
+  }
+}
diff --git a/pkgs/test_descriptor/lib/src/sandbox.dart b/pkgs/test_descriptor/lib/src/sandbox.dart
new file mode 100644
index 0000000..128b6a9
--- /dev/null
+++ b/pkgs/test_descriptor/lib/src/sandbox.dart
@@ -0,0 +1,35 @@
+// 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:test/test.dart';
+
+/// The sandbox directory in which descriptors are created and validated by
+/// default.
+///
+/// This is a temporary directory beneath [Directory.systemTemp]. A new one is
+/// created the first time [sandbox] is accessed for each test case, and
+/// automatically deleted after the test finishes running.
+String get sandbox {
+  if (_sandbox != null) return _sandbox!;
+  // Resolve symlinks so we don't end up with inconsistent paths on Mac OS where
+  // /tmp is symlinked.
+  final sandbox = _sandbox = Directory.systemTemp
+      .createTempSync('dart_test_')
+      .resolveSymbolicLinksSync();
+
+  addTearDown(() async {
+    final sandbox = _sandbox!;
+    _sandbox = null;
+    await Directory(sandbox).delete(recursive: true);
+  });
+
+  return sandbox;
+}
+
+String? _sandbox;
+
+/// Whether [sandbox] has been created.
+bool get sandboxExists => _sandbox != null;
diff --git a/pkgs/test_descriptor/lib/src/utils.dart b/pkgs/test_descriptor/lib/src/utils.dart
new file mode 100644
index 0000000..f3542bd
--- /dev/null
+++ b/pkgs/test_descriptor/lib/src/utils.dart
@@ -0,0 +1,127 @@
+// 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 'package:path/path.dart' as p;
+import 'package:term_glyph/term_glyph.dart' as glyph;
+import 'package:test/test.dart';
+
+import 'descriptor.dart';
+import 'sandbox.dart';
+
+/// A UTF-8 codec that allows malformed byte sequences.
+const utf8 = Utf8Codec(allowMalformed: true);
+
+/// Prepends a vertical bar to [text].
+String addBar(String text) => prefixLines(
+      text,
+      '${glyph.verticalLine} ',
+      first: '${glyph.downEnd} ',
+      last: '${glyph.upEnd} ',
+      single: '| ',
+    );
+
+/// Indents [text], and adds a bullet at the beginning.
+String addBullet(String text) =>
+    prefixLines(text, '  ', first: '${glyph.bullet} ');
+
+/// Converts [strings] to a bulleted list.
+String bullet(Iterable<String> strings) => strings.map(addBullet).join('\n');
+
+/// Returns a human-readable description of a directory with the given [name]
+/// and [contents].
+String describeDirectory(String name, List<Descriptor> contents) {
+  if (contents.isEmpty) return name;
+
+  final buffer = StringBuffer();
+  buffer.writeln(name);
+  for (var entry in contents.take(contents.length - 1)) {
+    final entryString = prefixLines(
+      entry.describe(),
+      '${glyph.verticalLine}   ',
+      first: '${glyph.teeRight}${glyph.horizontalLine}'
+          '${glyph.horizontalLine} ',
+    );
+    buffer.writeln(entryString);
+  }
+
+  final lastEntryString = prefixLines(
+    contents.last.describe(),
+    '    ',
+    first: '${glyph.bottomLeftCorner}${glyph.horizontalLine}'
+        '${glyph.horizontalLine} ',
+  );
+  buffer.write(lastEntryString);
+  return buffer.toString();
+}
+
+/// Prepends each line in [text] with [prefix].
+///
+/// If [first] or [last] is passed, the first and last lines, respectively, are
+/// prefixed with those instead. If [single] is passed, it's used if there's
+/// only a single line; otherwise, [first], [last], or [prefix] is used, in that
+/// order of precedence.
+String prefixLines(
+  String text,
+  String prefix, {
+  String? first,
+  String? last,
+  String? single,
+}) {
+  single ??= first ?? last ?? prefix;
+  first ??= prefix;
+  last ??= prefix;
+
+  final lines = text.split('\n');
+  if (lines.length == 1) return '$single$text';
+
+  final buffer = StringBuffer('$first${lines.first}\n');
+  for (var line in lines.skip(1).take(lines.length - 2)) {
+    buffer.writeln('$prefix$line');
+  }
+  buffer.write('$last${lines.last}');
+  return buffer.toString();
+}
+
+/// Returns a representation of [path] that's easy for humans to read.
+///
+/// This may not be a valid path relative to [p.current].
+String prettyPath(String path) {
+  if (sandboxExists && p.isWithin(sandbox, path)) {
+    return p.relative(path, from: sandbox);
+  } else if (p.isWithin(p.current, path)) {
+    return p.relative(path);
+  } else {
+    return path;
+  }
+}
+
+/// Returns whether [pattern] matches all of [string].
+bool matchesAll(Pattern pattern, String string) =>
+    pattern.matchAsPrefix(string)?.end == string.length;
+
+/// Like [Future.wait] with `eagerError: true`, but reports errors after the
+/// first using [registerException] rather than silently ignoring them.
+Future<List<T>> waitAndReportErrors<T>(Iterable<Future<T>> futures) {
+  var errored = false;
+  return Future.wait(
+    futures.map(
+      (future) =>
+          // Avoid async/await so that we synchronously add error handlers for the
+          // futures to keep them from top-leveling.
+          future.catchError(
+        // ignore: body_might_complete_normally_catch_error
+        (Object error, StackTrace stackTrace) {
+          if (!errored) {
+            errored = true;
+            throw error; // ignore: only_throw_errors
+          } else {
+            registerException(error, stackTrace);
+          }
+        },
+      ),
+    ),
+  );
+}
diff --git a/pkgs/test_descriptor/lib/test_descriptor.dart b/pkgs/test_descriptor/lib/test_descriptor.dart
new file mode 100644
index 0000000..b302910
--- /dev/null
+++ b/pkgs/test_descriptor/lib/test_descriptor.dart
@@ -0,0 +1,80 @@
+// 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 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+
+import 'src/descriptor.dart';
+import 'src/directory_descriptor.dart';
+import 'src/file_descriptor.dart';
+import 'src/nothing_descriptor.dart';
+import 'src/pattern_descriptor.dart';
+import 'src/sandbox.dart';
+
+export 'src/descriptor.dart';
+export 'src/directory_descriptor.dart';
+export 'src/file_descriptor.dart';
+export 'src/nothing_descriptor.dart';
+export 'src/pattern_descriptor.dart';
+export 'src/sandbox.dart' show sandbox;
+
+/// Creates a new [FileDescriptor] with [name] and [contents].
+///
+/// The [contents] may be a `String`, a `List<int>`, or a [Matcher]. If it's a
+/// string, [Descriptor.create] creates a UTF-8 file and [Descriptor.validate]
+/// parses the physical file as UTF-8. If it's a [Matcher],
+/// [Descriptor.validate] matches it against the physical file's contents parsed
+/// as UTF-8, and [Descriptor.create] is unsupported.
+///
+/// If [contents] isn't passed, [Descriptor.create] creates an empty file and
+/// [Descriptor.validate] verifies that the file is empty.
+///
+/// To match a [Matcher] against a file's binary contents, use
+/// [FileDescriptor.binaryMatcher] instead.
+FileDescriptor file(String name, [Object? contents]) =>
+    FileDescriptor(name, contents);
+
+/// Creates a new [DirectoryDescriptor] descriptor with [name] and [contents].
+///
+/// [Descriptor.validate] requires that all descriptors in [contents] match
+/// children of the physical diretory, but it *doesn't* require that no other
+/// children exist. To ensure that a particular child doesn't exist, use
+/// [nothing].
+DirectoryDescriptor dir(String name, [Iterable<Descriptor>? contents]) =>
+    DirectoryDescriptor(name, contents ?? <Descriptor>[]);
+
+/// Creates a new [NothingDescriptor] descriptor that asserts that no entry
+/// named [name] exists.
+///
+/// [Descriptor.create] does nothing for this descriptor.
+NothingDescriptor nothing(String name) => NothingDescriptor(name);
+
+/// Creates a new [PatternDescriptor] descriptor that asserts than an entry with
+/// a name matching [pattern] exists, and matches the [Descriptor] returned
+/// by [child].
+///
+/// The [child] callback is passed the basename of each entry matching [name].
+/// It returns a descriptor that should match that entry. It's valid for
+/// multiple entries to match [name] as long as only one of them matches
+/// [child].
+///
+/// [Descriptor.create] is not supported for this descriptor.
+PatternDescriptor pattern(
+  Pattern name,
+  Descriptor Function(String basename) child,
+) =>
+    PatternDescriptor(name, child);
+
+/// A convenience method for creating a [PatternDescriptor] descriptor that
+/// constructs a [FileDescriptor] descriptor.
+PatternDescriptor filePattern(Pattern name, [Object? contents]) =>
+    pattern(name, (realName) => file(realName, contents));
+
+/// A convenience method for creating a [PatternDescriptor] descriptor that
+/// constructs a [DirectoryDescriptor] descriptor.
+PatternDescriptor dirPattern(Pattern name, [Iterable<Descriptor>? contents]) =>
+    pattern(name, (realName) => dir(realName, contents));
+
+/// Returns [path] within the [sandbox] directory.
+String path(String path) => p.join(sandbox, path);
diff --git a/pkgs/test_descriptor/pubspec.yaml b/pkgs/test_descriptor/pubspec.yaml
new file mode 100644
index 0000000..d9a97ac
--- /dev/null
+++ b/pkgs/test_descriptor/pubspec.yaml
@@ -0,0 +1,17 @@
+name: test_descriptor
+version: 2.0.2
+description: An API for defining and verifying files and directory structures.
+repository: https://github.com/dart-lang/test/tree/master/pkgs/test_descriptor
+
+environment:
+  sdk: ^3.1.0
+
+dependencies:
+  async: ^2.5.0
+  collection: ^1.15.0
+  path: ^1.8.0
+  term_glyph: ^1.2.0
+  test: ^1.16.6
+
+dev_dependencies:
+  dart_flutter_team_lints: ^3.0.0
diff --git a/pkgs/test_descriptor/test/directory_test.dart b/pkgs/test_descriptor/test/directory_test.dart
new file mode 100644
index 0000000..bac6153
--- /dev/null
+++ b/pkgs/test_descriptor/test/directory_test.dart
@@ -0,0 +1,362 @@
+// 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.
+
+@TestOn('vm')
+library;
+
+import 'dart:async';
+import 'dart:convert';
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:term_glyph/term_glyph.dart' as term_glyph;
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+
+import 'utils.dart';
+
+void main() {
+  group('create()', () {
+    test('creates a directory and its contents', () async {
+      await d.dir('dir', [
+        d.dir('subdir', [
+          d.file('subfile1.txt', 'subcontents1'),
+          d.file('subfile2.txt', 'subcontents2'),
+        ]),
+        d.file('file1.txt', 'contents1'),
+        d.file('file2.txt', 'contents2'),
+      ]).create();
+
+      expect(
+        File(p.join(d.sandbox, 'dir', 'file1.txt')).readAsString(),
+        completion(equals('contents1')),
+      );
+      expect(
+        File(p.join(d.sandbox, 'dir', 'file2.txt')).readAsString(),
+        completion(equals('contents2')),
+      );
+      expect(
+        File(p.join(d.sandbox, 'dir', 'subdir', 'subfile1.txt')).readAsString(),
+        completion(equals('subcontents1')),
+      );
+      expect(
+        File(p.join(d.sandbox, 'dir', 'subdir', 'subfile2.txt')).readAsString(),
+        completion(equals('subcontents2')),
+      );
+    });
+
+    test('works if the directory already exists', () async {
+      await d.dir('dir').create();
+      await d.dir('dir', [d.file('name.txt', 'contents')]).create();
+
+      expect(
+        File(p.join(d.sandbox, 'dir', 'name.txt')).readAsString(),
+        completion(equals('contents')),
+      );
+    });
+  });
+
+  group('validate()', () {
+    test('completes successfully if the filesystem matches the descriptor',
+        () async {
+      final dirPath = p.join(d.sandbox, 'dir');
+      final subdirPath = p.join(dirPath, 'subdir');
+      await Directory(subdirPath).create(recursive: true);
+      await File(p.join(dirPath, 'file1.txt')).writeAsString('contents1');
+      await File(p.join(dirPath, 'file2.txt')).writeAsString('contents2');
+      await File(p.join(subdirPath, 'subfile1.txt'))
+          .writeAsString('subcontents1');
+      await File(p.join(subdirPath, 'subfile2.txt'))
+          .writeAsString('subcontents2');
+
+      await d.dir('dir', [
+        d.dir('subdir', [
+          d.file('subfile1.txt', 'subcontents1'),
+          d.file('subfile2.txt', 'subcontents2'),
+        ]),
+        d.file('file1.txt', 'contents1'),
+        d.file('file2.txt', 'contents2'),
+      ]).validate();
+    });
+
+    test("fails if the directory doesn't exist", () async {
+      final dirPath = p.join(d.sandbox, 'dir');
+      await Directory(dirPath).create();
+      await File(p.join(dirPath, 'file1.txt')).writeAsString('contents1');
+      await File(p.join(dirPath, 'file2.txt')).writeAsString('contents2');
+
+      expect(
+        d.dir('dir', [
+          d.dir('subdir', [
+            d.file('subfile1.txt', 'subcontents1'),
+            d.file('subfile2.txt', 'subcontents2'),
+          ]),
+          d.file('file1.txt', 'contents1'),
+          d.file('file2.txt', 'contents2'),
+        ]).validate(),
+        throwsA(
+          toString(
+            equals('Directory not found: "${p.join('dir', 'subdir')}".'),
+          ),
+        ),
+      );
+    });
+
+    test('emits an error for each child that fails to validate', () async {
+      final dirPath = p.join(d.sandbox, 'dir');
+      final subdirPath = p.join(dirPath, 'subdir');
+      await Directory(subdirPath).create(recursive: true);
+      await File(p.join(dirPath, 'file1.txt')).writeAsString('contents1');
+      await File(p.join(subdirPath, 'subfile2.txt'))
+          .writeAsString('subwrongtents2');
+
+      var errors = 0;
+      final controller = StreamController<String>();
+      runZonedGuarded(
+        () {
+          d.dir('dir', [
+            d.dir('subdir', [
+              d.file('subfile1.txt', 'subcontents1'),
+              d.file('subfile2.txt', 'subcontents2'),
+            ]),
+            d.file('file1.txt', 'contents1'),
+            d.file('file2.txt', 'contents2'),
+          ]).validate();
+        },
+        expectAsync2(
+          (error, _) {
+            errors++;
+            controller.add(error.toString());
+            if (errors == 3) controller.close();
+          },
+          count: 3,
+        ),
+      );
+
+      expect(
+        controller.stream.toList(),
+        completion(
+          allOf([
+            contains(
+              'File not found: "${p.join('dir', 'subdir', 'subfile1.txt')}".',
+            ),
+            contains('File not found: "${p.join('dir', 'file2.txt')}".'),
+            contains(
+              startsWith('File "${p.join('dir', 'subdir', 'subfile2.txt')}" '
+                  'should contain:'),
+            ),
+          ]),
+        ),
+      );
+    });
+  });
+
+  group('load()', () {
+    test('loads a file', () {
+      final dir = d.dir(
+        'dir',
+        [d.file('name.txt', 'contents'), d.file('other.txt', 'wrong')],
+      );
+      expect(
+        utf8.decodeStream(dir.load('name.txt')),
+        completion(equals('contents')),
+      );
+    });
+
+    test('loads a deeply-nested file', () {
+      final dir = d.dir('dir', [
+        d.dir(
+          'subdir',
+          [d.file('name.txt', 'subcontents'), d.file('other.txt', 'wrong')],
+        ),
+        d.dir('otherdir', [d.file('other.txt', 'wrong')]),
+        d.file('name.txt', 'contents'),
+      ]);
+
+      expect(
+        utf8.decodeStream(dir.load('subdir/name.txt')),
+        completion(equals('subcontents')),
+      );
+    });
+
+    test('fails to load a nested directory', () {
+      final dir = d.dir('dir', [
+        d.dir('subdir', [
+          d.dir('subsubdir', [d.file('name.txt', 'subcontents')]),
+        ]),
+        d.file('name.txt', 'contents'),
+      ]);
+
+      expect(
+        dir.load('subdir/subsubdir').toList(),
+        throwsA(
+          toString(
+            equals('Couldn\'t find a file descriptor named '
+                '"subsubdir" within "dir/subdir".'),
+          ),
+        ),
+      );
+    });
+
+    test('fails to load an absolute path', () {
+      final dir = d.dir('dir', [d.file('name.txt', 'contents')]);
+      expect(() => dir.load('/name.txt'), throwsArgumentError);
+    });
+
+    test("fails to load '..'", () {
+      final dir = d.dir('dir', [d.file('name.txt', 'contents')]);
+      expect(() => dir.load('..'), throwsArgumentError);
+    });
+
+    test("fails to load a file that doesn't exist", () {
+      final dir = d.dir('dir', [
+        d.dir('subdir', [d.file('name.txt', 'contents')]),
+      ]);
+
+      expect(
+        dir.load('subdir/not-name.txt').toList(),
+        throwsA(
+          toString(
+            equals('Couldn\'t find a file descriptor named '
+                '"not-name.txt" within "dir/subdir".'),
+          ),
+        ),
+      );
+    });
+
+    test('fails to load a file that exists multiple times', () {
+      final dir = d.dir('dir', [
+        d.dir(
+          'subdir',
+          [d.file('name.txt', 'contents'), d.file('name.txt', 'contents')],
+        ),
+      ]);
+
+      expect(
+        dir.load('subdir/name.txt').toList(),
+        throwsA(
+          toString(
+            equals('Found multiple file descriptors named '
+                '"name.txt" within "dir/subdir".'),
+          ),
+        ),
+      );
+    });
+
+    test('loads a file next to a subdirectory with the same name', () {
+      final dir = d.dir('dir', [
+        d.file('name', 'contents'),
+        d.dir('name', [d.file('subfile', 'contents')]),
+      ]);
+
+      expect(
+        utf8.decodeStream(dir.load('name')),
+        completion(equals('contents')),
+      );
+    });
+  });
+
+  group('describe()', () {
+    late bool oldAscii;
+    setUpAll(() {
+      oldAscii = term_glyph.ascii;
+      term_glyph.ascii = true;
+    });
+
+    tearDownAll(() {
+      term_glyph.ascii = oldAscii;
+    });
+
+    test('lists the contents of the directory', () {
+      final dir = d.dir(
+        'dir',
+        [d.file('file1.txt', 'contents1'), d.file('file2.txt', 'contents2')],
+      );
+
+      expect(
+        dir.describe(),
+        equals('dir\n'
+            '+-- file1.txt\n'
+            "'-- file2.txt"),
+      );
+    });
+
+    test('lists the contents of nested directories', () {
+      final dir = d.dir('dir', [
+        d.file('file1.txt', 'contents1'),
+        d.dir('subdir', [
+          d.file('subfile1.txt', 'subcontents1'),
+          d.file('subfile2.txt', 'subcontents2'),
+          d.dir('subsubdir', [d.file('subsubfile.txt', 'subsubcontents')]),
+        ]),
+        d.file('file2.txt', 'contents2'),
+      ]);
+
+      expect(
+        dir.describe(),
+        equals('dir\n'
+            '+-- file1.txt\n'
+            '+-- subdir\n'
+            '|   +-- subfile1.txt\n'
+            '|   +-- subfile2.txt\n'
+            "|   '-- subsubdir\n"
+            "|       '-- subsubfile.txt\n"
+            "'-- file2.txt"),
+      );
+    });
+
+    test('with no contents returns the directory name', () {
+      expect(d.dir('dir').describe(), equals('dir'));
+    });
+  });
+
+  group('fromFilesystem()', () {
+    test('creates a descriptor based on the physical filesystem', () async {
+      final dir = d.dir('dir', [
+        d.dir('subdir', [
+          d.file('subfile1.txt', 'subcontents1'),
+          d.file('subfile2.txt', 'subcontents2'),
+        ]),
+        d.file('file1.txt', 'contents1'),
+        d.file('file2.txt', 'contents2'),
+      ]);
+
+      await dir.create();
+      final descriptor =
+          d.DirectoryDescriptor.fromFilesystem('dir', p.join(d.sandbox, 'dir'));
+      await descriptor.create(p.join(d.sandbox, 'dir2'));
+      await dir.validate(p.join(d.sandbox, 'dir2'));
+    });
+
+    test('ignores hidden files', () async {
+      await d.dir('dir', [
+        d.dir('subdir', [
+          d.file('subfile1.txt', 'subcontents1'),
+          d.file('.hidden', 'subcontents2'),
+        ]),
+        d.file('file1.txt', 'contents1'),
+        d.file('.DS_Store', 'contents2'),
+      ]).create();
+
+      final descriptor = d.DirectoryDescriptor.fromFilesystem(
+        'dir2',
+        p.join(d.sandbox, 'dir'),
+      );
+      await descriptor.create();
+
+      await d.dir('dir2', [
+        d.dir(
+          'subdir',
+          [d.file('subfile1.txt', 'subcontents1'), d.nothing('.hidden')],
+        ),
+        d.file('file1.txt', 'contents1'),
+        d.nothing('.DS_Store'),
+      ]).validate();
+    });
+  });
+
+  test('io refers to the directory within the sandbox', () {
+    expect(d.file('dir').io.path, equals(p.join(d.sandbox, 'dir')));
+  });
+}
diff --git a/pkgs/test_descriptor/test/file_test.dart b/pkgs/test_descriptor/test/file_test.dart
new file mode 100644
index 0000000..26e4b74
--- /dev/null
+++ b/pkgs/test_descriptor/test/file_test.dart
@@ -0,0 +1,196 @@
+// Copyright (c) 2013, 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.
+
+@TestOn('vm')
+library;
+
+import 'dart:convert';
+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 'utils.dart';
+
+void main() {
+  group('create()', () {
+    test('creates a text file', () async {
+      await d.file('name.txt', 'contents').create();
+
+      expect(
+        File(p.join(d.sandbox, 'name.txt')).readAsString(),
+        completion(equals('contents')),
+      );
+    });
+
+    test('creates a binary file', () async {
+      await d.file('name.txt', [0, 1, 2, 3]).create();
+
+      expect(
+        File(p.join(d.sandbox, 'name.txt')).readAsBytes(),
+        completion(equals([0, 1, 2, 3])),
+      );
+    });
+
+    test('fails to create a matcher file', () async {
+      expect(
+        d.file('name.txt', contains('foo')).create(),
+        throwsUnsupportedError,
+      );
+    });
+
+    test('overwrites an existing file', () async {
+      await d.file('name.txt', 'contents1').create();
+      await d.file('name.txt', 'contents2').create();
+
+      expect(
+        File(p.join(d.sandbox, 'name.txt')).readAsString(),
+        completion(equals('contents2')),
+      );
+    });
+  });
+
+  group('validate()', () {
+    test('succeeds if the filesystem matches a text descriptor', () async {
+      await File(p.join(d.sandbox, 'name.txt')).writeAsString('contents');
+      await d.file('name.txt', 'contents').validate();
+    });
+
+    test('succeeds if the filesystem matches a binary descriptor', () async {
+      await File(p.join(d.sandbox, 'name.txt')).writeAsBytes([0, 1, 2, 3]);
+      await d.file('name.txt', [0, 1, 2, 3]).validate();
+    });
+
+    test('succeeds if the filesystem matches a text matcher', () async {
+      await File(p.join(d.sandbox, 'name.txt')).writeAsString('contents');
+      await d.file('name.txt', contains('ent')).validate();
+    });
+
+    test('succeeds if the filesystem matches a binary matcher', () async {
+      await File(p.join(d.sandbox, 'name.txt')).writeAsBytes([0, 1, 2, 3]);
+      await d.FileDescriptor.binaryMatcher('name.txt', contains(2)).validate();
+    });
+
+    test('succeeds if invalid UTF-8 matches a text matcher', () async {
+      await File(p.join(d.sandbox, 'name.txt')).writeAsBytes([0xC3, 0x28]);
+      await d.file('name.txt', isNot(isEmpty)).validate();
+    });
+
+    test("fails if the text contents don't match", () async {
+      await File(p.join(d.sandbox, 'name.txt')).writeAsString('wrong');
+
+      expect(
+        d.file('name.txt', 'contents').validate(),
+        throwsA(toString(startsWith('File "name.txt" should contain:'))),
+      );
+    });
+
+    test("fails if the binary contents don't match", () async {
+      await File(p.join(d.sandbox, 'name.txt')).writeAsBytes([5, 4, 3, 2]);
+
+      expect(
+        d.file('name.txt', [0, 1, 2, 3]).validate(),
+        throwsA(
+          toString(
+            equals(
+              'File "name.txt" didn\'t contain the expected binary data.',
+            ),
+          ),
+        ),
+      );
+    });
+
+    test("fails if the text contents don't match the matcher", () async {
+      await File(p.join(d.sandbox, 'name.txt')).writeAsString('wrong');
+
+      expect(
+        d.file('name.txt', contains('ent')).validate(),
+        throwsA(
+          toString(startsWith('Invalid contents for file "name.txt":')),
+        ),
+      );
+    });
+
+    test("fails if the binary contents don't match the matcher", () async {
+      await File(p.join(d.sandbox, 'name.txt')).writeAsBytes([5, 4, 3, 2]);
+
+      expect(
+        d.FileDescriptor.binaryMatcher('name.txt', contains(1)).validate(),
+        throwsA(
+          toString(startsWith('Invalid contents for file "name.txt":')),
+        ),
+      );
+    });
+
+    test("fails if invalid UTF-8 doesn't match a text matcher", () async {
+      await File(p.join(d.sandbox, 'name.txt')).writeAsBytes([0xC3, 0x28]);
+      expect(
+        d.file('name.txt', isEmpty).validate(),
+        throwsA(
+          toString(
+            allOf([
+              startsWith('Invalid contents for file "name.txt":'),
+              contains('�'),
+            ]),
+          ),
+        ),
+      );
+    });
+
+    test("fails if there's no file", () {
+      expect(
+        d.file('name.txt', 'contents').validate(),
+        throwsA(toString(equals('File not found: "name.txt".'))),
+      );
+    });
+  });
+
+  group('reading', () {
+    test('read() returns the contents of a text file as a string', () {
+      expect(
+        d.file('name.txt', 'contents').read(),
+        completion(equals('contents')),
+      );
+    });
+
+    test('read() returns the contents of a binary file as a string', () {
+      expect(
+        d.file('name.txt', [0x68, 0x65, 0x6c, 0x6c, 0x6f]).read(),
+        completion(equals('hello')),
+      );
+    });
+
+    test('read() fails for a matcher file', () {
+      expect(d.file('name.txt', contains('hi')).read, throwsUnsupportedError);
+    });
+
+    test('readAsBytes() returns the contents of a text file as a byte stream',
+        () {
+      expect(
+        utf8.decodeStream(d.file('name.txt', 'contents').readAsBytes()),
+        completion(equals('contents')),
+      );
+    });
+
+    test('readAsBytes() returns the contents of a binary file as a byte stream',
+        () {
+      expect(
+        byteStreamToList(d.file('name.txt', [0, 1, 2, 3]).readAsBytes()),
+        completion(equals([0, 1, 2, 3])),
+      );
+    });
+
+    test('readAsBytes() fails for a matcher file', () {
+      expect(
+        d.file('name.txt', contains('hi')).readAsBytes,
+        throwsUnsupportedError,
+      );
+    });
+  });
+
+  test('io refers to the file within the sandbox', () {
+    expect(d.file('name.txt').io.path, equals(p.join(d.sandbox, 'name.txt')));
+  });
+}
diff --git a/pkgs/test_descriptor/test/nothing_test.dart b/pkgs/test_descriptor/test/nothing_test.dart
new file mode 100644
index 0000000..2663034
--- /dev/null
+++ b/pkgs/test_descriptor/test/nothing_test.dart
@@ -0,0 +1,70 @@
+// Copyright (c) 2013, 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.
+
+@TestOn('vm')
+library;
+
+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 'utils.dart';
+
+void main() {
+  test('create() does nothing', () async {
+    await d.nothing('foo').create();
+    expect(File(p.join(d.sandbox, 'foo')).exists(), completion(isFalse));
+    expect(Directory(p.join(d.sandbox, 'foo')).exists(), completion(isFalse));
+  });
+
+  group('validate()', () {
+    test("succeeds if nothing's there", () async {
+      await d.nothing('foo').validate();
+    });
+
+    test("fails if there's a file", () async {
+      await d.file('name.txt', 'contents').create();
+      expect(
+        d.nothing('name.txt').validate(),
+        throwsA(
+          toString(
+            equals(
+              'Expected nothing to exist at "name.txt", but found a file.',
+            ),
+          ),
+        ),
+      );
+    });
+
+    test("fails if there's a directory", () async {
+      await d.dir('dir').create();
+      expect(
+        d.nothing('dir').validate(),
+        throwsA(
+          toString(
+            equals(
+              'Expected nothing to exist at "dir", but found a directory.',
+            ),
+          ),
+        ),
+      );
+    });
+
+    test("fails if there's a broken link", () async {
+      await Link(p.join(d.sandbox, 'link')).create('nonexistent');
+      expect(
+        d.nothing('link').validate(),
+        throwsA(
+          toString(
+            equals(
+              'Expected nothing to exist at "link", but found a link.',
+            ),
+          ),
+        ),
+      );
+    });
+  });
+}
diff --git a/pkgs/test_descriptor/test/pattern_test.dart b/pkgs/test_descriptor/test/pattern_test.dart
new file mode 100644
index 0000000..988a66c
--- /dev/null
+++ b/pkgs/test_descriptor/test/pattern_test.dart
@@ -0,0 +1,84 @@
+// Copyright (c) 2013, 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.
+
+@TestOn('vm')
+library;
+
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+
+import 'utils.dart';
+
+void main() {
+  group('validate()', () {
+    test("succeeds if there's a file matching the pattern and the child",
+        () async {
+      await d.file('foo', 'blap').create();
+      await d.filePattern(RegExp('f..'), 'blap').validate();
+    });
+
+    test("succeeds if there's a directory matching the pattern and the child",
+        () async {
+      await d.dir('foo', [d.file('bar', 'baz')]).create();
+
+      await d.dirPattern(RegExp('f..'), [d.file('bar', 'baz')]).validate();
+    });
+
+    test(
+        'succeeds if multiple files match the pattern but only one matches '
+        'the child entry', () async {
+      await d.file('foo', 'blap').create();
+      await d.file('fee', 'blak').create();
+      await d.file('faa', 'blut').create();
+
+      await d.filePattern(RegExp('f..'), 'blap').validate();
+    });
+
+    test("fails if there's no file matching the pattern", () {
+      expect(
+        d.filePattern(RegExp('f..'), 'bar').validate(),
+        throwsA(
+          toString(equals('No entries found in sandbox matching /f../.')),
+        ),
+      );
+    });
+
+    test("fails if there's a file matching the pattern but not the entry",
+        () async {
+      await d.file('foo', 'bap').create();
+      expect(
+        d.filePattern(RegExp('f..'), 'bar').validate(),
+        throwsA(toString(startsWith('File "foo" should contain:'))),
+      );
+    });
+
+    test("fails if there's a dir matching the pattern but not the entry",
+        () async {
+      await d.dir('foo', [d.file('bar', 'bap')]).create();
+
+      expect(
+        d.dirPattern(RegExp('f..'), [d.file('bar', 'baz')]).validate(),
+        throwsA(toString(startsWith('File "foo/bar" should contain:'))),
+      );
+    });
+
+    test(
+        'fails if there are multiple files matching the pattern and the child '
+        'entry', () async {
+      await d.file('foo', 'bar').create();
+      await d.file('fee', 'bar').create();
+      await d.file('faa', 'bar').create();
+      expect(
+        d.filePattern(RegExp('f..'), 'bar').validate(),
+        throwsA(
+          toString(
+            startsWith(
+              'Multiple valid entries found in sandbox matching /f../:',
+            ),
+          ),
+        ),
+      );
+    });
+  });
+}
diff --git a/pkgs/test_descriptor/test/sandbox_test.dart b/pkgs/test_descriptor/test/sandbox_test.dart
new file mode 100644
index 0000000..b0eb32c
--- /dev/null
+++ b/pkgs/test_descriptor/test/sandbox_test.dart
@@ -0,0 +1,31 @@
+// Copyright (c) 2018, 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.
+
+@TestOn('vm')
+library;
+
+import 'dart:io';
+
+import 'package:path/path.dart' as p;
+import 'package:test/test.dart';
+import 'package:test_descriptor/test_descriptor.dart' as d;
+
+void main() {
+  test('accessing the getter creates the directory', () {
+    expect(Directory(d.sandbox).existsSync(), isTrue);
+  });
+
+  test('the directory is deleted after the test', () {
+    late String sandbox;
+    addTearDown(() {
+      expect(Directory(sandbox).existsSync(), isFalse);
+    });
+
+    sandbox = d.sandbox;
+  });
+
+  test('path() returns a path in the sandbox', () {
+    expect(d.path('foo'), equals(p.join(d.sandbox, 'foo')));
+  });
+}
diff --git a/pkgs/test_descriptor/test/utils.dart b/pkgs/test_descriptor/test/utils.dart
new file mode 100644
index 0000000..2d69663
--- /dev/null
+++ b/pkgs/test_descriptor/test/utils.dart
@@ -0,0 +1,22 @@
+// Copyright (c) 2013, 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';
+
+/// Converts a [Stream]`<List<int>>` to a flat byte future.
+Future<List<int>> byteStreamToList(Stream<List<int>> stream) =>
+    stream.fold(<int>[], (buffer, chunk) {
+      buffer.addAll(chunk);
+      return buffer;
+    });
+
+/// Returns a matcher that verifies that the result of calling `toString()`
+/// matches [matcher].
+Matcher toString(Object? matcher) => predicate(
+      (object) {
+        expect(object.toString(), matcher);
+        return true;
+      },
+      'toString() matches $matcher',
+    );