Add the contents of the package. (#1)

This is ported from the scheduled_test package, altered to remove
scheduling and to use a more modern style.
diff --git a/README.md b/README.md
index 95b216e..db47a2d 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,53 @@
 The `test_descriptor` package provides a convenient, easy-to-read API for
 defining and verifying directory structures in tests.
+
+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://www.dartdocs.org/documentation/test_descriptor/latest/test_descriptor/dir.html
+[file]: https://www.dartdocs.org/documentation/test_descriptor/latest/test_descriptor/file.html
+[create]: https://www.dartdocs.org/documentation/test_descriptor/latest/test_descriptor/Descriptor/create.html
+[validate]: https://www.dartdocs.org/documentation/test_descriptor/latest/test_descriptor/Descriptor/validate.html
+
+```dart
+import 'dart:io';
+
+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 new 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://www.dartdocs.org/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`][glyph.ascii] attribute.
+
+[term_glyph]: https://pub.dartlang.org/packages/term_glyph
+[gylph.ascii]: https://www.dartdocs.org/documentation/term_glyph/latest/term_glyph/ascii.html
diff --git a/lib/src/descriptor.dart b/lib/src/descriptor.dart
new file mode 100644
index 0000000..363bc90
--- /dev/null
+++ b/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 'dart:async';
+
+/// 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 create([String parent]);
+
+  /// Validates that the physical file system under [parent] (which defaults to
+  /// [sandbox]) contains an entry that matches this descriptor.
+  Future validate([String parent]);
+
+  /// Returns a human-friendly tree-style description of this descriptor.
+  String describe();
+}
diff --git a/lib/src/directory_descriptor.dart b/lib/src/directory_descriptor.dart
new file mode 100644
index 0000000..1b2ca97
--- /dev/null
+++ b/lib/src/directory_descriptor.dart
@@ -0,0 +1,140 @@
+// 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:term_glyph/term_glyph.dart' as glyph;
+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;
+
+  DirectoryDescriptor(String name, Iterable<Descriptor> contents)
+      : contents = contents.toList(),
+        super(name);
+
+  /// Creates a directory descriptor named [name] that describes the physical
+  /// directory at [path].
+  factory DirectoryDescriptor.fromFilesystem(String name, String path) {
+    return new DirectoryDescriptor(name,
+        new Directory(path).listSync().map((entity) {
+      // Ignore hidden files.
+      if (p.basename(entity.path).startsWith(".")) return null;
+
+      if (entity is Directory) {
+        return new DirectoryDescriptor.fromFilesystem(
+            p.basename(entity.path), entity.path);
+      } else if (entity is File) {
+        return new FileDescriptor(
+            p.basename(entity.path), entity.readAsBytesSync());
+      }
+      // Ignore broken symlinks.
+    }).where((path) => path != null));
+  }
+
+  Future create([String parent]) async {
+    var fullPath = p.join(parent ?? sandbox, name);
+    await new Directory(fullPath).create(recursive: true);
+    await Future.wait(contents.map((entry) => entry.create(fullPath)));
+  }
+
+  Future validate([String parent]) async {
+    var fullPath = p.join(parent ?? sandbox, name);
+    if (!(await new 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 [url], which may be
+  /// a [Uri] or a [String].
+  ///
+  /// The [parent] parameter should only be passed by subclasses of
+  /// [DirectoryDescriptor] that are recursively calling [load]. It's the
+  /// URL-format path of the directories that have been loaded so far.
+  Stream<List<int>> load(url, [String parents]) {
+    String path;
+    if (url is String) {
+      path = url;
+    } else if (url is Uri) {
+      path = url.toString();
+    } else {
+      throw new ArgumentError.value(url, "url", "must be a Uri or a String.");
+    }
+
+    if (!p.url.isWithin('.', path)) {
+      throw new ArgumentError.value(
+          url, "url", "must be relative and beneath the base URL.");
+    }
+
+    return StreamCompleter.fromFuture(new Future.sync(() {
+      var split = p.url.split(p.url.normalize(path));
+      var file = split.length == 1;
+      var matchingEntries = contents.where((entry) {
+        return entry.name == split.first &&
+            file
+                ? entry is FileDescriptor
+                : entry is DirectoryDescriptor;
+      }).toList();
+
+      var type = file ? 'file' : 'directory';
+      var 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 {
+        var 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);
+        }
+      }
+    }));
+  }
+
+  String describe() {
+    if (contents.isEmpty) return name;
+
+    var buffer = new StringBuffer();
+    buffer.writeln(name);
+    for (var entry in contents.take(contents.length - 1)) {
+      var entryString = prefixLines(
+          entry.describe(), '${glyph.verticalLine}   ',
+          first: '${glyph.teeRight}${glyph.horizontalLine}'
+              '${glyph.horizontalLine} ');
+      buffer.writeln(entryString);
+    }
+
+    var lastEntryString = prefixLines(contents.last.describe(), '    ',
+        first: '${glyph.bottomLeftCorner}${glyph.horizontalLine}'
+              '${glyph.horizontalLine} ');
+    buffer.write(lastEntryString);
+    return buffer.toString();
+  }
+}
diff --git a/lib/src/file_descriptor.dart b/lib/src/file_descriptor.dart
new file mode 100644
index 0000000..d648894
--- /dev/null
+++ b/lib/src/file_descriptor.dart
@@ -0,0 +1,194 @@
+// 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:convert';
+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 [new
+  /// FileDescriptor.binaryMatcher] instead.
+  factory FileDescriptor(String name, contents) {
+    if (contents is String) return new _StringFileDescriptor(name, contents);
+    if (contents is List) {
+      return new _BinaryFileDescriptor(name, DelegatingList.typed(contents));
+    }
+    if (contents == null) return new _BinaryFileDescriptor(name, []);
+    return new _MatcherFileDescriptor(name, contents);
+  }
+
+  /// 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) =>
+      new _MatcherFileDescriptor(name, matcher, isBinary: true);
+
+  /// A protected constructor that's only intended for subclasses.
+  FileDescriptor.protected(String name) : super(name);
+
+  Future 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).
+    var file = new File(p.join(parent ?? sandbox, name)).openWrite();
+    try {
+      await readAsBytes().listen(file.add).asFuture();
+    } finally {
+      await file.close();
+    }
+  }
+
+  Future validate([String parent]) async {
+    var fullPath = p.join(parent ?? sandbox, name);
+    var pretty = prettyPath(fullPath);
+    if (!(await new File(fullPath).exists())) {
+      fail('File not found: "$pretty".');
+    }
+
+    await _validate(pretty, await new 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.
+  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();
+
+  String describe() => name;
+}
+
+class _BinaryFileDescriptor extends FileDescriptor {
+  /// The contents of this descriptor's file.
+  final List<int> _contents;
+
+  _BinaryFileDescriptor(String name, this._contents) : super.protected(name);
+
+  Stream<List<int>> readAsBytes() => new Stream.fromIterable([_contents]);
+
+  Future _validate(String prettPath, List<int> actualContents) async {
+    if (const IterableEquality().equals(_contents, actualContents)) return null;
+    // 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(String name, this._contents) : super.protected(name);
+
+  Future<String> read() async => _contents;
+
+  Stream<List<int>> readAsBytes() =>
+      new Stream.fromIterable([UTF8.encode(_contents)]);
+
+  Future _validate(String prettyPath, List<int> actualContents) {
+    var actualContentsText = UTF8.decode(actualContents);
+    if (_contents == actualContentsText) return null;
+    throw fail(_textMismatchMessage(prettyPath, _contents, actualContentsText));
+  }
+
+  String _textMismatchMessage(String prettyPath, String expected,
+      String actual) {
+    final expectedLines = expected.split('\n');
+    final actualLines = actual.split('\n');
+
+    var results = [];
+
+    // Compare them line by line to see which ones match.
+    var 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 {
+        var expectedLine = expectedLines[i];
+        var 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(String name, this._matcher, {bool isBinary: false})
+      : _isBinary = isBinary,
+        super.protected(name);
+
+  Stream<List<int>> readAsBytes() =>
+      throw new UnsupportedError("Matcher files can't be created or read.");
+
+  void _validate(String prettyPath, List<int> actualContents) {
+    try {
+      expect(
+          _isBinary ? actualContents : UTF8.decode(actualContents),
+          _matcher);
+    } on TestFailure catch (error) {
+      throw new TestFailure(
+          'Invalid contents for file "$prettyPath":\n' + error.message);
+    }
+  }
+}
diff --git a/lib/src/nothing_descriptor.dart b/lib/src/nothing_descriptor.dart
new file mode 100644
index 0000000..f067ba3
--- /dev/null
+++ b/lib/src/nothing_descriptor.dart
@@ -0,0 +1,37 @@
+// 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: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(String name)
+      : super(name);
+
+  Future create([String parent]) async {}
+
+  Future validate([String parent]) async {
+    var fullPath = p.join(parent ?? sandbox, name);
+    var pretty = prettyPath(fullPath);
+    if (new File(fullPath).existsSync()) {
+      fail('Expected nothing to exist at "$pretty", but found a file.');
+    } else if (new Directory(fullPath).existsSync()) {
+      fail('Expected nothing to exist at "$pretty", but found a directory.');
+    } else if (new Link(fullPath).existsSync()) {
+      fail('Expected nothing to exist at "$pretty", but found a link.');
+    }
+  }
+
+  String describe() => 'nothing at "$name"';
+}
diff --git a/lib/src/pattern_descriptor.dart b/lib/src/pattern_descriptor.dart
new file mode 100644
index 0000000..2511238
--- /dev/null
+++ b/lib/src/pattern_descriptor.dart
@@ -0,0 +1,99 @@
+// 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 function that takes a name for a [Descriptor] and returns a [Descriptor].
+/// This is used for [PatternDescriptor]s, where the name isn't known
+/// ahead-of-time.
+typedef Descriptor _EntryCreator(String name);
+
+/// 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 _EntryCreator _fn;
+
+  PatternDescriptor(Pattern pattern, Descriptor child(String basename))
+      : pattern = pattern,
+        _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 the [EntityCreator]
+  /// and validates the result. If exactly one succeeds, [this] is considered
+  /// valid.
+  Future validate([String parent]) async {
+    var inSandbox = parent == null;
+    parent ??= sandbox;
+    var matchingEntries = await new Directory(parent).list()
+        .map((entry) =>
+            entry is File ? entry.resolveSymbolicLinksSync() : entry.path)
+        .where((entry) => matchesAll(pattern, p.basename(entry)))
+        .toList();
+    matchingEntries.sort();
+
+    var location = inSandbox ? "sandbox" : '"${prettyPath(parent)}"';
+    if (matchingEntries.isEmpty) {
+      fail('No entries found in $location matching ${_patternDescription}.');
+    }
+
+    var results = await Future.wait(matchingEntries.map((entry) {
+      var basename = p.basename(entry);
+      return runZoned(() {
+        return Result.capture/*<String>*/(new Future.sync(() async {
+          await _fn(basename).validate(parent);
+          return basename;
+        }));
+      }, onError: (_) {
+        // 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.
+      });
+    }).toList());
+
+    var successes = results.where((result) => result.isValue).toList();
+    if (successes.length == 0) {
+      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))}');
+    }
+  }
+
+  String describe() => "entry matching $_patternDescription";
+
+  String get _patternDescription {
+    if (pattern is String) return '"$pattern"';
+    if (pattern is! RegExp) return '$pattern';
+
+    var regExp = pattern as RegExp;
+    var flags = new StringBuffer();
+    if (!regExp.isCaseSensitive) flags.write('i');
+    if (regExp.isMultiLine) flags.write('m');
+    return '/${regExp.pattern}/$flags';
+  }
+
+  Future create([String parent]) {
+    throw new UnsupportedError("Pattern descriptors don't support create().");
+  }
+}
diff --git a/lib/src/sandbox.dart b/lib/src/sandbox.dart
new file mode 100644
index 0000000..6311629
--- /dev/null
+++ b/lib/src/sandbox.dart
@@ -0,0 +1,33 @@
+// 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.
+  _sandbox = Directory.systemTemp.createTempSync('dart_test_')
+      .resolveSymbolicLinksSync();
+
+  addTearDown(() async {
+    var sandbox = _sandbox;
+    _sandbox = null;
+    await new Directory(sandbox).delete(recursive: true);
+  });
+
+  return _sandbox;
+}
+String _sandbox;
+
+/// Whether [sandbox] has been created.
+bool get sandboxExists => _sandbox != null;
diff --git a/lib/src/utils.dart b/lib/src/utils.dart
new file mode 100644
index 0000000..385f4de
--- /dev/null
+++ b/lib/src/utils.dart
@@ -0,0 +1,82 @@
+// 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 'package:path/path.dart' as p;
+import 'package:term_glyph/term_glyph.dart' as glyph;
+import 'package:test/test.dart';
+
+import 'sandbox.dart';
+
+/// 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");
+
+/// 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}) {
+  first ??= prefix;
+  last ??= prefix;
+  single ??= first ?? last ?? prefix;
+
+  var lines = text.split('\n');
+  if (lines.length == 1) return "$single$text";
+
+  var buffer = new 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 waitAndReportErrors(Iterable<Future> 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.
+    return future.catchError((error, stackTrace) {
+      if (!errored) {
+        errored = true;
+        throw error;
+      } else {
+        registerException(error, stackTrace);
+      }
+    });
+  }));
+}
diff --git a/lib/test_descriptor.dart b/lib/test_descriptor.dart
new file mode 100644
index 0000000..54649ab
--- /dev/null
+++ b/lib/test_descriptor.dart
@@ -0,0 +1,72 @@
+// 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: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';
+
+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 [new
+/// FileDescriptor.binaryMatcher] instead.
+FileDescriptor file(String name, [contents]) =>
+    new 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]) =>
+    new DirectoryDescriptor(name, contents == null ? <Descriptor>[] : contents);
+
+/// Creates a new [NothingDescriptor] descriptor that asserts that no entry
+/// named [name] exists.
+///
+/// [Descriptor.create] does nothing for this descriptor.
+NothingDescriptor nothing(String name) => new 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 child(String basename)) =>
+    new PatternDescriptor(name, child);
+
+/// A convenience method for creating a [PatternDescriptor] descriptor that
+/// constructs a [FileDescriptor] descriptor.
+PatternDescriptor filePattern(Pattern name, [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));
diff --git a/pubspec.yaml b/pubspec.yaml
index 97328ce..b762b9c 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -6,3 +6,12 @@
 
 environment:
   sdk: '>=1.8.0 <2.0.0'
+
+dependencies:
+  async: '^1.10.0'
+  collection: '^1.5.0'
+  matcher: '^0.12.0'
+  path: '^1.0.0'
+  stack_trace: '^1.0.0'
+  test: '^0.12.19'
+  term_glyph: '^1.0.0'
diff --git a/test/directory_test.dart b/test/directory_test.dart
new file mode 100644
index 0000000..79cfd6a
--- /dev/null
+++ b/test/directory_test.dart
@@ -0,0 +1,297 @@
+// 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')
+
+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(new File(p.join(d.sandbox, 'dir', 'file1.txt')).readAsString(),
+          completion(equals('contents1')));
+      expect(new File(p.join(d.sandbox, 'dir', 'file2.txt')).readAsString(),
+          completion(equals('contents2')));
+      expect(new File(p.join(d.sandbox, 'dir', 'subdir', 'subfile1.txt'))
+              .readAsString(),
+          completion(equals('subcontents1')));
+      expect(new 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(new File(p.join(d.sandbox, 'dir', 'name.txt')).readAsString(),
+          completion(equals('contents')));
+    });
+  });
+
+  group("validate()", () {
+    test("completes successfully if the filesystem matches the descriptor",
+        () async {
+      var dirPath = p.join(d.sandbox, 'dir');
+      var subdirPath = p.join(dirPath, 'subdir');
+      await new Directory(subdirPath).create(recursive: true);
+      await new File(p.join(dirPath, 'file1.txt')).writeAsString('contents1');
+      await new File(p.join(dirPath, 'file2.txt')).writeAsString('contents2');
+      await new File(p.join(subdirPath, 'subfile1.txt'))
+          .writeAsString('subcontents1');
+      await new 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 {
+      var dirPath = p.join(d.sandbox, 'dir');
+      await new Directory(dirPath).create();
+      await new File(p.join(dirPath, 'file1.txt')).writeAsString('contents1');
+      await new 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 {
+      var dirPath = p.join(d.sandbox, 'dir');
+      var subdirPath = p.join(dirPath, 'subdir');
+      await new Directory(subdirPath).create(recursive: true);
+      await new File(p.join(dirPath, 'file1.txt')).writeAsString('contents1');
+      await new File(p.join(subdirPath, 'subfile2.txt'))
+          .writeAsString('subwrongtents2');
+
+      var errors = 0;
+      var controller = new StreamController<String>();
+      runZoned(() {
+        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();
+      }, onError: expectAsync1((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", () {
+      var dir = d.dir('dir', [d.file('name.txt', 'contents')]);
+      expect(UTF8.decodeStream(dir.load('name.txt')),
+          completion(equals('contents')));
+    });
+
+    test("loads a deeply-nested file", () {
+      var dir = d.dir('dir', [
+        d.dir('subdir', [
+          d.file('name.txt', 'subcontents')
+        ]),
+        d.file('name.txt', 'contents')
+      ]);
+
+      expect(UTF8.decodeStream(dir.load('subdir/name.txt')),
+          completion(equals('subcontents')));
+    });
+
+    test("fails to load a nested directory", () {
+      var 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", () {
+      var dir = d.dir('dir', [d.file('name.txt', 'contents')]);
+      expect(() => dir.load('/name.txt'), throwsArgumentError);
+    });
+
+    test("fails to load '..'", () {
+      var dir = d.dir('dir', [d.file('name.txt', 'contents')]);
+      expect(() => dir.load('..'), throwsArgumentError);
+    });
+
+    test("fails to load a file that doesn't exist", () {
+      var 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", () {
+      var 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", () {
+      var 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()", () {
+    bool oldAscii;
+    setUpAll(() {
+      oldAscii = term_glyph.ascii;
+      term_glyph.ascii = true;
+    });
+
+    tearDownAll(() {
+      term_glyph.ascii = oldAscii;
+    });
+
+    test("lists the contents of the directory", () {
+      var 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", () {
+      var 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 {
+      var 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();
+      var descriptor = new 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();
+
+      var descriptor = new 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();
+    });
+  });
+}
diff --git a/test/file_test.dart b/test/file_test.dart
new file mode 100644
index 0000000..08da999
--- /dev/null
+++ b/test/file_test.dart
@@ -0,0 +1,141 @@
+// 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')
+
+import 'dart:io';
+import 'dart:convert';
+
+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(new 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(new 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(new File(p.join(d.sandbox, 'name.txt')).readAsString(),
+          completion(equals('contents2')));
+    });
+  });
+
+  group("validate()", () {
+    test('succeeds if the filesystem matches a text descriptor', () async {
+      await new 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 new 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 new 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 new File(p.join(d.sandbox, 'name.txt')).writeAsBytes([0, 1, 2, 3]);
+      await new d.FileDescriptor.binaryMatcher('name.txt', contains(2))
+          .validate();
+    });
+
+    test("fails if the text contents don't match", () async {
+      await new 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 new 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 new 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 new File(p.join(d.sandbox, 'name.txt')).writeAsBytes([5, 4, 3, 2]);
+
+      expect(
+          new d.FileDescriptor.binaryMatcher('name.txt', contains(1))
+              .validate(),
+          throwsA(toString(startsWith(
+              'Invalid contents for file "name.txt":'))));
+    });
+
+    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);
+    });
+  });
+}
diff --git a/test/nothing_test.dart b/test/nothing_test.dart
new file mode 100644
index 0000000..916c396
--- /dev/null
+++ b/test/nothing_test.dart
@@ -0,0 +1,47 @@
+// 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')
+
+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(new File(p.join(d.sandbox, 'foo')).exists(), completion(isFalse));
+    expect(new 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 new 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/test/pattern_test.dart b/test/pattern_test.dart
new file mode 100644
index 0000000..e5f1969
--- /dev/null
+++ b/test/pattern_test.dart
@@ -0,0 +1,76 @@
+// 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')
+
+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(new RegExp(r'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(new RegExp(r'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(new RegExp(r'f..'), 'blap').validate();
+    });
+
+    test("fails if there's no file matching the pattern", () {
+      expect(d.filePattern(new RegExp(r'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(new RegExp(r'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(new RegExp(r'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(new RegExp(r'f..'), 'bar').validate(),
+          throwsA(toString(startsWith(
+              "Multiple valid entries found in sandbox matching /f../:"))));
+    });
+  });
+}
diff --git a/test/utils.dart b/test/utils.dart
new file mode 100644
index 0000000..d58f7ba
--- /dev/null
+++ b/test/utils.dart
@@ -0,0 +1,25 @@
+// 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 'dart:async';
+
+import 'package:test_descriptor/test_descriptor.dart' as d;
+import 'package:test/test.dart';
+
+/// Converts a [Stream<List<int>>] to a flat byte future.
+Future<List<int>> byteStreamToList(Stream<List<int>> stream) {
+  return 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(matcher) {
+  return predicate((object) {
+    expect(object.toString(), matcher);
+    return true;
+  }, "toString() matches $matcher");
+}