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>>();