diff --git a/.analysis_options b/.analysis_options index 7cbae87343f94..b941521c7988f 100644 --- a/.analysis_options +++ b/.analysis_options @@ -1,30 +1,68 @@ analyzer: strong-mode: true language: - enableSuperMixins: true + enableStrictCallChecks: true + enableSuperMixins: true + errors: + # Allow having TODOs in the code + todo: ignore + linter: rules: - # Errors + # these rules are documented on and in the same order as + # the Dart Lint rules page to make maintenance easier + # http://dart-lang.github.io/linter/lints/ + + # === error rules === - avoid_empty_else + # TODO - comment_references + - cancel_subscriptions + # TODO - close_sinks - control_flow_in_finally - empty_statements + - hash_and_equals + - invariant_booleans + - iterable_contains_unrelated_type + - list_remove_unrelated_type + - literal_only_boolean_expressions - test_types_in_equals - throw_in_finally + - unrelated_type_equality_checks - valid_regexps - # Style - # TODO: - annotate_overrides + # === style rules === + - always_declare_return_types + # TODO - always_specify_types + # TODO - annotate_overrides + # TODO - avoid_as - avoid_init_to_null - avoid_return_types_on_setters - await_only_futures - - camel_case_types - # TODO: - comment_references + # TODO - camel_case_types + # TODO - constant_identifier_names + - control_flow_in_finally - empty_catches - empty_constructor_bodies - - hash_and_equals + - implementation_imports + - library_names - library_prefixes - non_constant_identifier_names + - one_member_abstracts + - only_throw_errors + - overridden_fields + - package_api_docs + - package_prefixed_library_names - prefer_is_not_empty + # TODO - public_member_api_docs - slash_for_doc_comments + - sort_constructors_first + # TODO - sort_unnamed_constructors_first + - super_goes_last + # TODO - type_annotate_public_apis - type_init_formals - - unrelated_type_equality_checks + # TODO - unawaited_futures + - unnecessary_brace_in_string_interp + - unnecessary_getters_setters + + # === pub rules === + - package_names diff --git a/CHANGELOG.md b/CHANGELOG.md index 657f24c082546..6114cbd5045ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,12 @@ * Improved `toString` implementations in file system entity classes * Added `ForwardingFileSystem` and associated forwarding classes to the - main `file` library. + main `file` library * Removed `FileSystem.pathSeparator`, and added a more comprehensive - `FileSystem.path` property. + `FileSystem.path` property +* Added `FileSystemEntity.basename` and `FileSystemEntity.dirname` +* Added the `record_replay` library +* Added the `testing` library #### 1.0.1 diff --git a/lib/record_replay.dart b/lib/record_replay.dart new file mode 100644 index 0000000000000..383c20182adec --- /dev/null +++ b/lib/record_replay.dart @@ -0,0 +1,9 @@ +// 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. + +export 'src/backends/record_replay/events.dart' + show InvocationEvent, PropertyGetEvent, PropertySetEvent, MethodEvent; +export 'src/backends/record_replay/recording.dart'; +export 'src/backends/record_replay/recording_file_system.dart' + show RecordingFileSystem; diff --git a/lib/src/backends/record_replay/common.dart b/lib/src/backends/record_replay/common.dart new file mode 100644 index 0000000000000..53d3eb904fa6f --- /dev/null +++ b/lib/src/backends/record_replay/common.dart @@ -0,0 +1,23 @@ +// 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. + +/// Encoded value of the file system in a recording. +const String kFileSystemEncodedValue = '__fs__'; + +/// The name of the recording manifest file. +const String kManifestName = 'MANIFEST.txt'; + +/// Gets an id guaranteed to be unique on this isolate for objects within this +/// library. +int newUid() => _nextUid++; +int _nextUid = 1; + +/// Gets the name of the specified [symbol]. +// TODO(tvolkert): Symbol.name (https://github.com/dart-lang/sdk/issues/28372) +String getSymbolName(Symbol symbol) { + // Format of `str` is `Symbol("")` + String str = symbol.toString(); + int offset = str.indexOf('"') + 1; + return str.substring(offset, str.indexOf('"', offset)); +} diff --git a/lib/src/backends/record_replay/encoding.dart b/lib/src/backends/record_replay/encoding.dart new file mode 100644 index 0000000000000..f71dec227b26d --- /dev/null +++ b/lib/src/backends/record_replay/encoding.dart @@ -0,0 +1,169 @@ +// 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:file/file.dart'; +import 'package:path/path.dart' as p; + +import 'common.dart'; +import 'events.dart'; +import 'recording_directory.dart'; +import 'recording_file.dart'; +import 'recording_file_system_entity.dart'; +import 'recording_io_sink.dart'; +import 'recording_link.dart'; +import 'recording_random_access_file.dart'; + +/// Encodes an object into a JSON-ready representation. +typedef dynamic _Encoder(dynamic object); + +/// This class is a work-around for the "is" operator not accepting a variable +/// value as its right operand (https://github.com/dart-lang/sdk/issues/27680). +class _TypeMatcher { + /// Creates a type matcher for the given type parameter. + const _TypeMatcher(); + + /// Returns `true` if the given object is of type `T`. + bool check(dynamic object) => object is T; +} + +/// Known encoders. Types not covered here will be encoded using +/// [_encodeDefault]. +/// +/// When encoding an object, we will walk this map in iteration order looking +/// for a matching encoder. Thus, when there are two encoders that match an +// object, the first one will win. +const Map<_TypeMatcher, _Encoder> _kEncoders = const <_TypeMatcher, _Encoder>{ + const _TypeMatcher(): _encodeRaw, + const _TypeMatcher(): _encodeRaw, + const _TypeMatcher(): _encodeRaw, + const _TypeMatcher(): _encodeRaw, + const _TypeMatcher(): _encodeRaw, + const _TypeMatcher(): _encodeMap, + const _TypeMatcher(): _encodeIterable, + const _TypeMatcher(): getSymbolName, + const _TypeMatcher(): _encodeDateTime, + const _TypeMatcher(): _encodeUri, + const _TypeMatcher(): _encodePathContext, + const _TypeMatcher(): _encodeEvent, + const _TypeMatcher(): _encodeFileSystem, + const _TypeMatcher(): _encodeFileSystemEntity, + const _TypeMatcher(): _encodeFileSystemEntity, + const _TypeMatcher(): _encodeFileSystemEntity, + const _TypeMatcher(): _encodeIOSink, + const _TypeMatcher(): _encodeRandomAccessFile, + const _TypeMatcher(): _encodeEncoding, + const _TypeMatcher(): _encodeFileMode, + const _TypeMatcher(): _encodeFileStat, + const _TypeMatcher(): _encodeFileSystemEntityType, + const _TypeMatcher(): _encodeFileSystemEvent, +}; + +/// Encodes [object] into a JSON-ready representation. +/// +/// This function is intended to be used as the `toEncodable` argument to the +/// `JsonEncoder` constructors. +/// +/// See also: +/// - [JsonEncoder.withIndent] +dynamic encode(dynamic object) { + _Encoder encoder = _encodeDefault; + for (_TypeMatcher matcher in _kEncoders.keys) { + if (matcher.check(object)) { + encoder = _kEncoders[matcher]; + break; + } + } + return encoder(object); +} + +/// Default encoder (used for types not covered in [_kEncoders]). +String _encodeDefault(dynamic object) => object.runtimeType.toString(); + +/// Pass-through encoder. +dynamic _encodeRaw(dynamic object) => object; + +List _encodeIterable(Iterable iterable) => iterable.toList(); + +/// Encodes the map keys, and passes the values through. +/// +/// As [JsonEncoder] encodes an object graph, it will repeatedly call +/// `toEncodable` to encode unknown types, so any values in a map that need +/// special encoding will already be handled by `JsonEncoder`. However, the +/// encoder won't try to encode map *keys* by default, which is why we encode +/// them here. +Map _encodeMap(Map map) { + Map encoded = {}; + for (dynamic key in map.keys) { + String encodedKey = encode(key); + encoded[encodedKey] = map[key]; + } + return encoded; +} + +int _encodeDateTime(DateTime dateTime) => dateTime.millisecondsSinceEpoch; + +String _encodeUri(Uri uri) => uri.toString(); + +Map _encodePathContext(p.Context context) => { + 'style': context.style.name, + 'cwd': context.current, + }; + +Map _encodeEvent(EventImpl event) => event.encode(); + +String _encodeFileSystem(FileSystem fs) => kFileSystemEncodedValue; + +/// Encodes a file system entity by using its `uid` as a reference identifier. +/// During replay, this allows us to tie the return value of of one event to +/// the object of another. +String _encodeFileSystemEntity(RecordingFileSystemEntity entity) { + return '${entity.runtimeType}@${entity.uid}'; +} + +String _encodeIOSink(RecordingIOSink sink) { + return '${sink.runtimeType}@${sink.uid}'; +} + +String _encodeRandomAccessFile(RecordingRandomAccessFile raf) { + return '${raf.runtimeType}@${raf.uid}'; +} + +String _encodeEncoding(Encoding encoding) => encoding.name; + +String _encodeFileMode(FileMode fileMode) { + switch (fileMode) { + case FileMode.READ: + return 'READ'; + case FileMode.WRITE: + return 'WRITE'; + case FileMode.APPEND: + return 'APPEND'; + case FileMode.WRITE_ONLY: + return 'WRITE_ONLY'; + case FileMode.WRITE_ONLY_APPEND: + return 'WRITE_ONLY_APPEND'; + } + throw new ArgumentError('Invalid value: $fileMode'); +} + +Map _encodeFileStat(FileStat stat) => { + 'changed': stat.changed, + 'modified': stat.modified, + 'accessed': stat.accessed, + 'type': stat.type, + 'mode': stat.mode, + 'size': stat.size, + 'modeString': stat.modeString(), + }; + +String _encodeFileSystemEntityType(FileSystemEntityType type) => + type.toString(); + +Map _encodeFileSystemEvent(FileSystemEvent event) => + { + 'type': event.type, + 'path': event.path, + }; diff --git a/lib/src/backends/record_replay/events.dart b/lib/src/backends/record_replay/events.dart new file mode 100644 index 0000000000000..a0802ea9c15ac --- /dev/null +++ b/lib/src/backends/record_replay/events.dart @@ -0,0 +1,136 @@ +// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'recording.dart'; + +/// Base class for recordable file system invocation events. +/// +/// Instances of this class will be aggregated in a [Recording] +abstract class InvocationEvent { + /// The object on which the invocation occurred. Will always be non-null. + Object get object; + + /// The return value of the invocation. This may be null (and will always be + /// `null` for setters). + T get result; + + /// The stopwatch value (in milliseconds) when the invocation occurred. + /// + /// This value is recorded when the invocation first occurs, not when the + /// delegate returns. + int get timestamp; +} + +/// A recordable invocation of a property getter on a file system object. +abstract class PropertyGetEvent extends InvocationEvent { + /// The property that was retrieved. + Symbol get property; +} + +/// A recordable invocation of a property setter on a file system object. +abstract class PropertySetEvent extends InvocationEvent { + /// The property that was set. + /// + /// All setter property symbols will have a trailing equals sign. For example, + /// if the `foo` property was set, this value will be a symbol of `foo=`. + Symbol get property; + + /// The value to which [property] was set. This is distinct from [result], + /// which is always `null` for setters. + T get value; +} + +/// A recordable invocation of a method on a file system object. +abstract class MethodEvent extends InvocationEvent { + /// The method that was invoked. + Symbol get method; + + /// The positional arguments that were passed to the method. + List get positionalArguments; + + /// The named arguments that were passed to the method. + Map get namedArguments; +} + +abstract class EventImpl implements InvocationEvent { + EventImpl(this.object, this.result, this.timestamp); + + @override + final Object object; + + @override + final T result; + + @override + final int timestamp; + + /// Encodes this event into a JSON-ready format. + Map encode() => { + 'object': object, + 'result': result, + 'timestamp': timestamp, + }; + + @override + String toString() => encode().toString(); +} + +class PropertyGetEventImpl extends EventImpl + implements PropertyGetEvent { + PropertyGetEventImpl(Object object, this.property, T result, int timestamp) + : super(object, result, timestamp); + + @override + final Symbol property; + + @override + Map encode() => { + 'type': 'get', + 'property': property, + }..addAll(super.encode()); +} + +class PropertySetEventImpl extends EventImpl + implements PropertySetEvent { + PropertySetEventImpl(Object object, this.property, this.value, int timestamp) + : super(object, null, timestamp); + + @override + final Symbol property; + + @override + final T value; + + @override + Map encode() => { + 'type': 'set', + 'property': property, + 'value': value, + }..addAll(super.encode()); +} + +class MethodEventImpl extends EventImpl implements MethodEvent { + MethodEventImpl(Object object, this.method, List positionalArguments, + Map namedArguments, T result, int timestamp) + : this.positionalArguments = new List.unmodifiable(positionalArguments), + this.namedArguments = new Map.unmodifiable(namedArguments), + super(object, result, timestamp); + + @override + final Symbol method; + + @override + final List positionalArguments; + + @override + final Map namedArguments; + + @override + Map encode() => { + 'type': 'invoke', + 'method': method, + 'positionalArguments': positionalArguments, + 'namedArguments': namedArguments, + }..addAll(super.encode()); +} diff --git a/lib/src/backends/record_replay/mutable_recording.dart b/lib/src/backends/record_replay/mutable_recording.dart new file mode 100644 index 0000000000000..98a9baf7758c2 --- /dev/null +++ b/lib/src/backends/record_replay/mutable_recording.dart @@ -0,0 +1,40 @@ +// 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 'package:file/file.dart'; + +import 'common.dart'; +import 'encoding.dart'; +import 'events.dart'; +import 'recording.dart'; + +/// A mutable live recording. +class MutableRecording implements LiveRecording { + final List> _events = >[]; + + /// Creates a new `MutableRecording` that will serialize its data to the + /// specified [destination]. + MutableRecording(this.destination); + + @override + final Directory destination; + + @override + List> get events => new List.unmodifiable(_events); + + // TODO(tvolkert): Add ability to wait for all Future and Stream results + @override + Future flush() async { + Directory dir = destination; + String json = new JsonEncoder.withIndent(' ', encode).convert(_events); + String filename = dir.fileSystem.path.join(dir.path, kManifestName); + await dir.fileSystem.file(filename).writeAsString(json, flush: true); + } + + /// Adds the specified [event] to this recording. + void add(InvocationEvent event) => _events.add(event); +} diff --git a/lib/src/backends/record_replay/recording.dart b/lib/src/backends/record_replay/recording.dart new file mode 100644 index 0000000000000..75462c2c511ec --- /dev/null +++ b/lib/src/backends/record_replay/recording.dart @@ -0,0 +1,42 @@ +// 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:file/file.dart'; + +import 'events.dart'; + +/// A recording of a series of invocations on a [FileSystem] and its associated +/// objects (`File`, `Directory`, `IOSink`, etc). +/// +/// Recorded invocations include property getters, property setters, and +/// standard method invocations. A recording exists as an ordered series of +/// "invocation events". +abstract class Recording { + /// The invocation events that have been captured by this recording. + List> get events; +} + +/// An [Recording] in progress that can be serialized to disk for later use +/// in [ReplayFileSystem]. +/// +/// Live recordings exist only in memory until [flush] is called. +abstract class LiveRecording extends Recording { + /// The directory in which recording files will be stored. + /// + /// These contents of these files, while human readable, do not constitute an + /// API or contract. Their makeup and structure is subject to change from + /// one version of `package:file` to the next. + Directory get destination; + + /// Writes this recording to disk. + /// + /// Live recordings will *not* call `flush` on themselves, so it is up to + /// callers to call this method when they wish to write the recording to disk. + /// + /// Returns a future that completes once the recording has been fully written + /// to disk. + Future flush(); +} diff --git a/lib/src/backends/record_replay/recording_directory.dart b/lib/src/backends/record_replay/recording_directory.dart new file mode 100644 index 0000000000000..db74661459fad --- /dev/null +++ b/lib/src/backends/record_replay/recording_directory.dart @@ -0,0 +1,64 @@ +// 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:file/file.dart'; +import 'package:file/src/io.dart' as io; + +import 'recording_file_system.dart'; +import 'recording_file_system_entity.dart'; + +class RecordingDirectory + extends RecordingFileSystemEntity + implements Directory { + RecordingDirectory(RecordingFileSystem fileSystem, io.Directory delegate) + : super(fileSystem, delegate) { + methods.addAll({ + #create: _create, + #createSync: delegate.createSync, + #createTemp: _createTemp, + #createTempSync: _createTempSync, + #list: _list, + #listSync: _listSync, + }); + } + + @override + Directory wrap(io.Directory delegate) => + super.wrap(delegate) ?? wrapDirectory(delegate); + + Future _create({bool recursive: false}) => + delegate.create(recursive: recursive).then(wrap); + + Future _createTemp([String prefix]) => + delegate.createTemp(prefix).then(wrap); + + Directory _createTempSync([String prefix]) => + wrap(delegate.createTempSync(prefix)); + + Stream _list( + {bool recursive: false, bool followLinks: true}) => + delegate + .list(recursive: recursive, followLinks: followLinks) + .map(_wrapGeneric); + + List _listSync( + {bool recursive: false, bool followLinks: true}) => + delegate + .listSync(recursive: recursive, followLinks: followLinks) + .map(_wrapGeneric) + .toList(); + + FileSystemEntity _wrapGeneric(io.FileSystemEntity entity) { + if (entity is io.File) { + return wrapFile(entity); + } else if (entity is io.Directory) { + return wrapDirectory(entity); + } else if (entity is io.Link) { + return wrapLink(entity); + } + throw new FileSystemException('Unsupported type: $entity', entity.path); + } +} diff --git a/lib/src/backends/record_replay/recording_file.dart b/lib/src/backends/record_replay/recording_file.dart new file mode 100644 index 0000000000000..a6bd2c4b5fe07 --- /dev/null +++ b/lib/src/backends/record_replay/recording_file.dart @@ -0,0 +1,83 @@ +// 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 'package:file/file.dart'; +import 'package:file/src/io.dart' as io; + +import 'recording_file_system.dart'; +import 'recording_file_system_entity.dart'; +import 'recording_io_sink.dart'; +import 'recording_random_access_file.dart'; + +class RecordingFile extends RecordingFileSystemEntity + implements File { + RecordingFile(RecordingFileSystem fileSystem, io.File delegate) + : super(fileSystem, delegate) { + methods.addAll({ + #create: _create, + #createSync: delegate.createSync, + #copy: _copy, + #copySync: _copySync, + #length: delegate.length, + #lengthSync: delegate.lengthSync, + #lastModified: delegate.lastModified, + #lastModifiedSync: delegate.lastModifiedSync, + #open: _open, + #openSync: _openSync, + #openRead: delegate.openRead, + #openWrite: _openWrite, + #readAsBytes: delegate.readAsBytes, + #readAsBytesSync: delegate.readAsBytesSync, + #readAsString: delegate.readAsString, + #readAsStringSync: delegate.readAsStringSync, + #readAsLines: delegate.readAsLines, + #readAsLinesSync: delegate.readAsLinesSync, + #writeAsBytes: _writeAsBytes, + #writeAsBytesSync: delegate.writeAsBytesSync, + #writeAsString: _writeAsString, + #writeAsStringSync: delegate.writeAsStringSync, + }); + } + + @override + File wrap(io.File delegate) => super.wrap(delegate) ?? wrapFile(delegate); + + RandomAccessFile _wrapRandomAccessFile(RandomAccessFile delegate) => + new RecordingRandomAccessFile(fileSystem, delegate); + + Future _create({bool recursive: false}) => + delegate.create(recursive: recursive).then(wrap); + + Future _copy(String newPath) => delegate.copy(newPath).then(wrap); + + File _copySync(String newPath) => wrap(delegate.copySync(newPath)); + + Future _open({FileMode mode: FileMode.READ}) => + delegate.open(mode: mode).then(_wrapRandomAccessFile); + + RandomAccessFile _openSync({FileMode mode: FileMode.READ}) => + _wrapRandomAccessFile(delegate.openSync(mode: mode)); + + IOSink _openWrite({FileMode mode: FileMode.WRITE, Encoding encoding: UTF8}) { + IOSink sink = delegate.openWrite(mode: mode, encoding: encoding); + return new RecordingIOSink(fileSystem, sink); + } + + Future _writeAsBytes(List bytes, + {FileMode mode: FileMode.WRITE, bool flush: false}) => + delegate.writeAsBytes(bytes, mode: mode, flush: flush).then(wrap); + + Future _writeAsString( + String contents, { + FileMode mode: FileMode.WRITE, + Encoding encoding: UTF8, + bool flush: false, + }) => + delegate + .writeAsString(contents, mode: mode, encoding: encoding, flush: flush) + .then(wrap); +} diff --git a/lib/src/backends/record_replay/recording_file_system.dart b/lib/src/backends/record_replay/recording_file_system.dart new file mode 100644 index 0000000000000..be999636b7064 --- /dev/null +++ b/lib/src/backends/record_replay/recording_file_system.dart @@ -0,0 +1,137 @@ +// 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:file/file.dart'; +import 'package:meta/meta.dart'; + +import 'mutable_recording.dart'; +import 'recording.dart'; +import 'recording_directory.dart'; +import 'recording_file.dart'; +import 'recording_link.dart'; +import 'recording_proxy_mixin.dart'; + +/// File system that records invocations for later playback in tests. +/// +/// This will record all invocations (methods, property getters, and property +/// setters) that occur on it, in an opaque format that can later be used in +/// [ReplayFileSystem]. All activity in the [File], [Directory], [Link], +/// [IOSink], and [RandomAccessFile] instances returned from this API will also +/// be recorded. +/// +/// This class is intended for use in tests, where you would otherwise have to +/// set up complex mocks or fake file systems. With this class, the process is +/// as follows: +/// +/// - You record the file system activity during a real run of your program +/// by injecting a `RecordingFileSystem` that delegates to your real file +/// system. +/// - You serialize that recording to disk as your program finishes. +/// - You use that recording in tests to create a mock file system that knows +/// how to respond to the exact invocations your program makes. Any +/// invocations that aren't in the recording will throw, and you can make +/// assertions in your tests about which methods were invoked and in what +/// order. +/// +/// See also: +/// - [ReplayFileSystem] +abstract class RecordingFileSystem extends FileSystem { + /// Creates a new `RecordingFileSystem`. + /// + /// Invocations will be recorded and forwarded to the specified [delegate] + /// file system. + /// + /// The recording will be serialized to the specified [destination] directory + /// (only when `flush` is called on this file system's [recording]). + /// + /// If [stopwatch] is specified, it will be assumed to have already been + /// started by the caller, and it will be used to record timestamps on each + /// recorded invocation. If `stopwatch` is unspecified (or `null`), a new + /// stopwatch will be created and started immediately to record these + /// timestamps. + factory RecordingFileSystem({ + @required FileSystem delegate, + @required Directory destination, + Stopwatch stopwatch, + }) => + new RecordingFileSystemImpl(delegate, destination, stopwatch); + + /// The file system to which invocations will be forwarded upon recording. + FileSystem get delegate; + + /// The recording generated by invocations on this file system. + /// + /// The recording provides access to the invocation events that have been + /// recorded thus far, as well as the ability to flush them to disk. + LiveRecording get recording; + + /// The stopwatch used to record timestamps on invocation events. + /// + /// Timestamps will be recorded before the delegate is invoked (not after + /// the delegate returns). + Stopwatch get stopwatch; +} + +class RecordingFileSystemImpl extends FileSystem + with RecordingProxyMixin + implements RecordingFileSystem { + RecordingFileSystemImpl( + this.delegate, Directory destination, Stopwatch recordingStopwatch) + : recording = new MutableRecording(destination), + stopwatch = recordingStopwatch ?? new Stopwatch() { + if (recordingStopwatch == null) { + // We instantiated our own stopwatch, so start it ourselves. + stopwatch.start(); + } + + methods.addAll({ + #directory: _directory, + #file: _file, + #link: _link, + #stat: delegate.stat, + #statSync: delegate.statSync, + #identical: delegate.identical, + #identicalSync: delegate.identicalSync, + #type: delegate.type, + #typeSync: delegate.typeSync, + }); + + properties.addAll({ + #path: () => delegate.path, + #systemTempDirectory: _getSystemTempDirectory, + #currentDirectory: _getCurrentDirectory, + const Symbol('currentDirectory='): _setCurrentDirectory, + #isWatchSupported: () => delegate.isWatchSupported, + }); + } + + /// The file system to which invocations will be forwarded upon recording. + @override + final FileSystem delegate; + + /// The recording generated by invocations on this file system. + @override + final MutableRecording recording; + + /// The stopwatch used to record timestamps on invocation events. + @override + final Stopwatch stopwatch; + + Directory _directory(dynamic path) => + new RecordingDirectory(this, delegate.directory(path)); + + File _file(dynamic path) => new RecordingFile(this, delegate.file(path)); + + Link _link(dynamic path) => new RecordingLink(this, delegate.link(path)); + + Directory _getSystemTempDirectory() => + new RecordingDirectory(this, delegate.systemTempDirectory); + + Directory _getCurrentDirectory() => + new RecordingDirectory(this, delegate.currentDirectory); + + void _setCurrentDirectory(dynamic value) { + delegate.currentDirectory = value; + } +} diff --git a/lib/src/backends/record_replay/recording_file_system_entity.dart b/lib/src/backends/record_replay/recording_file_system_entity.dart new file mode 100644 index 0000000000000..097ba0b54739c --- /dev/null +++ b/lib/src/backends/record_replay/recording_file_system_entity.dart @@ -0,0 +1,97 @@ +// 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:file/file.dart'; +import 'package:file/src/io.dart' as io; +import 'package:meta/meta.dart'; + +import 'common.dart'; +import 'mutable_recording.dart'; +import 'recording_directory.dart'; +import 'recording_file.dart'; +import 'recording_file_system.dart'; +import 'recording_link.dart'; +import 'recording_proxy_mixin.dart'; + +abstract class RecordingFileSystemEntity extends Object + with RecordingProxyMixin + implements FileSystemEntity { + RecordingFileSystemEntity(this.fileSystem, this.delegate) { + methods.addAll({ + #exists: delegate.exists, + #existsSync: delegate.existsSync, + #rename: _rename, + #renameSync: _renameSync, + #resolveSymbolicLinks: delegate.resolveSymbolicLinks, + #resolveSymbolicLinksSync: delegate.resolveSymbolicLinksSync, + #stat: delegate.stat, + #statSync: delegate.statSync, + #delete: _delete, + #deleteSync: delegate.deleteSync, + #watch: delegate.watch, + }); + + properties.addAll({ + #path: () => delegate.path, + #uri: () => delegate.uri, + #isAbsolute: () => delegate.isAbsolute, + #absolute: _getAbsolute, + #parent: _getParent, + }); + } + + /// A unique entity id. + final int uid = newUid(); + + @override + final RecordingFileSystemImpl fileSystem; + + @override + MutableRecording get recording => fileSystem.recording; + + @override + Stopwatch get stopwatch => fileSystem.stopwatch; + + @protected + final D delegate; + + /// Returns an entity with the same file system and same type as this + /// entity but backed by the specified delegate. + /// + /// If the specified delegate is the same as this entity's delegate, this + /// will return this entity. + /// + /// Subclasses should override this method to instantiate the correct wrapped + /// type if this super implementation returns `null`. + @protected + @mustCallSuper + T wrap(D delegate) => delegate == this.delegate ? this as T : null; + + @protected + Directory wrapDirectory(io.Directory delegate) => + new RecordingDirectory(fileSystem, delegate); + + @protected + File wrapFile(io.File delegate) => new RecordingFile(fileSystem, delegate); + + @protected + Link wrapLink(io.Link delegate) => new RecordingLink(fileSystem, delegate); + + Future _rename(String newPath) => delegate + .rename(newPath) + .then((io.FileSystemEntity entity) => wrap(entity as D)); + + T _renameSync(String newPath) => wrap(delegate.renameSync(newPath) as D); + + Future _delete({bool recursive: false}) => delegate + .delete(recursive: recursive) + .then((io.FileSystemEntity entity) => wrap(entity as D)); + + T _getAbsolute() => wrap(delegate.absolute as D); + + Directory _getParent() => wrapDirectory(delegate.parent); +} diff --git a/lib/src/backends/record_replay/recording_io_sink.dart b/lib/src/backends/record_replay/recording_io_sink.dart new file mode 100644 index 0000000000000..8cff7487cd007 --- /dev/null +++ b/lib/src/backends/record_replay/recording_io_sink.dart @@ -0,0 +1,52 @@ +// 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:file/file.dart'; + +import 'common.dart'; +import 'mutable_recording.dart'; +import 'recording_file_system.dart'; +import 'recording_proxy_mixin.dart'; + +class RecordingIOSink extends Object + with RecordingProxyMixin + implements IOSink { + final RecordingFileSystem fileSystem; + final IOSink delegate; + + RecordingIOSink(this.fileSystem, this.delegate) { + methods.addAll({ + #add: delegate.add, + #write: delegate.write, + #writeAll: delegate.writeAll, + #writeln: delegate.writeln, + #writeCharCode: delegate.writeCharCode, + #addError: delegate.addError, + #addStream: delegate.addStream, + #flush: delegate.flush, + #close: delegate.close, + }); + + properties.addAll({ + #encoding: () => delegate.encoding, + const Symbol('encoding='): _setEncoding, + #done: () => delegate.done, + }); + } + + /// A unique entity id. + final int uid = newUid(); + + @override + MutableRecording get recording => fileSystem.recording; + + @override + Stopwatch get stopwatch => fileSystem.stopwatch; + + void _setEncoding(Encoding value) { + delegate.encoding = value; + } +} diff --git a/lib/src/backends/record_replay/recording_link.dart b/lib/src/backends/record_replay/recording_link.dart new file mode 100644 index 0000000000000..21545516ab325 --- /dev/null +++ b/lib/src/backends/record_replay/recording_link.dart @@ -0,0 +1,34 @@ +// 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:file/file.dart'; +import 'package:file/src/io.dart' as io; + +import 'recording_file_system.dart'; +import 'recording_file_system_entity.dart'; + +class RecordingLink extends RecordingFileSystemEntity + implements Link { + RecordingLink(RecordingFileSystem fileSystem, io.Link delegate) + : super(fileSystem, delegate) { + methods.addAll({ + #create: _create, + #createSync: delegate.createSync, + #update: _update, + #updateSync: delegate.updateSync, + #target: delegate.target, + #targetSync: delegate.targetSync, + }); + } + + @override + Link wrap(io.Link delegate) => super.wrap(delegate) ?? wrapLink(delegate); + + Future _create(String target, {bool recursive: false}) => + delegate.create(target, recursive: recursive).then(wrap); + + Future _update(String target) => delegate.update(target).then(wrap); +} diff --git a/lib/src/backends/record_replay/recording_proxy_mixin.dart b/lib/src/backends/record_replay/recording_proxy_mixin.dart new file mode 100644 index 0000000000000..77e6490e2fbfc --- /dev/null +++ b/lib/src/backends/record_replay/recording_proxy_mixin.dart @@ -0,0 +1,202 @@ +// 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:meta/meta.dart'; + +import 'events.dart'; +import 'mutable_recording.dart'; + +/// Mixin that enables recording of property accesses, property mutations, and +/// method invocations. +/// +/// This class uses `noSuchMethod` to record a well-defined set of invocations +/// (including property gets and sets) on an object before passing the +/// invocation on to a delegate. Subclasses wire this up by doing the following: +/// +/// - Populate the list of method invocations to record in the [methods] map. +/// - Populate the list of property invocations to record in the [properties] +/// map. The symbol name for getters should be the property name, and the +/// symbol name for setters should be the property name immediately +/// followed by an equals sign (e.g. `propertyName=`). +/// - Do not implement a concrete getter, setter, or method that you wish to +/// record, as doing so will circumvent the machinery that this mixin uses +/// (`noSuchMethod`) to record invocations. +/// +/// **Example use**: +/// +/// abstract class Foo { +/// void sampleMethod(); +/// +/// int sampleProperty; +/// } +/// +/// class RecordingFoo extends Object with _RecordingProxyMixin implements Foo { +/// final Foo delegate; +/// +/// RecordingFoo(this.delegate) { +/// methods.addAll({ +/// #sampleMethod: delegate.sampleMethod, +/// }); +/// +/// properties.addAll({ +/// #sampleProperty: () => delegate.sampleProperty, +/// const Symbol('sampleProperty='): (int value) { +/// delegate.sampleProperty = value; +/// }, +/// }); +/// } +/// } +/// +/// **Behavioral notes**: +/// +/// Methods that return [Future]s will not be recorded until the future +/// completes. +/// +/// Methods that return [Stream]s will be recorded immediately, but their +/// return values will be recorded as a [List] that will grow as the stream +/// produces data. +abstract class RecordingProxyMixin { + /// Maps method names to delegate functions. + /// + /// Invocations of methods listed in this map will be recorded after + /// invoking the underlying delegate function. + @protected + final Map methods = {}; + + /// Maps property getter and setter names to delegate functions. + /// + /// Access and mutation of properties listed in this map will be recorded + /// after invoking the underlying delegate function. + /// + /// The keys for property getters are the simple property names, whereas the + /// keys for property setters are the property names followed by an equals + /// sign (e.g. `propertyName=`). + @protected + final Map properties = {}; + + /// The object to which invocation events will be recorded. + @protected + MutableRecording get recording; + + /// The stopwatch used to record invocation timestamps. + @protected + Stopwatch get stopwatch; + + /// Handles invocations for which there is no concrete implementation + /// function. + /// + /// For invocations that have matching entries in [methods] (for method + /// invocations) or [properties] (for property access and mutation), this + /// will record the invocation in [recording] after invoking the underlying + /// delegate method. All other invocations will throw a [NoSuchMethodError]. + @override + dynamic noSuchMethod(Invocation invocation) { + Symbol name = invocation.memberName; + List args = invocation.positionalArguments; + Map namedArgs = invocation.namedArguments; + Function method = invocation.isAccessor ? properties[name] : methods[name]; + int time = stopwatch.elapsedMilliseconds; + + if (method == null) { + // No delegate function generally means that there truly is no such + // method on this object. The exception is when the invocation represents + // a getter on a method, in which case we return a method proxy that, + // when invoked, will perform the desired recording. + return invocation.isGetter && methods[name] != null + ? new _MethodProxy(this, name) + : super.noSuchMethod(invocation); + } + + T recordEvent(T value) { + InvocationEvent event; + if (invocation.isGetter) { + event = new PropertyGetEventImpl(this, name, value, time); + } else if (invocation.isSetter) { + // TODO(tvolkert): Remove indirection once SDK 1.22 is in stable branch + dynamic temp = new PropertySetEventImpl(this, name, args[0], time); + event = temp; + } else { + event = new MethodEventImpl(this, name, args, namedArgs, value, time); + } + recording.add(event); + return value; + } + + dynamic value = Function.apply(method, args, namedArgs); + if (value is Stream) { + List list = []; + value = _recordStreamToList(value, list); + recordEvent(list); + } else if (value is Future) { + value = value.then(recordEvent); + } else { + recordEvent(value); + } + + return value; + } + + /// Returns a stream that produces the same data as [stream] but will record + /// the data in the specified [list] as it is produced by the stream. + Stream _recordStreamToList(Stream stream, List list) async* { + await for (T element in stream) { + yield element; + list.add(element); + } + } +} + +/// A function reference that, when invoked, will record the invocation. +class _MethodProxy extends Object implements Function { + /// The object on which the method was originally invoked. + final RecordingProxyMixin object; + + /// The name of the method that was originally invoked. + final Symbol methodName; + + _MethodProxy(this.object, this.methodName); + + @override + dynamic noSuchMethod(Invocation invocation) { + if (invocation.isMethod && invocation.memberName == #call) { + // The method is being invoked. Capture the arguments, and invoke the + // method on the object. We have to synthesize an invocation, since our + // current `invocation` object represents the invocation of `call()`. + return object.noSuchMethod(new _MethodInvocationProxy( + methodName, + invocation.positionalArguments, + invocation.namedArguments, + )); + } + return super.noSuchMethod(invocation); + } +} + +class _MethodInvocationProxy extends Invocation { + _MethodInvocationProxy( + this.memberName, + this.positionalArguments, + this.namedArguments, + ); + + @override + final Symbol memberName; + + @override + final List positionalArguments; + + @override + final Map namedArguments; + + @override + final bool isMethod = true; + + @override + final bool isGetter = false; + + @override + final bool isSetter = false; +} diff --git a/lib/src/backends/record_replay/recording_random_access_file.dart b/lib/src/backends/record_replay/recording_random_access_file.dart new file mode 100644 index 0000000000000..2b1d9119ea96c --- /dev/null +++ b/lib/src/backends/record_replay/recording_random_access_file.dart @@ -0,0 +1,97 @@ +// 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 'package:file/file.dart'; + +import 'common.dart'; +import 'mutable_recording.dart'; +import 'recording_file_system.dart'; +import 'recording_proxy_mixin.dart'; + +class RecordingRandomAccessFile extends Object + with RecordingProxyMixin + implements RandomAccessFile { + final RecordingFileSystem fileSystem; + final RandomAccessFile delegate; + + RecordingRandomAccessFile(this.fileSystem, this.delegate) { + methods.addAll({ + #close: _close, + #closeSync: delegate.closeSync, + #readByte: delegate.readByte, + #readByteSync: delegate.readByteSync, + #read: delegate.read, + #readSync: delegate.readSync, + #readInto: delegate.readInto, + #readIntoSync: delegate.readIntoSync, + #writeByte: _writeByte, + #writeByteSync: delegate.writeByteSync, + #writeFrom: _writeFrom, + #writeFromSync: delegate.writeFromSync, + #writeString: _writeString, + #writeStringSync: delegate.writeStringSync, + #position: delegate.position, + #positionSync: delegate.positionSync, + #setPosition: _setPosition, + #setPositionSync: delegate.setPositionSync, + #truncate: _truncate, + #truncateSync: delegate.truncateSync, + #length: delegate.length, + #lengthSync: delegate.lengthSync, + #flush: _flush, + #flushSync: delegate.flushSync, + #lock: _lock, + #lockSync: delegate.lockSync, + #unlock: _unlock, + #unlockSync: delegate.unlockSync, + }); + + properties.addAll({ + #path: () => delegate.path, + }); + } + + /// A unique entity id. + final int uid = newUid(); + + @override + MutableRecording get recording => fileSystem.recording; + + @override + Stopwatch get stopwatch => fileSystem.stopwatch; + + RandomAccessFile _wrap(RandomAccessFile raw) => + raw == delegate ? this : new RecordingRandomAccessFile(fileSystem, raw); + + Future _close() => delegate.close().then(_wrap); + + Future _writeByte(int value) => + delegate.writeByte(value).then(_wrap); + + Future _writeFrom(List buffer, + [int start = 0, int end]) => + delegate.writeFrom(buffer, start, end).then(_wrap); + + Future _writeString(String string, + {Encoding encoding: UTF8}) => + delegate.writeString(string, encoding: encoding).then(_wrap); + + Future _setPosition(int position) => + delegate.setPosition(position).then(_wrap); + + Future _truncate(int length) => + delegate.truncate(length).then(_wrap); + + Future _flush() => delegate.flush().then(_wrap); + + Future _lock( + [FileLock mode = FileLock.EXCLUSIVE, int start = 0, int end = -1]) => + delegate.lock(mode, start, end).then(_wrap); + + Future _unlock([int start = 0, int end = -1]) => + delegate.unlock(start, end).then(_wrap); +} diff --git a/lib/src/testing/core_matchers.dart b/lib/src/testing/core_matchers.dart new file mode 100644 index 0000000000000..d631c2f22bad1 --- /dev/null +++ b/lib/src/testing/core_matchers.dart @@ -0,0 +1,101 @@ +// 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:file/file.dart'; +import 'package:test/test.dart'; + +/// Matcher that successfully matches against any instance of [Directory]. +const Matcher isDirectory = const isInstanceOf(); + +/// Matcher that successfully matches against any instance of [File]. +const Matcher isFile = const isInstanceOf(); + +/// Matcher that successfully matches against any instance of [Link]. +const Matcher isLink = const isInstanceOf(); + +/// Matcher that successfully matches against any instance of +/// [FileSystemEntity]. +const Matcher isFileSystemEntity = const isInstanceOf(); + +/// Matcher that successfully matches against any instance of [FileStat]. +const Matcher isFileStat = const isInstanceOf(); + +/// Returns a [Matcher] that matches [path] against an entity's path. +/// +/// [path] may be a String, a predicate function, or a [Matcher]. If it is +/// a String, it will be wrapped in an equality matcher. +Matcher hasPath(dynamic path) => new _HasPath(path); + +/// Returns a [Matcher] that successfully matches against an instance of +/// [FileSystemException]. +/// +/// If [message] is specified, matches will be limited to exceptions with a +/// matching `message` (either in the exception itself or in the nested +/// [OSError]). +/// +/// [message] may be a String, a predicate function, or a [Matcher]. If it is +/// a String, it will be wrapped in an equality matcher. +Matcher isFileSystemException([dynamic message]) => + new _FileSystemException(message); + +/// Returns a matcher that successfully matches against a future or function +/// that throws a [FileSystemException]. +/// +/// If [message] is specified, matches will be limited to exceptions with a +/// matching `message` (either in the exception itself or in the nested +/// [OSError]). +/// +/// [message] may be a String, a predicate function, or a [Matcher]. If it is +/// a String, it will be wrapped in an equality matcher. +Matcher throwsFileSystemException([dynamic message]) => + new Throws(isFileSystemException(message)); + +class _FileSystemException extends Matcher { + final Matcher _matcher; + + _FileSystemException(dynamic message) + : _matcher = message == null ? null : wrapMatcher(message); + + @override + bool matches(dynamic item, Map matchState) { + if (item is FileSystemException) { + return (_matcher == null || + _matcher.matches(item.message, matchState) || + _matcher.matches(item.osError?.message, matchState)); + } + return false; + } + + @override + Description describe(Description desc) { + desc.add('FileSystemException with message: '); + return _matcher.describe(desc); + } +} + +class _HasPath extends Matcher { + final Matcher _matcher; + + _HasPath(dynamic path) : _matcher = wrapMatcher(path); + + @override + bool matches(dynamic item, Map matchState) => + _matcher.matches(item.path, matchState); + + @override + Description describe(Description desc) { + desc.add('has path: '); + return _matcher.describe(desc); + } + + @override + Description describeMismatch( + item, Description desc, Map matchState, bool verbose) { + desc.add('has path: \'${item.path}\'').add('\n Which: '); + Description pathDesc = new StringDescription(); + _matcher.describeMismatch(item.path, pathDesc, matchState, verbose); + desc.add(pathDesc.toString()); + return desc; + } +} diff --git a/lib/src/testing/record_replay_matchers.dart b/lib/src/testing/record_replay_matchers.dart new file mode 100644 index 0000000000000..25cc13eea8629 --- /dev/null +++ b/lib/src/testing/record_replay_matchers.dart @@ -0,0 +1,442 @@ +// 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:file/record_replay.dart'; +import 'package:file/src/backends/record_replay/common.dart'; +import 'package:test/test.dart'; + +const Map _kTypeDescriptions = const { + MethodEvent: 'a method invocation', + PropertyGetEvent: 'a property retrieval', + PropertySetEvent: 'a property mutation', +}; + +const Map _kTypeMatchers = const { + MethodEvent: const isInstanceOf>(), + PropertyGetEvent: const isInstanceOf>(), + PropertySetEvent: const isInstanceOf>(), +}; + +/// Returns a matcher that will match against a [MethodEvent]. +/// +/// If [name] is specified, only method invocations of a matching method name +/// will successfully match. [name] may be a String, a predicate function, +/// or a [Matcher]. +/// +/// The returned [MethodInvocation] matcher can be used to further limit the +/// scope of the match (e.g. by invocation result, target object, etc). +MethodInvocation invokesMethod([dynamic name]) => new MethodInvocation._(name); + +/// Returns a matcher that will match against a [PropertyGetEvent]. +/// +/// If [name] is specified, only property retrievals of a matching property name +/// will successfully match. [name] may be a String, a predicate function, +/// or a [Matcher]. +/// +/// The returned [PropertyGet] matcher can be used to further limit the +/// scope of the match (e.g. by property value, target object, etc). +PropertyGet getsProperty([dynamic name]) => new PropertyGet._(name); + +/// Returns a matcher that will match against a [PropertySetEvent]. +/// +/// If [name] is specified, only property mutations of a matching property name +/// will successfully match. [name] may be a String, a predicate function, +/// or a [Matcher]. +/// +/// The returned [PropertySet] matcher can be used to further limit the +/// scope of the match (e.g. by property value, target object, etc). +PropertySet setsProperty([dynamic name]) => new PropertySet._(name); + +/// Base class for matchers that match against generic [InvocationEvent] +/// instances. +abstract class RecordedInvocation> + extends Matcher { + final _Type _typeMatcher; + final List _fieldMatchers = []; + + RecordedInvocation._(Type type) : _typeMatcher = new _Type(type); + + /// Limits the scope of the match to invocations that occurred on the + /// specified target [object]. + /// + /// [object] may be an instance or a [Matcher]. If it is an instance, it will + /// be automatically wrapped in an equality matcher. + /// + /// Returns this matcher for chaining. + T on(dynamic object) { + _fieldMatchers.add(new _Target(object)); + return this; + } + + /// Limits the scope of the match to invocations that produced the specified + /// [result]. + /// + /// For method invocations, this matches against the return value of the + /// method. For property retrievals, this matches against the value of the + /// property. Property mutations will always produce a `null` result, so + /// [PropertySet] will automatically call `withResult(null)` when it is + /// instantiated. + /// + /// [result] may be an instance or a [Matcher]. If it is an instance, it will + /// be automatically wrapped in an equality matcher. + /// + /// Returns this matcher for chaining. + T withResult(dynamic result) { + _fieldMatchers.add(new _Result(result)); + return this; + } + + /// @nodoc + bool matches(dynamic item, Map matchState) { + if (!_typeMatcher.matches(item, matchState)) { + addStateInfo(matchState, {'matcher': _typeMatcher}); + return false; + } + for (Matcher matcher in _fieldMatchers) { + if (!matcher.matches(item, matchState)) { + addStateInfo(matchState, {'matcher': matcher}); + return false; + } + } + return true; + } + + /// @nodoc + Description describeMismatch(dynamic item, Description mismatchDescription, + Map matchState, bool verbose) { + Matcher matcher = matchState['matcher']; + matcher.describeMismatch( + item, mismatchDescription, matchState['state'], verbose); + return mismatchDescription; + } + + /// @nodoc + Description describe(Description description) { + String divider = '\n - '; + return _typeMatcher + .describe(description) + .add(':') + .addAll(divider, divider, '', _fieldMatchers); + } +} + +/// Matchers that matches against [MethodEvent] instances. +/// +/// Instances of this matcher are obtained by calling [invokesMethod]. Once +/// instantiated, callers may use this matcher to further qualify the scope +/// of their match. +class MethodInvocation extends RecordedInvocation { + MethodInvocation._(dynamic methodName) : super._(MethodEvent) { + if (methodName != null) { + _fieldMatchers.add(new _MethodName(methodName)); + } + } + + /// Limits the scope of the match to method invocations that passed the + /// specified positional [arguments]. + /// + /// [arguments] may be a list instance or a [Matcher]. If it is a list, it + /// will be automatically wrapped in an equality matcher. + /// + /// Returns this matcher for chaining. + MethodInvocation withPositionalArguments(dynamic arguments) { + _fieldMatchers.add(new _PositionalArguments(arguments)); + return this; + } + + /// Limits the scope of the match to method invocations that passed the + /// specified named argument. + /// + /// The argument [value] may be an instance or a [Matcher]. If it is an + /// instance, it will be automatically wrapped in an equality matcher. + /// + /// Returns this matcher for chaining. + MethodInvocation withNamedArgument(String name, dynamic value) { + _fieldMatchers.add(new _NamedArgument(name, value)); + return this; + } +} + +/// Matchers that matches against [PropertyGetEvent] instances. +/// +/// Instances of this matcher are obtained by calling [getsProperty]. Once +/// instantiated, callers may use this matcher to further qualify the scope +/// of their match. +class PropertyGet extends RecordedInvocation { + PropertyGet._(dynamic propertyName) : super._(PropertyGetEvent) { + if (propertyName != null) { + _fieldMatchers.add(new _GetPropertyName(propertyName)); + } + } +} + +/// Matchers that matches against [PropertySetEvent] instances. +/// +/// Instances of this matcher are obtained by calling [setsProperty]. Once +/// instantiated, callers may use this matcher to further qualify the scope +/// of their match. +class PropertySet extends RecordedInvocation { + PropertySet._(dynamic propertyName) : super._(PropertySetEvent) { + withResult(null); + if (propertyName != null) { + _fieldMatchers.add(new _SetPropertyName(propertyName)); + } + } + + /// Limits the scope of the match to property mutations that set the property + /// to the specified [value]. + /// + /// [value] may be an instance or a [Matcher]. If it is an instance, it will + /// be automatically wrapped in an equality matcher. + /// + /// Returns this matcher for chaining. + PropertySet toValue(dynamic value) { + _fieldMatchers.add(new _SetValue(value)); + return this; + } +} + +class _Target extends Matcher { + final Matcher _matcher; + + _Target(dynamic target) : _matcher = wrapMatcher(target); + + @override + bool matches(dynamic item, Map matchState) => + _matcher.matches(item.object, matchState); + + @override + Description describeMismatch( + dynamic item, Description desc, Map matchState, bool verbose) { + desc.add('was invoked on: ${item.object}').add('\n Which: '); + Description matcherDesc = new StringDescription(); + _matcher.describeMismatch(item.object, matcherDesc, matchState, verbose); + desc.add(matcherDesc.toString()); + return desc; + } + + Description describe(Description description) { + description.add('on object: '); + return _matcher.describe(description); + } +} + +class _Result extends Matcher { + final Matcher _matcher; + + _Result(dynamic result) : _matcher = wrapMatcher(result); + + @override + bool matches(dynamic item, Map matchState) => + _matcher.matches(item.result, matchState); + + @override + Description describeMismatch(dynamic item, Description mismatchDescription, + Map matchState, bool verbose) { + mismatchDescription.add('returned: ${item.result}').add('\n Which: '); + Description matcherDesc = new StringDescription(); + _matcher.describeMismatch(item.result, matcherDesc, matchState, verbose); + mismatchDescription.add(matcherDesc.toString()); + return mismatchDescription; + } + + Description describe(Description description) { + description.add('with result: '); + return _matcher.describe(description); + } +} + +class _Type extends Matcher { + final Type type; + + const _Type(this.type); + + @override + bool matches(dynamic item, Map matchState) => + _kTypeMatchers[type].matches(item, matchState); + + @override + Description describeMismatch(dynamic item, Description desc, + Map matchState, bool verbose) { + Type type; + for (Type matchType in _kTypeMatchers.keys) { + Matcher matcher = _kTypeMatchers[matchType]; + if (matcher.matches(item, {})) { + type = matchType; + break; + } + } + if (type != null) { + desc.add('is ').add(_kTypeDescriptions[type]); + } else { + desc.add('is a ${item.runtimeType}'); + } + return desc; + } + + @override + Description describe(Description desc) => desc.add(_kTypeDescriptions[type]); +} + +class _MethodName extends Matcher { + final Matcher _matcher; + + _MethodName(dynamic name) : _matcher = wrapMatcher(name); + + @override + bool matches(dynamic item, Map matchState) => + _matcher.matches(getSymbolName(item.method), matchState); + + @override + Description describeMismatch(dynamic item, Description mismatchDescription, + Map matchState, bool verbose) { + String methodName = getSymbolName(item.method); + mismatchDescription + .add('invoked method: \'$methodName\'') + .add('\n Which: '); + Description matcherDesc = new StringDescription(); + _matcher.describeMismatch(methodName, matcherDesc, matchState, verbose); + mismatchDescription.add(matcherDesc.toString()); + return mismatchDescription; + } + + Description describe(Description description) { + description.add('method: '); + return _matcher.describe(description); + } +} + +class _PositionalArguments extends Matcher { + final Matcher _matcher; + + _PositionalArguments(dynamic value) : _matcher = wrapMatcher(value); + + @override + bool matches(dynamic item, Map matchState) => + _matcher.matches(item.positionalArguments, matchState); + + @override + Description describeMismatch(dynamic item, Description desc, + Map matchState, bool verbose) { + return _matcher.describeMismatch( + item.positionalArguments, desc, matchState, verbose); + } + + Description describe(Description description) { + description.add('with positional arguments: '); + return _matcher.describe(description); + } +} + +class _NamedArgument extends Matcher { + final String name; + final dynamic value; + final Matcher _matcher; + + _NamedArgument(String name, this.value) + : this.name = name, + _matcher = containsPair(new Symbol(name), value); + + @override + bool matches(dynamic item, Map matchState) => + _matcher.matches(item.namedArguments, matchState); + + @override + Description describeMismatch(dynamic item, Description desc, + Map matchState, bool verbose) { + return _matcher.describeMismatch( + item.namedArguments, desc, matchState, verbose); + } + + Description describe(Description description) => + description.add('with named argument "$name" = $value'); +} + +class _GetPropertyName extends Matcher { + final Matcher _matcher; + + _GetPropertyName(dynamic _name) : _matcher = wrapMatcher(_name); + + @override + bool matches(dynamic item, Map matchState) => + _matcher.matches(getSymbolName(item.property), matchState); + + @override + Description describeMismatch(dynamic item, Description mismatchDescription, + Map matchState, bool verbose) { + String propertyName = getSymbolName(item.property); + mismatchDescription + .add('got property: \'$propertyName\'') + .add('\n Which: '); + Description matcherDesc = new StringDescription(); + _matcher.describeMismatch(propertyName, matcherDesc, matchState, verbose); + mismatchDescription.add(matcherDesc.toString()); + return mismatchDescription; + } + + Description describe(Description description) { + description.add('gets property: '); + return _matcher.describe(description); + } +} + +class _SetPropertyName extends Matcher { + final Matcher _matcher; + + _SetPropertyName(dynamic _name) : _matcher = wrapMatcher(_name); + + /// Strips the trailing `=` off the symbol name to get the property name. + String _getPropertyName(dynamic item) { + String symbolName = getSymbolName(item.property); + return symbolName.substring(0, symbolName.length - 1); + } + + @override + bool matches(dynamic item, Map matchState) { + return _matcher.matches(_getPropertyName(item), matchState); + } + + @override + Description describeMismatch(dynamic item, Description mismatchDescription, + Map matchState, bool verbose) { + String propertyName = _getPropertyName(item); + mismatchDescription + .add('set property: \'$propertyName\'') + .add('\n Which: '); + Description matcherDesc = new StringDescription(); + _matcher.describeMismatch(propertyName, matcherDesc, matchState, verbose); + mismatchDescription.add(matcherDesc.toString()); + return mismatchDescription; + } + + Description describe(Description description) { + description.add('of property: '); + return _matcher.describe(description); + } +} + +class _SetValue extends Matcher { + final Matcher _matcher; + + _SetValue(dynamic value) : _matcher = wrapMatcher(value); + + @override + bool matches(dynamic item, Map matchState) => + _matcher.matches(item.value, matchState); + + @override + Description describeMismatch(dynamic item, Description mismatchDescription, + Map matchState, bool verbose) { + mismatchDescription.add('set value: ${item.value}').add('\n Which: '); + Description matcherDesc = new StringDescription(); + _matcher.describeMismatch(item.value, matcherDesc, matchState, verbose); + mismatchDescription.add(matcherDesc.toString()); + return mismatchDescription; + } + + Description describe(Description description) { + description.add('to value: '); + return _matcher.describe(description); + } +} diff --git a/lib/testing.dart b/lib/testing.dart new file mode 100644 index 0000000000000..a56ef19b9c8b3 --- /dev/null +++ b/lib/testing.dart @@ -0,0 +1,6 @@ +// 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. + +export 'src/testing/core_matchers.dart'; +export 'src/testing/record_replay_matchers.dart'; diff --git a/test/common_tests.dart b/test/common_tests.dart index 025f99d645ff7..940b06eca5129 100644 --- a/test/common_tests.dart +++ b/test/common_tests.dart @@ -8,9 +8,14 @@ import 'dart:convert'; import 'dart:io' as io; import 'package:file/file.dart'; +import 'package:file/testing.dart'; import 'package:test/test.dart'; import 'package:test/test.dart' as testpkg show group, test; +void expectFileSystemException(dynamic msg, void callback()) { + expect(callback, throwsFileSystemException(msg)); +} + /// Runs a suite of tests common to all file system implementations. All file /// system implementations should run *at least* these tests to ensure /// compliance with file system API. @@ -441,8 +446,8 @@ void runCommonTests( fs.file(ns('/foo')).createSync(); // TODO(tvolkert): Change this to just be 'Not a directory' // once Dart 1.22 is stable. - RegExp pattern = new RegExp('(File exists|Not a directory)'); - expectFileSystemException(pattern, () { + String pattern = '(File exists|Not a directory)'; + expectFileSystemException(matches(pattern), () { fs.directory(ns('/foo')).createSync(); }); }); @@ -458,8 +463,8 @@ void runCommonTests( fs.link(ns('/bar')).createSync(ns('/foo')); // TODO(tvolkert): Change this to just be 'Not a directory' // once Dart 1.22 is stable. - RegExp pattern = new RegExp('(File exists|Not a directory)'); - expectFileSystemException(pattern, () { + String pattern = '(File exists|Not a directory)'; + expectFileSystemException(matches(pattern), () { fs.directory(ns('/bar')).createSync(); }); }); @@ -2667,8 +2672,8 @@ void runCommonTests( fs.directory(ns('/foo')).createSync(); // TODO(tvolkert): Change this to just be 'Is a directory' // once Dart 1.22 is stable. - RegExp pattern = new RegExp('(Invalid argument|Is a directory)'); - expectFileSystemException(pattern, () { + String pattern = '(Invalid argument|Is a directory)'; + expectFileSystemException(matches(pattern), () { fs.link(ns('/foo')).deleteSync(); }); }); @@ -2832,8 +2837,8 @@ void runCommonTests( fs.directory(ns('/foo')).createSync(); // TODO(tvolkert): Change this to just be 'Is a directory' // once Dart 1.22 is stable. - RegExp pattern = new RegExp('(Invalid argument|Is a directory)'); - expectFileSystemException(pattern, () { + String pattern = '(Invalid argument|Is a directory)'; + expectFileSystemException(matches(pattern), () { fs.link(ns('/foo')).updateSync(ns('/bar')); }); }); @@ -3071,63 +3076,3 @@ void runCommonTests( }); }); } - -const Matcher isDirectory = const _IsDirectory(); -const Matcher isFile = const _IsFile(); -const Matcher isLink = const _IsLink(); -const Matcher isFileSystemEntity = const _IsFileSystemEntity(); - -Matcher hasPath(String path) => new _HasPath(equals(path)); - -Matcher isFileSystemException([Pattern msg]) => new _FileSystemException(msg); -Matcher throwsFileSystemException([Pattern msg]) => - new Throws(isFileSystemException(msg)); - -void expectFileSystemException(Pattern msg, void callback()) { - expect(callback, throwsFileSystemException(msg)); -} - -class _FileSystemException extends Matcher { - final Pattern msg; - const _FileSystemException(this.msg); - - Description describe(Description description) => - description.add('FileSystemException with msg "$msg"'); - - bool matches(item, Map matchState) { - if (item is FileSystemException) { - return (msg == null || - item.message.contains(msg) || - (item.osError?.message?.contains(msg) ?? false)); - } - return false; - } -} - -// TODO: make this provide a better description of errors. -class _HasPath extends Matcher { - final Matcher _path; - const _HasPath(this._path); - Description describe(Description description) => _path.describe(description); - bool matches(item, Map matchState) => _path.matches(item.path, matchState); -} - -class _IsFile extends TypeMatcher { - const _IsFile() : super("File"); - bool matches(item, Map matchState) => item is File; -} - -class _IsDirectory extends TypeMatcher { - const _IsDirectory() : super("Directory"); - bool matches(item, Map matchState) => item is Directory; -} - -class _IsLink extends TypeMatcher { - const _IsLink() : super("Link"); - bool matches(item, Map matchState) => item is Link; -} - -class _IsFileSystemEntity extends TypeMatcher { - const _IsFileSystemEntity() : super("FileSystemEntity"); - bool matches(item, Map matchState) => item is FileSystemEntity; -} diff --git a/test/recording_test.dart b/test/recording_test.dart new file mode 100644 index 0000000000000..ec0e52b148d4d --- /dev/null +++ b/test/recording_test.dart @@ -0,0 +1,251 @@ +// 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:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:file/record_replay.dart'; +import 'package:file/testing.dart'; +import 'package:path/path.dart' as p; +import 'package:test/test.dart'; + +import 'common_tests.dart'; + +void main() { + group('RecordingFileSystem', () { + RecordingFileSystem fs; + MemoryFileSystem delegate; + Recording recording; + + setUp(() { + delegate = new MemoryFileSystem(); + fs = new RecordingFileSystem( + delegate: delegate, + destination: new MemoryFileSystem().directory('/tmp')..createSync(), + ); + recording = fs.recording; + }); + + runCommonTests( + () => fs, + skip: [ + 'File > open', // Not yet implemented in MemoryFileSystem + ], + ); + + group('recording', () { + test('supportsMultipleActions', () { + fs.directory('/foo').createSync(); + fs.file('/foo/bar').writeAsStringSync('BAR'); + var events = recording.events; + expect(events, hasLength(4)); + expect(events[0], invokesMethod('directory')); + expect(events[1], invokesMethod('createSync')); + expect(events[2], invokesMethod('file')); + expect(events[3], invokesMethod('writeAsStringSync')); + expect(events[0].result, events[1].object); + expect(events[2].result, events[3].object); + }); + + group('FileSystem', () { + test('directory', () { + fs.directory('/foo'); + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + invokesMethod('directory') + .on(fs) + .withPositionalArguments(['/foo']).withResult(isDirectory)); + }); + + test('file', () { + fs.file('/foo'); + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + invokesMethod('file') + .on(fs) + .withPositionalArguments(['/foo']).withResult(isFile)); + }); + + test('link', () { + fs.link('/foo'); + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + invokesMethod('link') + .on(fs) + .withPositionalArguments(['/foo']).withResult(isLink)); + }); + + test('path', () { + fs.path; + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + getsProperty('path') + .on(fs) + .withResult(const isInstanceOf())); + }); + + test('systemTempDirectory', () { + fs.systemTempDirectory; + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + getsProperty('systemTempDirectory') + .on(fs) + .withResult(isDirectory)); + }); + + group('currentDirectory', () { + test('get', () { + fs.currentDirectory; + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + getsProperty('currentDirectory') + .on(fs) + .withResult(isDirectory)); + }); + + test('setToString', () { + delegate.directory('/foo').createSync(); + fs.currentDirectory = '/foo'; + var events = recording.events; + expect(events, hasLength(1)); + expect(events[0], + setsProperty('currentDirectory').on(fs).toValue('/foo')); + }); + + test('setToRecordingDirectory', () { + delegate.directory('/foo').createSync(); + fs.currentDirectory = fs.directory('/foo'); + var events = recording.events; + expect(events.length, greaterThanOrEqualTo(2)); + expect(events[0], invokesMethod().withResult(isDirectory)); + Directory directory = events[0].result; + expect( + events, + contains(setsProperty('currentDirectory') + .on(fs) + .toValue(directory))); + }); + + test('setToNonRecordingDirectory', () { + Directory dir = delegate.directory('/foo'); + dir.createSync(); + fs.currentDirectory = dir; + var events = recording.events; + expect(events, hasLength(1)); + expect(events[0], + setsProperty('currentDirectory').on(fs).toValue(isDirectory)); + }); + }); + + test('stat', () async { + delegate.file('/foo').createSync(); + await fs.stat('/foo'); + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + invokesMethod('stat') + .on(fs) + .withPositionalArguments(['/foo']).withResult(isFileStat), + ); + }); + + test('statSync', () { + delegate.file('/foo').createSync(); + fs.statSync('/foo'); + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + invokesMethod('statSync') + .on(fs) + .withPositionalArguments(['/foo']).withResult(isFileStat), + ); + }); + + test('identical', () async { + delegate.file('/foo').createSync(); + delegate.file('/bar').createSync(); + await fs.identical('/foo', '/bar'); + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + invokesMethod('identical').on(fs).withPositionalArguments( + ['/foo', '/bar']).withResult(isFalse)); + }); + + test('identicalSync', () { + delegate.file('/foo').createSync(); + delegate.file('/bar').createSync(); + fs.identicalSync('/foo', '/bar'); + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + invokesMethod('identicalSync').on(fs).withPositionalArguments( + ['/foo', '/bar']).withResult(isFalse)); + }); + + test('isWatchSupported', () { + fs.isWatchSupported; + var events = recording.events; + expect(events, hasLength(1)); + expect(events[0], + getsProperty('isWatchSupported').on(fs).withResult(isFalse)); + }); + + test('type', () async { + delegate.file('/foo').createSync(); + await fs.type('/foo'); + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + invokesMethod('type').on(fs).withPositionalArguments( + ['/foo']).withResult(FileSystemEntityType.FILE)); + }); + + test('typeSync', () { + delegate.file('/foo').createSync(); + fs.typeSync('/foo'); + var events = recording.events; + expect(events, hasLength(1)); + expect( + events[0], + invokesMethod('typeSync').on(fs).withPositionalArguments( + ['/foo']).withResult(FileSystemEntityType.FILE)); + }); + }); + + group('Directory', () { + test('create', () async { + await fs.directory('/foo').create(); + expect( + recording.events, + contains(invokesMethod('create') + .on(isDirectory) + .withResult(isDirectory))); + }); + + test('createSync', () {}); + }); + + group('File', () {}); + + group('Link', () {}); + }); + }); +}