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