Partial implementation of ReplayFileSystem (#28)
This implements all the plumbing for `ReplayFileSystem` except
for the resurrectors for `FileSystemEntity`, `File`, `Directory`,
and `Link`. Those will land in a follow-on PR.
Part of #11
diff --git a/lib/record_replay.dart b/lib/record_replay.dart
index 383c201..189ae18 100644
--- a/lib/record_replay.dart
+++ b/lib/record_replay.dart
@@ -2,8 +2,11 @@
// 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/errors.dart';
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;
+export 'src/backends/record_replay/replay_file_system.dart'
+ show ReplayFileSystem;
diff --git a/lib/src/backends/record_replay/common.dart b/lib/src/backends/record_replay/common.dart
index d41abeb..4690148 100644
--- a/lib/src/backends/record_replay/common.dart
+++ b/lib/src/backends/record_replay/common.dart
@@ -51,9 +51,9 @@
/// named arguments that were passed to the method.
const String kManifestNamedArgumentsKey = 'namedArguments';
-/// The key in a serialized [InvocationEvent] map that is used to store whether
-/// the invocation has been replayed already.
-const String kManifestReplayedKey = 'replayed';
+/// The key in a serialized [InvocationEvent] map that is used to store the
+/// order in which the invocation has been replayed (if it has been replayed).
+const String kManifestOrdinalKey = 'ordinal';
/// The serialized [kManifestTypeKey] for property retrievals.
const String kGetType = 'get';
diff --git a/lib/src/backends/record_replay/encoding.dart b/lib/src/backends/record_replay/encoding.dart
index 086e959..4e2146e 100644
--- a/lib/src/backends/record_replay/encoding.dart
+++ b/lib/src/backends/record_replay/encoding.dart
@@ -15,6 +15,7 @@
import 'recording_io_sink.dart';
import 'recording_link.dart';
import 'recording_random_access_file.dart';
+import 'replay_proxy_mixin.dart';
import 'result_reference.dart';
/// Encodes an object into a JSON-ready representation.
@@ -48,6 +49,7 @@
const TypeMatcher<RecordingLink>(): _encodeFileSystemEntity,
const TypeMatcher<RecordingIOSink>(): _encodeIOSink,
const TypeMatcher<RecordingRandomAccessFile>(): _encodeRandomAccessFile,
+ const TypeMatcher<ReplayProxyMixin>(): _encodeReplayEntity,
const TypeMatcher<Encoding>(): _encodeEncoding,
const TypeMatcher<FileMode>(): _encodeFileMode,
const TypeMatcher<FileStat>(): _encodeFileStat,
@@ -132,6 +134,8 @@
return '${raf.runtimeType}@${raf.uid}';
}
+String _encodeReplayEntity(ReplayProxyMixin entity) => entity.identifier;
+
String _encodeEncoding(Encoding encoding) => encoding.name;
String _encodeFileMode(FileMode fileMode) {
diff --git a/lib/src/backends/record_replay/errors.dart b/lib/src/backends/record_replay/errors.dart
new file mode 100644
index 0000000..c7892d3
--- /dev/null
+++ b/lib/src/backends/record_replay/errors.dart
@@ -0,0 +1,44 @@
+// 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 'common.dart';
+import 'encoding.dart';
+
+/// Error thrown during replay when there is no matching invocation in the
+/// recording.
+class NoMatchingInvocationError extends Error {
+ /// The invocation that was unable to be replayed.
+ final Invocation invocation;
+
+ /// Creates a new `NoMatchingInvocationError` caused by the failure to replay
+ /// the specified [invocation].
+ NoMatchingInvocationError(this.invocation);
+
+ @override
+ String toString() {
+ StringBuffer buf = new StringBuffer();
+ buf.write('No matching invocation found: ');
+ buf.write(getSymbolName(invocation.memberName));
+ if (invocation.isMethod) {
+ buf.write('(');
+ int i = 0;
+ for (dynamic arg in invocation.positionalArguments) {
+ buf.write(Error.safeToString(encode(arg)));
+ if (i++ > 0) {
+ buf.write(', ');
+ }
+ }
+ invocation.namedArguments.forEach((Symbol name, dynamic value) {
+ if (i++ > 0) {
+ buf.write(', ');
+ }
+ buf.write('${getSymbolName(name)}: ${encode(value)}');
+ });
+ buf.write(')');
+ } else if (invocation.isSetter) {
+ buf.write(Error.safeToString(encode(invocation.positionalArguments[0])));
+ }
+ return buf.toString();
+ }
+}
diff --git a/lib/src/backends/record_replay/proxy.dart b/lib/src/backends/record_replay/proxy.dart
new file mode 100644
index 0000000..2266baa
--- /dev/null
+++ b/lib/src/backends/record_replay/proxy.dart
@@ -0,0 +1,70 @@
+// 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.
+
+/// An object that uses [noSuchMethod] to dynamically handle invocations
+/// (property getters, property setters, and method invocations).
+abstract class ProxyObject {}
+
+/// A function reference that, when invoked, will forward the invocation back
+/// to a [ProxyObject].
+///
+/// This is used when a caller accesses a method on a [ProxyObject] via the
+/// method's getter. In these cases, the caller will receive a [MethodProxy]
+/// that allows delayed invocation of the method.
+class MethodProxy extends Object implements Function {
+ /// The object on which the method was retrieved.
+ ///
+ /// This will be the target object when this method proxy is invoked.
+ final ProxyObject _proxyObject;
+
+ /// The name of the method in question.
+ final Symbol _methodName;
+
+ /// Creates a new [MethodProxy] that, when invoked, will invoke the method
+ /// identified by [methodName] on the specified target [object].
+ MethodProxy(ProxyObject object, Symbol methodName)
+ : _proxyObject = object,
+ _methodName = 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 proxy object. We have to synthesize an invocation, since
+ // our current `invocation` object represents the invocation of `call()`.
+ return _proxyObject.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<dynamic> positionalArguments;
+
+ @override
+ final Map<Symbol, dynamic> 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.dart b/lib/src/backends/record_replay/recording.dart
index 0a6ef97..3932905 100644
--- a/lib/src/backends/record_replay/recording.dart
+++ b/lib/src/backends/record_replay/recording.dart
@@ -7,6 +7,7 @@
import 'package:file/file.dart';
import 'events.dart';
+import 'replay_file_system.dart';
/// A recording of a series of invocations on a [FileSystem] and its associated
/// objects (`File`, `Directory`, `IOSink`, etc).
@@ -20,10 +21,9 @@
}
/// An [Recording] in progress that can be serialized to disk for later use
-/// in `ReplayFileSystem`.
+/// in [ReplayFileSystem].
///
/// Live recordings exist only in memory until [flush] is called.
-// TODO(tvolkert): Link to ReplayFileSystem in docs once it's implemented
abstract class LiveRecording extends Recording {
/// The directory in which recording files will be stored.
///
diff --git a/lib/src/backends/record_replay/recording_file_system.dart b/lib/src/backends/record_replay/recording_file_system.dart
index aad9c45..7e33e14 100644
--- a/lib/src/backends/record_replay/recording_file_system.dart
+++ b/lib/src/backends/record_replay/recording_file_system.dart
@@ -11,12 +11,13 @@
import 'recording_file.dart';
import 'recording_link.dart';
import 'recording_proxy_mixin.dart';
+import 'replay_file_system.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],
+/// [ReplayFileSystem]. All activity in the [File], [Directory], [Link],
/// [IOSink], and [RandomAccessFile] instances returned from this API will also
/// be recorded.
///
@@ -35,8 +36,7 @@
/// order.
///
/// See also:
-/// - `ReplayFileSystem`
-// TODO(tvolkert): Link to ReplayFileSystem in docs once it's implemented
+/// - [ReplayFileSystem]
abstract class RecordingFileSystem extends FileSystem {
/// Creates a new `RecordingFileSystem`.
///
diff --git a/lib/src/backends/record_replay/recording_proxy_mixin.dart b/lib/src/backends/record_replay/recording_proxy_mixin.dart
index 36110aa..a10815b 100644
--- a/lib/src/backends/record_replay/recording_proxy_mixin.dart
+++ b/lib/src/backends/record_replay/recording_proxy_mixin.dart
@@ -8,6 +8,7 @@
import 'events.dart';
import 'mutable_recording.dart';
+import 'proxy.dart';
import 'result_reference.dart';
/// Mixin that enables recording of property accesses, property mutations, and
@@ -34,7 +35,7 @@
/// int sampleProperty;
/// }
///
-/// class RecordingFoo extends Object with _RecordingProxyMixin implements Foo {
+/// class RecordingFoo extends Object with RecordingProxyMixin implements Foo {
/// final Foo delegate;
///
/// RecordingFoo(this.delegate) {
@@ -59,7 +60,7 @@
/// 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 {
+abstract class RecordingProxyMixin implements ProxyObject {
/// Maps method names to delegate functions.
///
/// Invocations of methods listed in this map will be recorded after
@@ -107,7 +108,7 @@
// 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)
+ ? new MethodProxy(this, name)
: super.noSuchMethod(invocation);
}
@@ -144,55 +145,3 @@
return result;
}
}
-
-/// 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<dynamic> positionalArguments;
-
- @override
- final Map<Symbol, dynamic> namedArguments;
-
- @override
- final bool isMethod = true;
-
- @override
- final bool isGetter = false;
-
- @override
- final bool isSetter = false;
-}
diff --git a/lib/src/backends/record_replay/replay_directory.dart b/lib/src/backends/record_replay/replay_directory.dart
new file mode 100644
index 0000000..928db67
--- /dev/null
+++ b/lib/src/backends/record_replay/replay_directory.dart
@@ -0,0 +1,27 @@
+// 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 'replay_file_system.dart';
+import 'replay_file_system_entity.dart';
+import 'resurrectors.dart';
+
+/// [Directory] implementation that replays all invocation activity from a
+/// prior recording.
+class ReplayDirectory extends ReplayFileSystemEntity implements Directory {
+ /// Creates a new `ReplayDirectory`.
+ ReplayDirectory(ReplayFileSystemImpl fileSystem, String identifier)
+ : super(fileSystem, identifier) {
+ // TODO(tvolkert): fill in resurrectors
+ methods.addAll(<Symbol, Resurrector>{
+ #create: null,
+ #createSync: null,
+ #createTemp: null,
+ #createTempSync: null,
+ #list: null,
+ #listSync: null,
+ });
+ }
+}
diff --git a/lib/src/backends/record_replay/replay_file.dart b/lib/src/backends/record_replay/replay_file.dart
new file mode 100644
index 0000000..5a3eb63
--- /dev/null
+++ b/lib/src/backends/record_replay/replay_file.dart
@@ -0,0 +1,43 @@
+// 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 'replay_file_system.dart';
+import 'replay_file_system_entity.dart';
+import 'resurrectors.dart';
+
+/// [File] implementation that replays all invocation activity from a prior
+/// recording.
+class ReplayFile extends ReplayFileSystemEntity implements File {
+ /// Creates a new `ReplayFile`.
+ ReplayFile(ReplayFileSystemImpl fileSystem, String identifier)
+ : super(fileSystem, identifier) {
+ // TODO(tvolkert): fill in resurrectors
+ methods.addAll(<Symbol, Resurrector>{
+ #create: null,
+ #createSync: null,
+ #copy: null,
+ #copySync: null,
+ #length: null,
+ #lengthSync: null,
+ #lastModified: null,
+ #lastModifiedSync: null,
+ #open: null,
+ #openSync: null,
+ #openRead: null,
+ #openWrite: null,
+ #readAsBytes: null,
+ #readAsBytesSync: null,
+ #readAsString: null,
+ #readAsStringSync: null,
+ #readAsLines: null,
+ #readAsLinesSync: null,
+ #writeAsBytes: null,
+ #writeAsBytesSync: null,
+ #writeAsString: null,
+ #writeAsStringSync: null,
+ });
+ }
+}
diff --git a/lib/src/backends/record_replay/replay_file_stat.dart b/lib/src/backends/record_replay/replay_file_stat.dart
new file mode 100644
index 0000000..c0ab022
--- /dev/null
+++ b/lib/src/backends/record_replay/replay_file_stat.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 'package:file/file.dart';
+
+import 'resurrectors.dart';
+
+/// [FileStat] implementation that derives its properties from a recorded
+/// invocation event.
+class ReplayFileStat implements FileStat {
+ final Map<String, dynamic> _data;
+
+ /// Creates a new `ReplayFileStat` that will derive its properties from the
+ /// specified [data].
+ ReplayFileStat(Map<String, dynamic> data) : _data = data;
+
+ @override
+ DateTime get changed => resurrectDateTime(_data['changed']);
+
+ @override
+ DateTime get modified => resurrectDateTime(_data['modified']);
+
+ @override
+ DateTime get accessed => resurrectDateTime(_data['accessed']);
+
+ @override
+ FileSystemEntityType get type => resurrectFileSystemEntityType(_data['type']);
+
+ @override
+ int get mode => _data['mode'];
+
+ @override
+ int get size => _data['size'];
+
+ @override
+ String modeString() => _data['modeString'];
+}
diff --git a/lib/src/backends/record_replay/replay_file_system.dart b/lib/src/backends/record_replay/replay_file_system.dart
new file mode 100644
index 0000000..468570f
--- /dev/null
+++ b/lib/src/backends/record_replay/replay_file_system.dart
@@ -0,0 +1,99 @@
+// Copyright (c) 2017, the Dart project authors. Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'dart:convert';
+
+import 'package:file/file.dart';
+import 'package:meta/meta.dart';
+
+import 'common.dart';
+import 'errors.dart';
+import 'recording_file_system.dart';
+import 'replay_proxy_mixin.dart';
+import 'resurrectors.dart';
+
+/// A file system that replays invocations from a prior recording for use
+/// in tests.
+///
+/// This will replay all invocations (methods, property getters, and property
+/// setters) that occur on it, based on an opaque recording that was generated
+/// in [RecordingFileSystem]. All activity in the [File], [Directory], [Link],
+/// [IOSink], and [RandomAccessFile] instances returned from this API will also
+/// be replayed from the same recording.
+///
+/// Once an invocation has been replayed once, it is marked as such and will
+/// not be eligible for further replay. If an eligible invocation cannot be
+/// found that matches an incoming invocation, a [NoMatchingInvocationError]
+/// will be thrown.
+///
+/// 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:
+/// - [RecordingFileSystem]
+abstract class ReplayFileSystem extends FileSystem {
+ /// Creates a new `ReplayFileSystem`.
+ ///
+ /// Recording data will be loaded from the specified [recording] location.
+ /// This location must have been created by [RecordingFileSystem], or an
+ /// [ArgumentError] will be thrown.
+ factory ReplayFileSystem({
+ @required Directory recording,
+ }) {
+ String dirname = recording.path;
+ String path = recording.fileSystem.path.join(dirname, kManifestName);
+ File manifestFile = recording.fileSystem.file(path);
+ if (!manifestFile.existsSync()) {
+ throw new ArgumentError('Not a valid recording directory: $dirname');
+ }
+ List<Map<String, dynamic>> manifest =
+ new JsonDecoder().convert(manifestFile.readAsStringSync());
+ return new ReplayFileSystemImpl(manifest);
+ }
+}
+
+/// Non-exported implementation class for `ReplayFileSystem`.
+class ReplayFileSystemImpl extends FileSystem
+ with ReplayProxyMixin
+ implements ReplayFileSystem {
+ /// Creates a new `ReplayFileSystemImpl`.
+ ReplayFileSystemImpl(this.manifest) {
+ methods.addAll(<Symbol, Resurrector>{
+ #directory: resurrectDirectory(this),
+ #file: resurrectFile(this),
+ #link: resurrectLink(this),
+ #stat: resurrectFuture(resurrectFileStat),
+ #statSync: resurrectFileStat,
+ #identical: resurrectFuture(resurrectPassthrough),
+ #identicalSync: resurrectPassthrough,
+ #type: resurrectFuture(resurrectFileSystemEntityType),
+ #typeSync: resurrectFileSystemEntityType,
+ });
+
+ properties.addAll(<Symbol, Resurrector>{
+ #path: resurrectPathContext,
+ #systemTempDirectory: resurrectDirectory(this),
+ #currentDirectory: resurrectDirectory(this),
+ const Symbol('currentDirectory='): resurrectPassthrough,
+ #isWatchSupported: resurrectPassthrough,
+ });
+ }
+
+ @override
+ String get identifier => kFileSystemEncodedValue;
+
+ @override
+ final List<Map<String, dynamic>> manifest;
+}
diff --git a/lib/src/backends/record_replay/replay_file_system_entity.dart b/lib/src/backends/record_replay/replay_file_system_entity.dart
new file mode 100644
index 0000000..d74ede3
--- /dev/null
+++ b/lib/src/backends/record_replay/replay_file_system_entity.dart
@@ -0,0 +1,53 @@
+// 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 'replay_file_system.dart';
+import 'replay_proxy_mixin.dart';
+import 'resurrectors.dart';
+
+/// [FileSystemEntity] implementation that replays all invocation activity
+/// from a prior recording.
+abstract class ReplayFileSystemEntity extends Object
+ with ReplayProxyMixin
+ implements FileSystemEntity {
+ /// Creates a new `ReplayFileSystemEntity`.
+ ReplayFileSystemEntity(this.fileSystem, this.identifier) {
+ // TODO(tvolkert): fill in resurrectors
+ methods.addAll(<Symbol, Resurrector>{
+ #exists: null,
+ #existsSync: null,
+ #rename: null,
+ #renameSync: null,
+ #resolveSymbolicLinks: null,
+ #resolveSymbolicLinksSync: null,
+ #stat: null,
+ #statSync: null,
+ #delete: null,
+ #deleteSync: null,
+ #watch: null,
+ });
+
+ // TODO(tvolkert): fill in resurrectors
+ properties.addAll(<Symbol, Resurrector>{
+ #path: resurrectPassthrough,
+ #uri: null,
+ #isAbsolute: null,
+ #absolute: null,
+ #parent: null,
+ #basename: null,
+ #dirname: null,
+ });
+ }
+
+ @override
+ final ReplayFileSystemImpl fileSystem;
+
+ @override
+ final String identifier;
+
+ @override
+ List<Map<String, dynamic>> get manifest => fileSystem.manifest;
+}
diff --git a/lib/src/backends/record_replay/replay_link.dart b/lib/src/backends/record_replay/replay_link.dart
new file mode 100644
index 0000000..aed0b35
--- /dev/null
+++ b/lib/src/backends/record_replay/replay_link.dart
@@ -0,0 +1,27 @@
+// 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 'replay_file_system.dart';
+import 'replay_file_system_entity.dart';
+import 'resurrectors.dart';
+
+/// [Link] implementation that replays all invocation activity from a prior
+/// recording.
+class ReplayLink extends ReplayFileSystemEntity implements Link {
+ /// Creates a new `ReplayLink`.
+ ReplayLink(ReplayFileSystemImpl fileSystem, String identifier)
+ : super(fileSystem, identifier) {
+ // TODO(tvolkert): fill in resurrectors
+ methods.addAll(<Symbol, Resurrector>{
+ #create: null,
+ #createSync: null,
+ #update: null,
+ #updateSync: null,
+ #target: null,
+ #targetSync: null,
+ });
+ }
+}
diff --git a/lib/src/backends/record_replay/replay_proxy_mixin.dart b/lib/src/backends/record_replay/replay_proxy_mixin.dart
new file mode 100644
index 0000000..f4d6310
--- /dev/null
+++ b/lib/src/backends/record_replay/replay_proxy_mixin.dart
@@ -0,0 +1,167 @@
+// 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:meta/meta.dart';
+
+import 'common.dart';
+import 'encoding.dart';
+import 'errors.dart';
+import 'proxy.dart';
+import 'resurrectors.dart';
+
+typedef bool _InvocationMatcher(Map<String, dynamic> entry);
+
+/// Used to record the order in which invocations were replayed.
+///
+/// Tests can later check expectations about the order in which invocations
+/// were replayed vis-a-vis the order in which they were recorded.
+int _nextOrdinal = 0;
+
+/// Mixin that enables replaying of property accesses, property mutations, and
+/// method invocations from a prior recording.
+///
+/// This class uses `noSuchMethod` to replay a well-defined set of invocations
+/// (including property gets and sets) on an object. Subclasses wire this up by
+/// doing the following:
+///
+/// - Populate the list of method invocations to replay in the [methods] map.
+/// - Populate the list of property invocations to replay 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
+/// replay, as doing so will circumvent the machinery that this mixin uses
+/// (`noSuchMethod`) to replay invocations.
+///
+/// **Example use**:
+///
+/// abstract class Foo {
+/// ComplexObject sampleMethod();
+///
+/// Foo sampleParent;
+/// }
+///
+/// class ReplayFoo extends Object with ReplayProxyMixin implements Foo {
+/// final List<Map<String, dynamic>> manifest;
+/// final String identifier;
+///
+/// ReplayFoo(this.manifest, this.identifier) {
+/// methods.addAll(<Symbol, Resurrector>{
+/// #sampleMethod: resurrectComplexObject,
+/// });
+///
+/// properties.addAll(<Symbol, Resurrector>{
+/// #sampleParent: resurrectFoo,
+/// const Symbol('sampleParent='): resurrectPassthrough,
+/// });
+/// }
+/// }
+abstract class ReplayProxyMixin implements ProxyObject {
+ /// Maps method names to [Resurrector] functions.
+ ///
+ /// Invocations of methods listed in this map will be replayed by looking for
+ /// matching invocations in the [manifest] and resurrecting the invocation
+ /// return value using the [Resurrector] found in this map.
+ @protected
+ final Map<Symbol, Resurrector> methods = <Symbol, Resurrector>{};
+
+ /// Maps property getter and setter names to [Resurrector] functions.
+ ///
+ /// Access and mutation of properties listed in this map will be replayed
+ /// by looking for matching property accesses in the [manifest] and
+ /// resurrecting the invocation return value using the [Resurrector] found
+ /// in this map.
+ ///
+ /// 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<Symbol, Resurrector> properties = <Symbol, Resurrector>{};
+
+ /// The unique identifier of this replay object.
+ ///
+ /// When replay-aware objects are serialized in a recording, they are done so
+ /// using only a unique String identifier. When the objects are resurrected
+ /// for the purpose of replay, their identifiers are used to match incoming
+ /// invocations against recorded invocations in the [manifest] (only
+ /// invocations whose target object matches the identifier are considered
+ /// possible matches).
+ String get identifier;
+
+ /// The manifest of recorded invocation events.
+ ///
+ /// When invocations are received on this object, we will attempt find a
+ /// matching invocation in this manifest to perform the replay. If no such
+ /// invocation is found (or if it has already been replayed), the caller will
+ /// receive a [NoMatchingInvocationError].
+ ///
+ /// This manifest exists as `MANIFEST.txt` in a recording directory.
+ List<Map<String, dynamic>> get manifest;
+
+ @override
+ dynamic noSuchMethod(Invocation invocation) {
+ Symbol name = invocation.memberName;
+ Resurrector resurrector =
+ invocation.isAccessor ? properties[name] : methods[name];
+
+ if (resurrector == null) {
+ // No resurrector 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 replay the desired invocation.
+ return invocation.isGetter && methods[name] != null
+ ? new MethodProxy(this, name)
+ : super.noSuchMethod(invocation);
+ }
+
+ Map<String, dynamic> entry = _nextEvent(invocation);
+ if (entry == null) {
+ throw new NoMatchingInvocationError(invocation);
+ }
+ entry[kManifestOrdinalKey] = _nextOrdinal++;
+
+ return resurrector(entry[kManifestResultKey]);
+ }
+
+ /// Finds the next available invocation event in the [manifest] that matches
+ /// the specified [invocation].
+ Map<String, dynamic> _nextEvent(Invocation invocation) {
+ _InvocationMatcher matches = _getMatcher(invocation);
+ return manifest.firstWhere((Map<String, dynamic> entry) {
+ return entry[kManifestOrdinalKey] == null && matches(entry);
+ }, orElse: () => null);
+ }
+
+ _InvocationMatcher _getMatcher(Invocation invocation) {
+ String name = getSymbolName(invocation.memberName);
+ List<dynamic> args = encode(invocation.positionalArguments);
+ Map<String, dynamic> namedArgs = encode(invocation.namedArguments);
+
+ if (invocation.isGetter) {
+ return (Map<String, dynamic> entry) =>
+ entry[kManifestTypeKey] == kGetType &&
+ entry[kManifestPropertyKey] == name &&
+ entry[kManifestObjectKey] == identifier;
+ } else if (invocation.isSetter) {
+ return (Map<String, dynamic> entry) =>
+ entry[kManifestTypeKey] == kSetType &&
+ entry[kManifestPropertyKey] == name &&
+ deeplyEqual(entry[kManifestValueKey], args[0]) &&
+ entry[kManifestObjectKey] == identifier;
+ } else {
+ return (Map<String, dynamic> entry) {
+ return entry[kManifestTypeKey] == kInvokeType &&
+ entry[kManifestMethodKey] == name &&
+ deeplyEqual(entry[kManifestPositionalArgumentsKey], args) &&
+ deeplyEqual(_asNamedArgsType(entry[kManifestNamedArgumentsKey]),
+ namedArgs) &&
+ entry[kManifestObjectKey] == identifier;
+ };
+ }
+ }
+
+ static Map<String, dynamic> _asNamedArgsType(Map<dynamic, dynamic> map) {
+ return new Map<String, dynamic>.from(map);
+ }
+}
diff --git a/lib/src/backends/record_replay/resurrectors.dart b/lib/src/backends/record_replay/resurrectors.dart
new file mode 100644
index 0000000..a221cc7
--- /dev/null
+++ b/lib/src/backends/record_replay/resurrectors.dart
@@ -0,0 +1,85 @@
+// 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:path/path.dart' as path;
+
+import 'replay_directory.dart';
+import 'replay_file.dart';
+import 'replay_file_stat.dart';
+import 'replay_file_system.dart';
+import 'replay_link.dart';
+
+/// Resurrects an invocation result (return value) from the specified
+/// serialized [data].
+typedef Object Resurrector(dynamic data);
+
+/// Returns a [Resurrector] that will wrap the return value of the specified
+/// [delegate] in a [Future].
+Resurrector resurrectFuture(Resurrector delegate) {
+ return (dynamic serializedResult) async => delegate(serializedResult);
+}
+
+/// Returns a [Resurrector] that will resurrect a [ReplayDirectory] that is
+/// tied to the specified [fileSystem].
+Resurrector resurrectDirectory(ReplayFileSystemImpl fileSystem) {
+ return (String identifier) {
+ return new ReplayDirectory(fileSystem, identifier);
+ };
+}
+
+/// Returns a [Resurrector] that will resurrect a [ReplayFile] that is tied to
+/// the specified [fileSystem].
+Resurrector resurrectFile(ReplayFileSystemImpl fileSystem) {
+ return (String identifier) {
+ return new ReplayFile(fileSystem, identifier);
+ };
+}
+
+/// Returns a [Resurrector] that will resurrect a [ReplayLink] that is tied to
+/// the specified [fileSystem].
+Resurrector resurrectLink(ReplayFileSystemImpl fileSystem) {
+ return (String identifier) {
+ return new ReplayLink(fileSystem, identifier);
+ };
+}
+
+/// Resurrects a [FileStat] from the specified serialized [data].
+FileStat resurrectFileStat(Map<String, dynamic> data) {
+ return new ReplayFileStat(data);
+}
+
+/// Resurrects a [DateTime] from the specified [milliseconds] since the epoch.
+DateTime resurrectDateTime(int milliseconds) {
+ return new DateTime.fromMillisecondsSinceEpoch(milliseconds);
+}
+
+/// Resurrects a [FileSystemEntityType] from the specified string
+/// representation.
+FileSystemEntityType resurrectFileSystemEntityType(String type) {
+ return const <String, FileSystemEntityType>{
+ 'FILE': FileSystemEntityType.FILE,
+ 'DIRECTORY': FileSystemEntityType.DIRECTORY,
+ 'LINK': FileSystemEntityType.LINK,
+ 'NOT_FOUND': FileSystemEntityType.NOT_FOUND,
+ }[type];
+}
+
+/// Resurrects a value whose serialized representation is the same the real
+/// value.
+dynamic resurrectPassthrough(dynamic value) => value;
+
+/// Resurrects a [path.Context] from the specified serialized [data]
+path.Context resurrectPathContext(Map<String, String> data) {
+ return new path.Context(
+ style: <String, path.Style>{
+ 'posix': path.Style.posix,
+ 'windows': path.Style.windows,
+ 'url': path.Style.url,
+ }[data['style']],
+ current: data['cwd'],
+ );
+}
diff --git a/lib/src/testing/record_replay_matchers.dart b/lib/src/testing/record_replay_matchers.dart
index c205139..c3cfbce 100644
--- a/lib/src/testing/record_replay_matchers.dart
+++ b/lib/src/testing/record_replay_matchers.dart
@@ -3,7 +3,7 @@
// 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:file/src/backends/record_replay/common.dart' hide TypeMatcher;
import 'package:test/test.dart';
const Map<Type, String> _kTypeDescriptions = const <Type, String>{
@@ -48,6 +48,15 @@
/// scope of the match (e.g. by property value, target object, etc).
PropertySet setsProperty([dynamic name]) => new PropertySet._(name);
+/// A matcher that successfully matches against an instance of
+/// [NoMatchingInvocationError].
+const Matcher isNoMatchingInvocationError = const _NoMatchingInvocationError();
+
+/// A matcher that successfully matches against a future or function
+/// that throws a [NoMatchingInvocationError].
+const Matcher throwsNoMatchingInvocationError =
+ const Throws(isNoMatchingInvocationError);
+
/// Base class for matchers that match against generic [InvocationEvent]
/// instances.
abstract class RecordedInvocation<T extends RecordedInvocation<T>>
@@ -574,3 +583,11 @@
return _matcher.describe(description);
}
}
+
+class _NoMatchingInvocationError extends TypeMatcher {
+ const _NoMatchingInvocationError() : super("NoMatchingInvocationError");
+
+ @override
+ bool matches(dynamic item, Map<dynamic, dynamic> matchState) =>
+ item is NoMatchingInvocationError;
+}
diff --git a/test/replay_test.dart b/test/replay_test.dart
new file mode 100644
index 0000000..517c553
--- /dev/null
+++ b/test/replay_test.dart
@@ -0,0 +1,190 @@
+// 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/memory.dart';
+import 'package:file/record_replay.dart';
+import 'package:file/testing.dart';
+import 'package:path/path.dart' as path;
+import 'package:test/test.dart';
+
+void main() {
+ group('Replay', () {
+ RecordingFileSystem recordingFileSystem;
+ MemoryFileSystem memoryFileSystem;
+ LiveRecording recording;
+
+ setUp(() {
+ memoryFileSystem = new MemoryFileSystem();
+ recordingFileSystem = new RecordingFileSystem(
+ delegate: memoryFileSystem,
+ destination: new MemoryFileSystem().directory('/tmp')..createSync(),
+ );
+ recording = recordingFileSystem.recording;
+ });
+
+ /// Creates a new [ReplayFileSystem] that will replay a recording of the
+ /// events that have been recorded within [recordingFileSystem] thus far.
+ Future<ReplayFileSystem> replay() async {
+ await recording.flush();
+ return new ReplayFileSystem(recording: recording.destination);
+ }
+
+ group('ReplayFileSystem', () {
+ test('directory', () async {
+ recordingFileSystem.directory('/foo');
+ ReplayFileSystem fs = await replay();
+ Directory dir = fs.directory('/foo');
+ expect(dir, isDirectory);
+ expect(() => dir.path, throwsNoMatchingInvocationError);
+ expect(() => fs.directory('/foo'), throwsNoMatchingInvocationError);
+ });
+
+ test('file', () async {
+ recordingFileSystem.file('/foo');
+ ReplayFileSystem fs = await replay();
+ expect(fs.file('/foo'), isFile);
+ expect(() => fs.file('/foo'), throwsNoMatchingInvocationError);
+ });
+
+ test('link', () async {
+ recordingFileSystem.link('/foo');
+ ReplayFileSystem fs = await replay();
+ expect(fs.link('/foo'), isLink);
+ expect(() => fs.link('/foo'), throwsNoMatchingInvocationError);
+ });
+
+ test('path', () async {
+ path.Context context = recordingFileSystem.path;
+ ReplayFileSystem fs = await replay();
+ path.Context replayContext = fs.path;
+ expect(() => fs.path, throwsNoMatchingInvocationError);
+ expect(replayContext.style, context.style);
+ expect(replayContext.current, context.current);
+ });
+
+ test('systemTempDirectory', () async {
+ recordingFileSystem.systemTempDirectory;
+ ReplayFileSystem fs = await replay();
+ Directory dir = fs.systemTempDirectory;
+ expect(dir, isDirectory);
+ expect(() => dir.path, throwsNoMatchingInvocationError);
+ expect(() => fs.systemTempDirectory, throwsNoMatchingInvocationError);
+ });
+
+ group('currentDirectory', () {
+ test('get', () async {
+ recordingFileSystem.currentDirectory;
+ ReplayFileSystem fs = await replay();
+ Directory dir = fs.currentDirectory;
+ expect(dir, isDirectory);
+ expect(() => dir.path, throwsNoMatchingInvocationError);
+ expect(() => fs.currentDirectory, throwsNoMatchingInvocationError);
+ });
+
+ test('setToString', () async {
+ memoryFileSystem.directory('/foo').createSync();
+ recordingFileSystem.currentDirectory = '/foo';
+ ReplayFileSystem fs = await replay();
+ expect(() => fs.currentDirectory = '/bar',
+ throwsNoMatchingInvocationError);
+ fs.currentDirectory = '/foo';
+ expect(() => fs.currentDirectory = '/foo',
+ throwsNoMatchingInvocationError);
+ });
+
+ test('setToDirectory', () async {
+ Directory dir = await recordingFileSystem.directory('/foo').create();
+ recordingFileSystem.currentDirectory = dir;
+ ReplayFileSystem fs = await replay();
+ Directory replayDir = fs.directory('/foo');
+ expect(() => fs.directory('/foo'), throwsNoMatchingInvocationError);
+ fs.currentDirectory = replayDir;
+ expect(() => fs.currentDirectory = replayDir,
+ throwsNoMatchingInvocationError);
+ });
+ });
+
+ test('stat', () async {
+ FileStat stat = await recordingFileSystem.stat('/');
+ ReplayFileSystem fs = await replay();
+ Future<FileStat> replayStatFuture = fs.stat('/');
+ expect(() => fs.stat('/'), throwsNoMatchingInvocationError);
+ expect(replayStatFuture, isFuture);
+ FileStat replayStat = await replayStatFuture;
+ expect(replayStat.accessed, stat.accessed);
+ expect(replayStat.changed, stat.changed);
+ expect(replayStat.modified, stat.modified);
+ expect(replayStat.mode, stat.mode);
+ expect(replayStat.type, stat.type);
+ expect(replayStat.size, stat.size);
+ expect(replayStat.modeString(), stat.modeString());
+ });
+
+ test('statSync', () async {
+ FileStat stat = recordingFileSystem.statSync('/');
+ ReplayFileSystem fs = await replay();
+ FileStat replayStat = fs.statSync('/');
+ expect(() => fs.statSync('/'), throwsNoMatchingInvocationError);
+ expect(replayStat.accessed, stat.accessed);
+ expect(replayStat.changed, stat.changed);
+ expect(replayStat.modified, stat.modified);
+ expect(replayStat.mode, stat.mode);
+ expect(replayStat.type, stat.type);
+ expect(replayStat.size, stat.size);
+ expect(replayStat.modeString(), stat.modeString());
+ });
+
+ test('identical', () async {
+ memoryFileSystem.directory('/foo').createSync();
+ bool identical = await recordingFileSystem.identical('/', '/foo');
+ ReplayFileSystem fs = await replay();
+ Future<bool> replayIdenticalFuture = fs.identical('/', '/foo');
+ expect(
+ () => fs.identical('/', '/foo'), throwsNoMatchingInvocationError);
+ expect(replayIdenticalFuture, isFuture);
+ expect(await replayIdenticalFuture, identical);
+ });
+
+ test('identicalSync', () async {
+ memoryFileSystem.directory('/foo').createSync();
+ bool identical = recordingFileSystem.identicalSync('/', '/foo');
+ ReplayFileSystem fs = await replay();
+ bool replayIdentical = fs.identicalSync('/', '/foo');
+ expect(() => fs.identicalSync('/', '/foo'),
+ throwsNoMatchingInvocationError);
+ expect(replayIdentical, identical);
+ });
+
+ test('isWatchSupported', () async {
+ bool isWatchSupported = recordingFileSystem.isWatchSupported;
+ ReplayFileSystem fs = await replay();
+ expect(fs.isWatchSupported, isWatchSupported);
+ expect(() => fs.isWatchSupported, throwsNoMatchingInvocationError);
+ });
+
+ test('type', () async {
+ FileSystemEntityType type = await recordingFileSystem.type('/');
+ ReplayFileSystem fs = await replay();
+ Future<FileSystemEntityType> replayTypeFuture = fs.type('/');
+ expect(() => fs.type('/'), throwsNoMatchingInvocationError);
+ expect(replayTypeFuture, isFuture);
+ expect(await replayTypeFuture, type);
+ });
+
+ test('typeSync', () async {
+ FileSystemEntityType type = recordingFileSystem.typeSync('/');
+ ReplayFileSystem fs = await replay();
+ FileSystemEntityType replayType = fs.typeSync('/');
+ expect(() => fs.typeSync('/'), throwsNoMatchingInvocationError);
+ expect(replayType, type);
+ });
+ });
+ });
+}
+
+/// Successfully matches against an instance of [Future].
+const Matcher isFuture = const isInstanceOf<Future<dynamic>>();