Add ResultReference<T> to record_replay library. (#18)

This introduces a level of indirection that will allow
recording objects to differentiate between the invocation
result return value, the recorded result value, and the
serialized result value.

This is used to provide "special handling" of certain
invocation results (such as byte arrays) to make recordings
more human-readable & editable.

This design also yields more technical correctness over
the previous design. This is because we used to delay
recording an invocation whose result was a Future until
that future completed. Now, we record the invocation
immediately and late-record future results (with support
for awaiting those futures when serializing a recoridng).

Another step in #11
diff --git a/lib/src/backends/record_replay/common.dart b/lib/src/backends/record_replay/common.dart
index 53d3eb9..9ba9d1c 100644
--- a/lib/src/backends/record_replay/common.dart
+++ b/lib/src/backends/record_replay/common.dart
@@ -21,3 +21,13 @@
   int offset = str.indexOf('"') + 1;
   return str.substring(offset, str.indexOf('"', offset));
 }
+
+/// 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<T> {
+  /// Creates a type matcher for the given type parameter.
+  const TypeMatcher();
+
+  /// Returns `true` if the given object is of type `T`.
+  bool matches(dynamic object) => object is T;
+}
diff --git a/lib/src/backends/record_replay/encoding.dart b/lib/src/backends/record_replay/encoding.dart
index 74d075b..1f5037b 100644
--- a/lib/src/backends/record_replay/encoding.dart
+++ b/lib/src/backends/record_replay/encoding.dart
@@ -16,51 +16,43 @@
 import 'recording_io_sink.dart';
 import 'recording_link.dart';
 import 'recording_random_access_file.dart';
+import 'result_reference.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<T> {
-  /// 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<dynamic>, _Encoder> _kEncoders =
-    const <_TypeMatcher<dynamic>, _Encoder>{
-  const _TypeMatcher<num>(): _encodeRaw,
-  const _TypeMatcher<bool>(): _encodeRaw,
-  const _TypeMatcher<String>(): _encodeRaw,
-  const _TypeMatcher<Null>(): _encodeRaw,
-  const _TypeMatcher<List<dynamic>>(): _encodeRaw,
-  const _TypeMatcher<Map<dynamic, dynamic>>(): _encodeMap,
-  const _TypeMatcher<Iterable<dynamic>>(): _encodeIterable,
-  const _TypeMatcher<Symbol>(): getSymbolName,
-  const _TypeMatcher<DateTime>(): _encodeDateTime,
-  const _TypeMatcher<Uri>(): _encodeUri,
-  const _TypeMatcher<p.Context>(): _encodePathContext,
-  const _TypeMatcher<EventImpl<dynamic>>(): _encodeEvent,
-  const _TypeMatcher<FileSystem>(): _encodeFileSystem,
-  const _TypeMatcher<RecordingDirectory>(): _encodeFileSystemEntity,
-  const _TypeMatcher<RecordingFile>(): _encodeFileSystemEntity,
-  const _TypeMatcher<RecordingLink>(): _encodeFileSystemEntity,
-  const _TypeMatcher<RecordingIOSink>(): _encodeIOSink,
-  const _TypeMatcher<RecordingRandomAccessFile>(): _encodeRandomAccessFile,
-  const _TypeMatcher<Encoding>(): _encodeEncoding,
-  const _TypeMatcher<FileMode>(): _encodeFileMode,
-  const _TypeMatcher<FileStat>(): _encodeFileStat,
-  const _TypeMatcher<FileSystemEntityType>(): _encodeFileSystemEntityType,
-  const _TypeMatcher<FileSystemEvent>(): _encodeFileSystemEvent,
+const Map<TypeMatcher<dynamic>, _Encoder> _kEncoders =
+    const <TypeMatcher<dynamic>, _Encoder>{
+  const TypeMatcher<num>(): _encodeRaw,
+  const TypeMatcher<bool>(): _encodeRaw,
+  const TypeMatcher<String>(): _encodeRaw,
+  const TypeMatcher<Null>(): _encodeRaw,
+  const TypeMatcher<List<dynamic>>(): _encodeRaw,
+  const TypeMatcher<Map<dynamic, dynamic>>(): _encodeMap,
+  const TypeMatcher<Iterable<dynamic>>(): _encodeIterable,
+  const TypeMatcher<Symbol>(): getSymbolName,
+  const TypeMatcher<DateTime>(): _encodeDateTime,
+  const TypeMatcher<Uri>(): _encodeUri,
+  const TypeMatcher<p.Context>(): _encodePathContext,
+  const TypeMatcher<ResultReference<dynamic>>(): _encodeResultReference,
+  const TypeMatcher<LiveInvocationEvent<dynamic>>(): _encodeEvent,
+  const TypeMatcher<FileSystem>(): _encodeFileSystem,
+  const TypeMatcher<RecordingDirectory>(): _encodeFileSystemEntity,
+  const TypeMatcher<RecordingFile>(): _encodeFileSystemEntity,
+  const TypeMatcher<RecordingLink>(): _encodeFileSystemEntity,
+  const TypeMatcher<RecordingIOSink>(): _encodeIOSink,
+  const TypeMatcher<RecordingRandomAccessFile>(): _encodeRandomAccessFile,
+  const TypeMatcher<Encoding>(): _encodeEncoding,
+  const TypeMatcher<FileMode>(): _encodeFileMode,
+  const TypeMatcher<FileStat>(): _encodeFileStat,
+  const TypeMatcher<FileSystemEntityType>(): _encodeFileSystemEntityType,
+  const TypeMatcher<FileSystemEvent>(): _encodeFileSystemEvent,
 };
 
 /// Encodes [object] into a JSON-ready representation.
@@ -72,8 +64,8 @@
 ///   - [JsonEncoder.withIndent]
 dynamic encode(dynamic object) {
   _Encoder encoder = _encodeDefault;
-  for (_TypeMatcher<dynamic> matcher in _kEncoders.keys) {
-    if (matcher.check(object)) {
+  for (TypeMatcher<dynamic> matcher in _kEncoders.keys) {
+    if (matcher.matches(object)) {
       encoder = _kEncoders[matcher];
       break;
     }
@@ -114,7 +106,11 @@
       'cwd': context.current,
     };
 
-Map<String, dynamic> _encodeEvent(EventImpl<dynamic> event) => event.encode();
+dynamic _encodeResultReference(ResultReference<dynamic> reference) =>
+    reference.serializedValue;
+
+Map<String, dynamic> _encodeEvent(LiveInvocationEvent<dynamic> event) =>
+    event.serialize();
 
 String _encodeFileSystem(FileSystem fs) => kFileSystemEncodedValue;
 
diff --git a/lib/src/backends/record_replay/events.dart b/lib/src/backends/record_replay/events.dart
index bbb1526..f5ff039 100644
--- a/lib/src/backends/record_replay/events.dart
+++ b/lib/src/backends/record_replay/events.dart
@@ -2,7 +2,10 @@
 // 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 'recording.dart';
+import 'result_reference.dart';
 
 /// Base class for recordable file system invocation events.
 ///
@@ -53,53 +56,83 @@
   Map<Symbol, dynamic> get namedArguments;
 }
 
-/// Non-exported implementation of [InvocationEvent].
-abstract class EventImpl<T> implements InvocationEvent<T> {
-  /// Creates a new `EventImpl`.
-  EventImpl(this.object, this.result, this.timestamp);
+/// An [InvocationEvent] that's in the process of being recorded.
+abstract class LiveInvocationEvent<T> implements InvocationEvent<T> {
+  /// Creates a new `LiveInvocationEvent`.
+  LiveInvocationEvent(this.object, this._result, this.timestamp);
+
+  final dynamic _result;
 
   @override
   final Object object;
 
   @override
-  final T result;
+  T get result {
+    dynamic result = _result;
+    while (result is ResultReference) {
+      ResultReference<dynamic> reference = result;
+      result = reference.recordedValue;
+    }
+    return result;
+  }
 
   @override
   final int timestamp;
 
-  /// Encodes this event into a JSON-ready format.
-  Map<String, dynamic> encode() => <String, dynamic>{
+  /// A [Future] that completes once [result] is ready for serialization.
+  ///
+  /// If [result] is a [Future], this future completes when [result] completes.
+  /// If [result] is a [Stream], this future completes when the stream sends a
+  /// "done" event. If [result] is neither a future nor a stream, this future
+  /// completes immediately.
+  ///
+  /// It is legal for [serialize] to be called before this future completes,
+  /// but doing so will cause incomplete results to be serialized. Results that
+  /// are unfinished futures will be serialized as `null`, and results that are
+  /// unfinished streams will be serialized as the data that has been received
+  /// thus far.
+  Future<Null> get done async {
+    dynamic result = _result;
+    while (result is ResultReference) {
+      ResultReference<dynamic> reference = result;
+      await reference.complete;
+      result = reference.recordedValue;
+    }
+  }
+
+  /// Returns this event as a JSON-serializable object.
+  Map<String, dynamic> serialize() => <String, dynamic>{
         'object': object,
-        'result': result,
+        'result': _result,
         'timestamp': timestamp,
       };
 
   @override
-  String toString() => encode().toString();
+  String toString() => serialize().toString();
 }
 
-/// Non-exported implementation of [PropertyGetEvent].
-class PropertyGetEventImpl<T> extends EventImpl<T>
+/// A [PropertyGetEvent] that's in the process of being recorded.
+class LivePropertyGetEvent<T> extends LiveInvocationEvent<T>
     implements PropertyGetEvent<T> {
-  /// Create a new `PropertyGetEventImpl`.
-  PropertyGetEventImpl(Object object, this.property, T result, int timestamp)
+  /// Creates a new `LivePropertyGetEvent`.
+  LivePropertyGetEvent(Object object, this.property, T result, int timestamp)
       : super(object, result, timestamp);
 
   @override
   final Symbol property;
 
   @override
-  Map<String, dynamic> encode() => <String, dynamic>{
+  Map<String, dynamic> serialize() => <String, dynamic>{
         'type': 'get',
         'property': property,
-      }..addAll(super.encode());
+      }..addAll(super.serialize());
 }
 
-/// Non-exported implementation of [PropertySetEvent].
-class PropertySetEventImpl<T> extends EventImpl<Null>
+/// A [PropertySetEvent] that's in the process of being recorded.
+class LivePropertySetEvent<T> extends LiveInvocationEvent<Null>
     implements PropertySetEvent<T> {
-  /// Create a new `PropertySetEventImpl`.
-  PropertySetEventImpl(Object object, this.property, this.value, int timestamp)
+  /// Creates a new `LivePropertySetEvent`.
+  LivePropertySetEvent(Object object, this.property, this.value, int timestamp)
       : super(object, null, timestamp);
 
   @override
@@ -109,17 +142,18 @@
   final T value;
 
   @override
-  Map<String, dynamic> encode() => <String, dynamic>{
+  Map<String, dynamic> serialize() => <String, dynamic>{
         'type': 'set',
         'property': property,
         'value': value,
-      }..addAll(super.encode());
+      }..addAll(super.serialize());
 }
 
-/// Non-exported implementation of [MethodEvent].
-class MethodEventImpl<T> extends EventImpl<T> implements MethodEvent<T> {
-  /// Create a new `MethodEventImpl`.
-  MethodEventImpl(
+/// A [MethodEvent] that's in the process of being recorded.
+class LiveMethodEvent<T> extends LiveInvocationEvent<T>
+    implements MethodEvent<T> {
+  /// Creates a new `LiveMethodEvent`.
+  LiveMethodEvent(
     Object object,
     this.method,
     List<dynamic> positionalArguments,
@@ -143,10 +177,10 @@
   final Map<Symbol, dynamic> namedArguments;
 
   @override
-  Map<String, dynamic> encode() => <String, dynamic>{
+  Map<String, dynamic> serialize() => <String, dynamic>{
         'type': 'invoke',
         'method': method,
         'positionalArguments': positionalArguments,
         'namedArguments': namedArguments,
-      }..addAll(super.encode());
+      }..addAll(super.serialize());
 }
diff --git a/lib/src/backends/record_replay/mutable_recording.dart b/lib/src/backends/record_replay/mutable_recording.dart
index d75f91a..5630591 100644
--- a/lib/src/backends/record_replay/mutable_recording.dart
+++ b/lib/src/backends/record_replay/mutable_recording.dart
@@ -14,28 +14,45 @@
 
 /// A mutable live recording.
 class MutableRecording implements LiveRecording {
-  final List<InvocationEvent<dynamic>> _events = <InvocationEvent<dynamic>>[];
-
   /// Creates a new `MutableRecording` that will serialize its data to the
   /// specified [destination].
   MutableRecording(this.destination);
 
+  final List<LiveInvocationEvent<dynamic>> _events =
+      <LiveInvocationEvent<dynamic>>[];
+
+  bool _flushing = false;
+
   @override
   final Directory destination;
 
   @override
-  List<InvocationEvent<dynamic>> get events =>
-      new List<InvocationEvent<dynamic>>.unmodifiable(_events);
+  List<LiveInvocationEvent<dynamic>> get events =>
+      new List<LiveInvocationEvent<dynamic>>.unmodifiable(_events);
 
-  // TODO(tvolkert): Add ability to wait for all Future and Stream results
   @override
-  Future<Null> 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);
+  Future<Null> flush({Duration awaitPendingResults}) async {
+    if (_flushing) {
+      throw new StateError('Recording is already flushing');
+    }
+    _flushing = true;
+    try {
+      if (awaitPendingResults != null) {
+        Iterable<Future<Null>> futures =
+            _events.map((LiveInvocationEvent<dynamic> event) => event.done);
+        await Future
+            .wait<String>(futures)
+            .timeout(awaitPendingResults, onTimeout: () {});
+      }
+      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);
+    } finally {
+      _flushing = false;
+    }
   }
 
   /// Adds the specified [event] to this recording.
-  void add(InvocationEvent<dynamic> event) => _events.add(event);
+  void add(LiveInvocationEvent<dynamic> event) => _events.add(event);
 }
diff --git a/lib/src/backends/record_replay/recording.dart b/lib/src/backends/record_replay/recording.dart
index a3e5836..0a6ef97 100644
--- a/lib/src/backends/record_replay/recording.dart
+++ b/lib/src/backends/record_replay/recording.dart
@@ -35,9 +35,20 @@
   /// 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.
+  /// callers to call this method when they wish to write the recording to
+  /// disk.
+  ///
+  /// If [awaitPendingResults] is specified, this will wait the specified
+  /// duration for any results that are `Future`s or `Stream`s to complete
+  /// before serializing the recording to disk. Futures that don't complete
+  /// within the specified duration will have their results recorded as `null`,
+  /// and streams that don't send a "done" event within the specified duration
+  /// will have their results recorded as the list of events the stream has
+  /// fired thus far.
+  ///
+  /// Throws a [StateError] if a flush is already in progress.
   ///
   /// Returns a future that completes once the recording has been fully written
   /// to disk.
-  Future<Null> flush();
+  Future<Null> flush({Duration awaitPendingResults});
 }
diff --git a/lib/src/backends/record_replay/recording_proxy_mixin.dart b/lib/src/backends/record_replay/recording_proxy_mixin.dart
index bf69f74..36110aa 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 'result_reference.dart';
 
 /// Mixin that enables recording of property accesses, property mutations, and
 /// method invocations.
@@ -110,44 +111,37 @@
           : super.noSuchMethod(invocation);
     }
 
-    T recordEvent<T>(T value) {
-      InvocationEvent<T> event;
-      if (invocation.isGetter) {
-        event = new PropertyGetEventImpl<T>(this, name, value, time);
-      } else if (invocation.isSetter) {
-        // TODO(tvolkert): Remove indirection once SDK 1.22 is in stable branch
-        dynamic temp =
-            new PropertySetEventImpl<dynamic>(this, name, args[0], time);
-        event = temp;
-      } else {
-        event =
-            new MethodEventImpl<T>(this, name, args, namedArgs, value, time);
-      }
-      recording.add(event);
-      return value;
-    }
-
+    // Invoke the configured delegate method, wrapping Future and Stream
+    // results so that we record their values as they become available.
+    // TODO(tvolkert): record exceptions.
     dynamic value = Function.apply(method, args, namedArgs);
     if (value is Stream) {
-      List<dynamic> list = <dynamic>[];
-      value = _recordStreamToList(value, list);
-      recordEvent(list);
+      value = new StreamReference<dynamic>(value);
     } else if (value is Future) {
-      value = value.then(recordEvent);
+      value = new FutureReference<dynamic>(value);
+    }
+
+    // Record the invocation event associated with this invocation.
+    InvocationEvent<dynamic> event;
+    if (invocation.isGetter) {
+      event = new LivePropertyGetEvent<dynamic>(this, name, value, time);
+    } else if (invocation.isSetter) {
+      // TODO(tvolkert): Remove indirection once SDK 1.22 is in stable branch
+      dynamic temp =
+          new LivePropertySetEvent<dynamic>(this, name, args[0], time);
+      event = temp;
     } else {
-      recordEvent(value);
+      event = new LiveMethodEvent<dynamic>(
+          this, name, args, namedArgs, value, time);
     }
+    recording.add(event);
 
-    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<T> _recordStreamToList<T>(Stream<T> stream, List<T> list) async* {
-    await for (T element in stream) {
-      yield element;
-      list.add(element);
+    // Unwrap any result references before returning to the caller.
+    dynamic result = value;
+    while (result is ResultReference) {
+      result = result.value;
     }
+    return result;
   }
 }
 
diff --git a/lib/src/backends/record_replay/result_reference.dart b/lib/src/backends/record_replay/result_reference.dart
new file mode 100644
index 0000000..9936726
--- /dev/null
+++ b/lib/src/backends/record_replay/result_reference.dart
@@ -0,0 +1,152 @@
+// 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 'encoding.dart';
+import 'events.dart';
+import 'recording_proxy_mixin.dart';
+
+/// Wraps a raw invocation return value for the purpose of recording.
+///
+/// This class is intended for use with [RecordingProxyMixin]. Mixin subclasses
+/// may configure a method or getter to return a [ResultReference] rather than
+/// a raw result, and:
+///
+///   - [RecordingProxyMixin] will automatically return the reference's [value]
+///     to callers (as if the mixin subclass had returned the raw value
+///     directly).
+///   - The recording's [InvocationEvent] will automatically record the
+///     reference's [recordedValue].
+///   - The recording's serialized value (written out during
+///     [LiveRecording.flush]) will automatically serialize the reference's
+///     [serializedValue].
+abstract class ResultReference<T> {
+  /// Creates a new `ResultReference`.
+  const ResultReference();
+
+  /// The raw value to return to callers of the method or getter.
+  T get value;
+
+  /// The value to record in the recording's [InvocationEvent].
+  dynamic get recordedValue;
+
+  /// A JSON-serializable representation of this result, suitable for
+  /// encoding in a recording manifest.
+  ///
+  /// The value of this property will be one of the JSON-native types: `num`,
+  /// `String`, `bool`, `Null`, `List`, or `Map`.
+  ///
+  /// This allows for method-specific encoding routines. Take, for example, the
+  /// case of a method that returns `List<int>`. This type is natively
+  /// serializable by `JSONEncoder`, so if the raw value were directly returned
+  /// from an invocation, the recording would happily serialize the result as
+  /// a list of integers. However, the method may want to serialize the return
+  /// value differently, such as by writing it to file (if it knows the list is
+  /// actually a byte array that was read from a file). In this case, the
+  /// method can return a `ResultReference` to the list, and it will have a
+  /// hook into the serialization process.
+  dynamic get serializedValue => encode(recordedValue);
+
+  /// A [Future] that completes when [value] has completed.
+  ///
+  /// If [value] is a [Future], this future will complete when [value] has
+  /// completed. If [value] is a [Stream], this future will complete when the
+  /// stream sends a "done" event. If value is neither a future nor a stream,
+  /// this future will complete immediately.
+  Future<Null> get complete => new Future<Null>.value();
+}
+
+/// Wraps a future result.
+class FutureReference<T> extends ResultReference<Future<T>> {
+  final Future<T> _future;
+  T _value;
+
+  /// Creates a new `FutureReference` that wraps the specified [future].
+  FutureReference(Future<T> future) : _future = future;
+
+  /// The future value to return to callers of the method or getter.
+  @override
+  Future<T> get value {
+    return _future.then(
+      (T value) {
+        _value = value;
+        return value;
+      },
+      onError: (dynamic error) {
+        // TODO(tvolkert): Record errors
+        throw error;
+      },
+    );
+  }
+
+  /// The value returned by the completion of the future.
+  ///
+  /// If the future threw an error, this value will be `null`.
+  @override
+  T get recordedValue => _value;
+
+  @override
+  Future<Null> get complete => value;
+}
+
+/// Wraps a stream result.
+class StreamReference<T> extends ResultReference<Stream<T>> {
+  final Stream<T> _stream;
+  final StreamController<T> _controller;
+  final Completer<Null> _completer = new Completer<Null>();
+  final List<T> _data = <T>[];
+  StreamSubscription<T> _subscription;
+
+  /// Creates a new `StreamReference` that wraps the specified [stream].
+  StreamReference(Stream<T> stream)
+      : _stream = stream,
+        _controller = stream.isBroadcast
+            ? new StreamController<T>.broadcast()
+            : new StreamController<T>() {
+    _controller.onListen = () {
+      assert(_subscription == null);
+      _subscription = _listenToStream();
+    };
+    _controller.onCancel = () async {
+      assert(_subscription != null);
+      await _subscription.cancel();
+      _subscription = null;
+    };
+    _controller.onPause = () {
+      assert(_subscription != null && !_subscription.isPaused);
+      _subscription.pause();
+    };
+    _controller.onResume = () {
+      assert(_subscription != null && _subscription.isPaused);
+      _subscription.resume();
+    };
+  }
+
+  StreamSubscription<T> _listenToStream() {
+    return _stream.listen(
+      (T element) {
+        _data.add(element);
+        _controller.add(element);
+      },
+      onError: (dynamic error, StackTrace stackTrace) {
+        // TODO(tvolkert): Record errors
+        _controller.addError(error, stackTrace);
+      },
+      onDone: () {
+        _completer.complete();
+        _controller.close();
+      },
+    );
+  }
+
+  @override
+  Stream<T> get value => _controller.stream;
+
+  @override
+  List<T> get recordedValue => _data;
+
+  @override
+  Future<Null> get complete => _completer.future;
+}
diff --git a/lib/src/testing/record_replay_matchers.dart b/lib/src/testing/record_replay_matchers.dart
index 1475ba4..c205139 100644
--- a/lib/src/testing/record_replay_matchers.dart
+++ b/lib/src/testing/record_replay_matchers.dart
@@ -87,6 +87,18 @@
     return this;
   }
 
+  /// Limits the scope of the match to invocations that were recorded with the
+  /// specified [timestamp].
+  ///
+  /// [timestamp] may be an `int` or a [Matcher]. If it is an `int`, it will
+  /// be automatically wrapped in an equality matcher.
+  ///
+  /// Returns this matcher for chaining.
+  T withTimestamp(dynamic timestamp) {
+    _fieldMatchers.add(new _Timestamp(timestamp));
+    return this;
+  }
+
   /// @nodoc
   @override
   bool matches(dynamic item, Map<dynamic, dynamic> matchState) {
@@ -162,6 +174,15 @@
     _fieldMatchers.add(new _NamedArgument(name, value));
     return this;
   }
+
+  /// Limits the scope of the match to method invocations that specified no
+  /// named arguments.
+  ///
+  /// Returns this matcher for chaining.
+  MethodInvocation withNoNamedArguments() {
+    _fieldMatchers.add(const _NoNamedArguments());
+    return this;
+  }
 }
 
 /// Matchers that matches against [PropertyGetEvent] instances.
@@ -219,10 +240,12 @@
     Map<dynamic, dynamic> matchState,
     bool verbose,
   ) {
-    description.add('was invoked on: ${item.object}').add('\n   Which: ');
+    description.add('was invoked on: ${item.object}');
     Description matcherDesc = new StringDescription();
     _matcher.describeMismatch(item.object, matcherDesc, matchState, verbose);
-    description.add(matcherDesc.toString());
+    if (matcherDesc.length > 0) {
+      description.add('\n   Which: ').add(matcherDesc.toString());
+    }
     return description;
   }
 
@@ -249,10 +272,12 @@
     Map<dynamic, dynamic> matchState,
     bool verbose,
   ) {
-    description.add('returned: ${item.result}').add('\n   Which: ');
+    description.add('returned: ${item.result}');
     Description matcherDesc = new StringDescription();
     _matcher.describeMismatch(item.result, matcherDesc, matchState, verbose);
-    description.add(matcherDesc.toString());
+    if (matcherDesc.length > 0) {
+      description.add('\n   Which: ').add(matcherDesc.toString());
+    }
     return description;
   }
 
@@ -263,6 +288,38 @@
   }
 }
 
+class _Timestamp extends Matcher {
+  final Matcher _matcher;
+
+  _Timestamp(dynamic timestamp) : _matcher = wrapMatcher(timestamp);
+
+  @override
+  bool matches(dynamic item, Map<dynamic, dynamic> matchState) =>
+      _matcher.matches(item.timestamp, matchState);
+
+  @override
+  Description describeMismatch(
+    dynamic item,
+    Description description,
+    Map<dynamic, dynamic> matchState,
+    bool verbose,
+  ) {
+    description.add('has timestamp: ${item.timestamp}');
+    Description matcherDesc = new StringDescription();
+    _matcher.describeMismatch(item.timestamp, matcherDesc, matchState, verbose);
+    if (matcherDesc.length > 0) {
+      description.add('\n   Which: ').add(matcherDesc.toString());
+    }
+    return description;
+  }
+
+  @override
+  Description describe(Description desc) {
+    desc.add('with timestamp: ');
+    return _matcher.describe(desc);
+  }
+}
+
 class _Type extends Matcher {
   final Type type;
 
@@ -316,10 +373,12 @@
     bool verbose,
   ) {
     String methodName = getSymbolName(item.method);
-    description.add('invoked method: \'$methodName\'').add('\n   Which: ');
+    description.add('invoked method: \'$methodName\'');
     Description matcherDesc = new StringDescription();
     _matcher.describeMismatch(methodName, matcherDesc, matchState, verbose);
-    description.add(matcherDesc.toString());
+    if (matcherDesc.length > 0) {
+      description.add('\n   Which: ').add(matcherDesc.toString());
+    }
     return description;
   }
 
@@ -386,6 +445,31 @@
       description.add('with named argument "$name" = $value');
 }
 
+class _NoNamedArguments extends Matcher {
+  final Matcher _matcher = isEmpty;
+
+  const _NoNamedArguments();
+
+  @override
+  bool matches(dynamic item, Map<dynamic, dynamic> matchState) =>
+      _matcher.matches(item.namedArguments, matchState);
+
+  @override
+  Description describeMismatch(
+    dynamic item,
+    Description description,
+    Map<dynamic, dynamic> matchState,
+    bool verbose,
+  ) {
+    return _matcher.describeMismatch(
+        item.namedArguments, description, matchState, verbose);
+  }
+
+  @override
+  Description describe(Description description) =>
+      description.add('with no named arguments');
+}
+
 class _GetPropertyName extends Matcher {
   final Matcher _matcher;
 
@@ -403,10 +487,12 @@
     bool verbose,
   ) {
     String propertyName = getSymbolName(item.property);
-    description.add('got property: \'$propertyName\'').add('\n   Which: ');
+    description.add('got property: \'$propertyName\'');
     Description matcherDesc = new StringDescription();
     _matcher.describeMismatch(propertyName, matcherDesc, matchState, verbose);
-    description.add(matcherDesc.toString());
+    if (matcherDesc.length > 0) {
+      description.add('\n   Which: ').add(matcherDesc.toString());
+    }
     return description;
   }
 
@@ -441,10 +527,12 @@
     bool verbose,
   ) {
     String propertyName = _getPropertyName(item);
-    description.add('set property: \'$propertyName\'').add('\n   Which: ');
+    description.add('set property: \'$propertyName\'');
     Description matcherDesc = new StringDescription();
     _matcher.describeMismatch(propertyName, matcherDesc, matchState, verbose);
-    description.add(matcherDesc.toString());
+    if (matcherDesc.length > 0) {
+      description.add('\n   Which: ').add(matcherDesc.toString());
+    }
     return description;
   }
 
@@ -471,10 +559,12 @@
     Map<dynamic, dynamic> matchState,
     bool verbose,
   ) {
-    description.add('set value: ${item.value}').add('\n   Which: ');
+    description.add('set value: ${item.value}');
     Description matcherDesc = new StringDescription();
     _matcher.describeMismatch(item.value, matcherDesc, matchState, verbose);
-    description.add(matcherDesc.toString());
+    if (matcherDesc.length > 0) {
+      description.add('\n   Which: ').add(matcherDesc.toString());
+    }
     return description;
   }
 
diff --git a/test/recording_test.dart b/test/recording_test.dart
index e9c5118..3288b70 100644
--- a/test/recording_test.dart
+++ b/test/recording_test.dart
@@ -2,20 +2,200 @@
 // 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/memory.dart';
 import 'package:file/record_replay.dart';
 import 'package:file/testing.dart';
+import 'package:file/src/backends/record_replay/mutable_recording.dart';
+import 'package:file/src/backends/record_replay/recording_proxy_mixin.dart';
 import 'package:path/path.dart' as p;
 import 'package:test/test.dart';
 
 import 'common_tests.dart';
 
 void main() {
+  group('SupportingClasses', () {
+    BasicClass delegate;
+    RecordingClass rc;
+    MutableRecording recording;
+
+    setUp(() {
+      delegate = new BasicClass();
+      rc = new RecordingClass(
+        delegate: delegate,
+        stopwatch: new FakeStopwatch(10),
+        destination: new MemoryFileSystem().directory('/tmp')..createSync(),
+      );
+      recording = rc.recording;
+    });
+
+    group('InvocationEvent', () {
+      test('recordsAllPropertyGetMetadata', () {
+        delegate.basicProperty = 'foo';
+        String value = rc.basicProperty;
+        expect(recording.events, hasLength(1));
+        expect(
+            recording.events[0],
+            getsProperty('basicProperty')
+                .on(rc)
+                .withResult(value)
+                .withTimestamp(10));
+      });
+
+      test('recordsAllPropertySetMetadata', () {
+        rc.basicProperty = 'foo';
+        expect(recording.events, hasLength(1));
+        expect(
+            recording.events[0],
+            setsProperty('basicProperty')
+                .on(rc)
+                .toValue('foo')
+                .withTimestamp(10));
+      });
+
+      test('recordsAllMethodInvocationMetadata', () {
+        String result = rc.basicMethod('foo', namedArg: 'bar');
+        expect(recording.events, hasLength(1));
+        expect(
+            recording.events[0],
+            invokesMethod('basicMethod')
+                .on(rc)
+                .withPositionalArguments(<String>['foo'])
+                .withNamedArgument('namedArg', 'bar')
+                .withResult(result)
+                .withTimestamp(10));
+      });
+
+      test('resultIncompleteUntilFutureCompletes', () async {
+        delegate.basicProperty = 'foo';
+        rc.futureProperty; // ignore: unawaited_futures
+        expect(recording.events, hasLength(1));
+        expect(
+            recording.events[0],
+            getsProperty('futureProperty')
+                .on(rc)
+                .withResult(isNull)
+                .withTimestamp(10));
+        await recording.events[0].done;
+        expect(
+            recording.events[0],
+            getsProperty('futureProperty')
+                .on(rc)
+                .withResult('future.foo')
+                .withTimestamp(10));
+      });
+
+      test('resultIncompleteUntilStreamCompletes', () async {
+        Stream<String> stream = rc.streamMethod('foo', namedArg: 'bar');
+        stream.listen((_) {});
+        expect(recording.events, hasLength(1));
+        expect(
+            recording.events[0],
+            invokesMethod('streamMethod')
+                .on(rc)
+                .withPositionalArguments(<String>['foo'])
+                .withNamedArgument('namedArg', 'bar')
+                .withResult(allOf(isList, isEmpty))
+                .withTimestamp(10));
+        await recording.events[0].done;
+        expect(
+            recording.events[0],
+            invokesMethod('streamMethod')
+                .on(rc)
+                .withPositionalArguments(<String>['foo'])
+                .withNamedArgument('namedArg', 'bar')
+                .withResult(<String>['stream', 'foo', 'bar'])
+                .withTimestamp(10));
+      });
+    });
+
+    group('MutableRecording', () {
+      group('flush', () {
+        List<Map<String, dynamic>> loadManifestFromFileSystem() {
+          List<FileSystemEntity> files = recording.destination.listSync();
+          expect(files.length, 1);
+          expect(files[0], isFile);
+          expect(files[0].basename, 'MANIFEST.txt');
+          File manifestFile = files[0];
+          return new JsonDecoder().convert(manifestFile.readAsStringSync());
+        }
+
+        test('writesManifestToFileSystemAsJson', () async {
+          rc.basicProperty = 'foo';
+          String value = rc.basicProperty;
+          rc.basicMethod(value, namedArg: 'bar');
+          await recording.flush();
+          List<Map<String, dynamic>> manifest = loadManifestFromFileSystem();
+          expect(manifest, hasLength(3));
+          expect(manifest[0], <String, dynamic>{
+            'type': 'set',
+            'property': 'basicProperty=',
+            'value': 'foo',
+            'object': 'RecordingClass',
+            'result': null,
+            'timestamp': 10,
+          });
+          expect(manifest[1], <String, dynamic>{
+            'type': 'get',
+            'property': 'basicProperty',
+            'object': 'RecordingClass',
+            'result': 'foo',
+            'timestamp': 11,
+          });
+          expect(manifest[2], <String, dynamic>{
+            'type': 'invoke',
+            'method': 'basicMethod',
+            'positionalArguments': <String>['foo'],
+            'namedArguments': <String, dynamic>{'namedArg': 'bar'},
+            'object': 'RecordingClass',
+            'result': 'foo.bar',
+            'timestamp': 12
+          });
+        });
+
+        test('doesntAwaitPendingResultsUnlessToldToDoSo', () async {
+          rc.futureMethod('foo', namedArg: 'bar'); // ignore: unawaited_futures
+          await recording.flush();
+          List<Map<String, dynamic>> manifest = loadManifestFromFileSystem();
+          expect(manifest[0], containsPair('result', null));
+        });
+
+        test('succeedsIfAwaitPendingResultsThatComplete', () async {
+          rc.futureMethod('foo', namedArg: 'bar'); // ignore: unawaited_futures
+          await recording.flush(
+              awaitPendingResults: const Duration(seconds: 30));
+          List<Map<String, dynamic>> manifest = loadManifestFromFileSystem();
+          expect(manifest[0], containsPair('result', 'future.foo.bar'));
+        });
+
+        test('succeedsIfAwaitPendingResultsThatTimeout', () async {
+          rc.veryLongFutureMethod(); // ignore: unawaited_futures
+          DateTime before = new DateTime.now();
+          await recording.flush(
+              awaitPendingResults: const Duration(milliseconds: 250));
+          DateTime after = new DateTime.now();
+          Duration delta = after.difference(before);
+          List<Map<String, dynamic>> manifest = loadManifestFromFileSystem();
+          expect(manifest[0], containsPair('result', isNull));
+          expect(delta.inMilliseconds, greaterThanOrEqualTo(250));
+        });
+
+        test('throwsIfAlreadyFlushing', () {
+          rc.basicProperty = 'foo';
+          recording.flush();
+          expect(recording.flush(), throwsA(isStateError));
+        });
+      });
+    });
+  });
+
   group('RecordingFileSystem', () {
     RecordingFileSystem fs;
     MemoryFileSystem delegate;
-    Recording recording;
+    LiveRecording recording;
 
     setUp(() {
       delegate = new MemoryFileSystem();
@@ -238,10 +418,34 @@
               recording.events,
               contains(invokesMethod('create')
                   .on(isDirectory)
+                  .withNoNamedArguments()
                   .withResult(isDirectory)));
         });
 
-        test('createSync', () {});
+        test('createSync', () {
+          fs.directory('/foo').createSync();
+          expect(
+              recording.events,
+              contains(invokesMethod('createSync')
+                  .on(isDirectory)
+                  .withNoNamedArguments()
+                  .withResult(isNull)));
+        });
+
+        test('list', () async {
+          await delegate.directory('/foo').create();
+          await delegate.directory('/bar').create();
+          await delegate.file('/baz').create();
+          Stream<FileSystemEntity> stream = fs.directory('/').list();
+          await stream.drain();
+          expect(
+            recording.events,
+            contains(invokesMethod('list')
+                .on(isDirectory)
+                .withNoNamedArguments()
+                .withResult(hasLength(3))),
+          );
+        });
       });
 
       group('File', () {});
@@ -250,3 +454,98 @@
     });
   });
 }
+
+// ignore: public_member_api_docs
+class BasicClass {
+  // ignore: public_member_api_docs
+  String basicProperty;
+
+  // ignore: public_member_api_docs
+  Future<String> get futureProperty async => 'future.$basicProperty';
+
+  // ignore: public_member_api_docs
+  String basicMethod(String positionalArg, {String namedArg}) =>
+      '$positionalArg.$namedArg';
+
+  // ignore: public_member_api_docs
+  Future<String> futureMethod(String positionalArg, {String namedArg}) async {
+    await new Future<Null>.delayed(const Duration(milliseconds: 500));
+    String basicValue = basicMethod(positionalArg, namedArg: namedArg);
+    return 'future.$basicValue';
+  }
+
+  // ignore: public_member_api_docs
+  Stream<String> streamMethod(String positionalArg, {String namedArg}) async* {
+    yield 'stream';
+    yield positionalArg;
+    yield namedArg;
+  }
+
+  // ignore: public_member_api_docs
+  Future<String> veryLongFutureMethod() async {
+    await new Future<Null>.delayed(const Duration(seconds: 1));
+    return 'future';
+  }
+
+  // ignore: public_member_api_docs
+  Stream<String> infiniteStreamMethod() async* {
+    yield 'stream';
+    int i = 0;
+    while (i >= 0) {
+      yield '${i++}';
+      await new Future<Null>.delayed(const Duration(seconds: 1));
+    }
+  }
+}
+
+// ignore: public_member_api_docs
+class RecordingClass extends Object
+    with RecordingProxyMixin
+    implements BasicClass {
+  // ignore: public_member_api_docs
+  final BasicClass delegate;
+
+  // ignore: public_member_api_docs
+  RecordingClass({
+    this.delegate,
+    this.stopwatch,
+    Directory destination,
+  })
+      : recording = new MutableRecording(destination) {
+    methods.addAll(<Symbol, Function>{
+      #basicMethod: delegate.basicMethod,
+      #futureMethod: delegate.futureMethod,
+      #streamMethod: delegate.streamMethod,
+      #veryLongFutureMethod: delegate.veryLongFutureMethod,
+      #infiniteStreamMethod: delegate.infiniteStreamMethod,
+    });
+
+    properties.addAll(<Symbol, Function>{
+      #basicProperty: () => delegate.basicProperty,
+      const Symbol('basicProperty='): (String value) {
+        delegate.basicProperty = value;
+      },
+      #futureProperty: () => delegate.futureProperty,
+    });
+  }
+
+  @override
+  final MutableRecording recording;
+
+  @override
+  final Stopwatch stopwatch;
+}
+
+// ignore: public_member_api_docs
+class FakeStopwatch implements Stopwatch {
+  int _value;
+
+  // ignore: public_member_api_docs
+  FakeStopwatch(this._value);
+
+  @override
+  int get elapsedMilliseconds => _value++;
+
+  @override
+  dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
+}