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");
+}