Record & replay errors in (Recording|Replay)FileSystem (#35)

diff --git a/lib/src/backends/record_replay/codecs.dart b/lib/src/backends/record_replay/codecs.dart
index 2ecd1f7..a974692 100644
--- a/lib/src/backends/record_replay/codecs.dart
+++ b/lib/src/backends/record_replay/codecs.dart
@@ -10,6 +10,7 @@
 import 'package:path/path.dart' as path;
 
 import 'common.dart';
+import 'errors.dart';
 import 'events.dart';
 import 'replay_directory.dart';
 import 'replay_file.dart';
@@ -41,8 +42,8 @@
 class _GenericEncoder extends Converter<dynamic, dynamic> {
   const _GenericEncoder();
 
-  /// Known encoders. Types not covered here will be encoded using
-  /// [_encodeDefault].
+  /// Known encoders. Types not covered here will be encoded as a [String]
+  /// whose value is the runtime type of the object being encoded.
   ///
   /// When encoding an object, we will walk this map in insertion order looking
   /// for a matching encoder. Thus, when there are two encoders that match an
@@ -67,22 +68,20 @@
     const TypeMatcher<FileStat>(): FileStatCodec.serialize,
     const TypeMatcher<FileSystemEntityType>(): EntityTypeCodec.serialize,
     const TypeMatcher<FileSystemEvent>(): FileSystemEventCodec.serialize,
+    const TypeMatcher<FileSystemException>(): _FSExceptionCodec.serialize,
+    const TypeMatcher<OSError>(): _OSErrorCodec.serialize,
+    const TypeMatcher<ArgumentError>(): _ArgumentErrorCodec.serialize,
+    const TypeMatcher<NoSuchMethodError>(): _NoSuchMethodErrorCodec.serialize,
   };
 
-  /// Default encoder (used for types not covered in [_encoders]).
-  static String _encodeDefault(dynamic object) => object.runtimeType.toString();
-
   @override
   dynamic convert(dynamic input) {
-    Converter<dynamic, dynamic> encoder =
-        const _ForwardingConverter<dynamic, String>(_encodeDefault);
     for (TypeMatcher<dynamic> matcher in _encoders.keys) {
       if (matcher.matches(input)) {
-        encoder = _encoders[matcher];
-        break;
+        return _encoders[matcher].convert(input);
       }
     }
-    return encoder.convert(input);
+    return input.runtimeType.toString();
   }
 }
 
@@ -552,3 +551,181 @@
     return file.readAsBytesSync();
   }
 }
+
+/// Converts serialized errors into throwable objects.
+class ToError extends Converter<dynamic, dynamic> {
+  /// Creates a new [ToError].
+  const ToError();
+
+  /// Known decoders (keyed by `type`). Types not covered here will be decoded
+  /// into [InvocationException].
+  static const Map<String, Converter<Object, Object>> _decoders =
+      const <String, Converter<Object, Object>>{
+    _FSExceptionCodec.type: _FSExceptionCodec.deserialize,
+    _OSErrorCodec.type: _OSErrorCodec.deserialize,
+    _ArgumentErrorCodec.type: _ArgumentErrorCodec.deserialize,
+    _NoSuchMethodErrorCodec.type: _NoSuchMethodErrorCodec.deserialize,
+  };
+
+  @override
+  dynamic convert(dynamic input) {
+    if (input is Map) {
+      String errorType = input[kManifestErrorTypeKey];
+      if (_decoders.containsKey(errorType)) {
+        return _decoders[errorType].convert(input);
+      }
+    }
+    return new InvocationException();
+  }
+}
+
+class _FSExceptionCodec
+    extends Codec<FileSystemException, Map<String, Object>> {
+  const _FSExceptionCodec();
+
+  static const String type = 'FileSystemException';
+
+  static Map<String, Object> _encode(FileSystemException exception) {
+    return <String, Object>{
+      kManifestErrorTypeKey: type,
+      'message': exception.message,
+      'path': exception.path,
+      'osError': encode(exception.osError),
+    };
+  }
+
+  static FileSystemException _decode(Map<String, Object> input) {
+    Object osError = input['osError'];
+    return new FileSystemException(
+      input['message'],
+      input['path'],
+      osError == null ? null : const ToError().convert(osError),
+    );
+  }
+
+  static const Converter<FileSystemException, Map<String, Object>> serialize =
+      const _ForwardingConverter<FileSystemException, Map<String, Object>>(
+          _encode);
+
+  static const Converter<Map<String, Object>, FileSystemException> deserialize =
+      const _ForwardingConverter<Map<String, Object>, FileSystemException>(
+          _decode);
+
+  @override
+  Converter<FileSystemException, Map<String, Object>> get encoder => serialize;
+
+  @override
+  Converter<Map<String, Object>, FileSystemException> get decoder =>
+      deserialize;
+}
+
+class _OSErrorCodec extends Codec<OSError, Map<String, Object>> {
+  const _OSErrorCodec();
+
+  static const String type = 'OSError';
+
+  static Map<String, Object> _encode(OSError error) {
+    return <String, Object>{
+      kManifestErrorTypeKey: type,
+      'message': error.message,
+      'errorCode': error.errorCode,
+    };
+  }
+
+  static OSError _decode(Map<String, Object> input) {
+    return new OSError(input['message'], input['errorCode']);
+  }
+
+  static const Converter<OSError, Map<String, Object>> serialize =
+      const _ForwardingConverter<OSError, Map<String, Object>>(_encode);
+
+  static const Converter<Map<String, Object>, OSError> deserialize =
+      const _ForwardingConverter<Map<String, Object>, OSError>(_decode);
+
+  @override
+  Converter<OSError, Map<String, Object>> get encoder => serialize;
+
+  @override
+  Converter<Map<String, Object>, OSError> get decoder => deserialize;
+}
+
+class _ArgumentErrorCodec extends Codec<ArgumentError, Map<String, Object>> {
+  const _ArgumentErrorCodec();
+
+  static const String type = 'ArgumentError';
+
+  static Map<String, Object> _encode(ArgumentError error) {
+    return <String, Object>{
+      kManifestErrorTypeKey: type,
+      'message': encode(error.message),
+      'invalidValue': encode(error.invalidValue),
+      'name': error.name,
+    };
+  }
+
+  static ArgumentError _decode(Map<String, Object> input) {
+    dynamic message = input['message'];
+    dynamic invalidValue = input['invalidValue'];
+    String name = input['name'];
+    if (invalidValue != null) {
+      return new ArgumentError.value(invalidValue, name, message);
+    } else if (name != null) {
+      return new ArgumentError.notNull(name);
+    } else {
+      return new ArgumentError(message);
+    }
+  }
+
+  static const Converter<ArgumentError, Map<String, Object>> serialize =
+      const _ForwardingConverter<ArgumentError, Map<String, Object>>(_encode);
+
+  static const Converter<Map<String, Object>, ArgumentError> deserialize =
+      const _ForwardingConverter<Map<String, Object>, ArgumentError>(_decode);
+
+  @override
+  Converter<ArgumentError, Map<String, Object>> get encoder => serialize;
+
+  @override
+  Converter<Map<String, Object>, ArgumentError> get decoder => deserialize;
+}
+
+class _NoSuchMethodErrorCodec
+    extends Codec<NoSuchMethodError, Map<String, Object>> {
+  const _NoSuchMethodErrorCodec();
+
+  static const String type = 'NoSuchMethodError';
+
+  static Map<String, Object> _encode(NoSuchMethodError error) {
+    return <String, Object>{
+      kManifestErrorTypeKey: type,
+      'toString': error.toString(),
+    };
+  }
+
+  static NoSuchMethodError _decode(Map<String, Object> input) {
+    return new _NoSuchMethodError(input['toString']);
+  }
+
+  static const Converter<NoSuchMethodError, Map<String, Object>> serialize =
+      const _ForwardingConverter<NoSuchMethodError, Map<String, Object>>(
+          _encode);
+
+  static const Converter<Map<String, Object>, NoSuchMethodError> deserialize =
+      const _ForwardingConverter<Map<String, Object>, NoSuchMethodError>(
+          _decode);
+
+  @override
+  Converter<NoSuchMethodError, Map<String, Object>> get encoder => serialize;
+
+  @override
+  Converter<Map<String, Object>, NoSuchMethodError> get decoder => deserialize;
+}
+
+class _NoSuchMethodError extends Error implements NoSuchMethodError {
+  final String _toString;
+
+  _NoSuchMethodError(this._toString);
+
+  @override
+  String toString() => _toString;
+}
diff --git a/lib/src/backends/record_replay/common.dart b/lib/src/backends/record_replay/common.dart
index 9313f90..3dbac0d 100644
--- a/lib/src/backends/record_replay/common.dart
+++ b/lib/src/backends/record_replay/common.dart
@@ -28,6 +28,14 @@
 const String kManifestResultKey = 'result';
 
 /// The key in a serialized [InvocationEvent] map that is used to store the
+/// error that was thrown during the invocation.
+const String kManifestErrorKey = 'error';
+
+/// The key in a serialized error that is used to store the runtime type of
+/// the error that was thrown.
+const String kManifestErrorTypeKey = 'type';
+
+/// The key in a serialized [InvocationEvent] map that is used to store the
 /// timestamp of the invocation.
 const String kManifestTimestampKey = 'timestamp';
 
@@ -118,9 +126,9 @@
 bool deeplyEqual(dynamic object1, dynamic object2) {
   if (object1.runtimeType != object2.runtimeType) {
     return false;
-  } else if (object1 is List<dynamic>) {
+  } else if (object1 is List) {
     return _areListsEqual(object1, object2);
-  } else if (object1 is Map<dynamic, dynamic>) {
+  } else if (object1 is Map) {
     return _areMapsEqual(object1, object2);
   } else {
     return object1 == object2;
diff --git a/lib/src/backends/record_replay/errors.dart b/lib/src/backends/record_replay/errors.dart
index 8e199ba..d2ebdb0 100644
--- a/lib/src/backends/record_replay/errors.dart
+++ b/lib/src/backends/record_replay/errors.dart
@@ -42,3 +42,8 @@
     return buf.toString();
   }
 }
+
+/// Exception thrown during replay when an invocation recorded error, but we
+/// were unable to find a type-specific converter to deserialize the recorded
+/// error into a more specific exception type.
+class InvocationException implements Exception {}
diff --git a/lib/src/backends/record_replay/events.dart b/lib/src/backends/record_replay/events.dart
index 65bab49..53884e3 100644
--- a/lib/src/backends/record_replay/events.dart
+++ b/lib/src/backends/record_replay/events.dart
@@ -16,10 +16,26 @@
   /// The object on which the invocation occurred. Will always be non-null.
   Object get object;
 
-  /// The return value of the invocation. This may be null (and will always be
-  /// `null` for setters).
+  /// The return value of the invocation if the invocation completed
+  /// successfully.
+  ///
+  /// This may be null (and will always be `null` for setters).
+  ///
+  /// If the invocation completed with an error, this value will be `null`,
+  /// and [error] will be set.
   T get result;
 
+  /// The error that was thrown by the invocation if the invocation completed
+  /// with an error.
+  ///
+  /// If the invocation completed successfully, this value will be `null`, and
+  /// [result] will hold the result of the invocation (which may also be
+  /// `null`).
+  ///
+  /// This field being non-null can be used as an indication that the invocation
+  /// completed with an error.
+  dynamic get error;
+
   /// The stopwatch value (in milliseconds) when the invocation occurred.
   ///
   /// This value is recorded when the invocation first occurs, not when the
@@ -60,8 +76,8 @@
 
 /// 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);
+  /// Creates a new [LiveInvocationEvent].
+  LiveInvocationEvent(this.object, this._result, this.error, this.timestamp);
 
   final dynamic _result;
 
@@ -79,6 +95,9 @@
   }
 
   @override
+  final dynamic error;
+
+  @override
   final int timestamp;
 
   /// A [Future] that completes once [result] is ready for serialization.
@@ -107,6 +126,7 @@
     return <String, dynamic>{
       kManifestObjectKey: encode(object),
       kManifestResultKey: encode(_result),
+      kManifestErrorKey: encode(error),
       kManifestTimestampKey: timestamp,
     };
   }
@@ -118,9 +138,10 @@
 /// A [PropertyGetEvent] that's in the process of being recorded.
 class LivePropertyGetEvent<T> extends LiveInvocationEvent<T>
     implements PropertyGetEvent<T> {
-  /// Creates a new `LivePropertyGetEvent`.
-  LivePropertyGetEvent(Object object, this.property, T result, int timestamp)
-      : super(object, result, timestamp);
+  /// Creates a new [LivePropertyGetEvent].
+  LivePropertyGetEvent(
+      Object object, this.property, T result, dynamic error, int timestamp)
+      : super(object, result, error, timestamp);
 
   @override
   final Symbol property;
@@ -137,9 +158,10 @@
 /// A [PropertySetEvent] that's in the process of being recorded.
 class LivePropertySetEvent<T> extends LiveInvocationEvent<Null>
     implements PropertySetEvent<T> {
-  /// Creates a new `LivePropertySetEvent`.
-  LivePropertySetEvent(Object object, this.property, this.value, int timestamp)
-      : super(object, null, timestamp);
+  /// Creates a new [LivePropertySetEvent].
+  LivePropertySetEvent(
+      Object object, this.property, this.value, dynamic error, int timestamp)
+      : super(object, null, error, timestamp);
 
   @override
   final Symbol property;
@@ -160,20 +182,21 @@
 /// A [MethodEvent] that's in the process of being recorded.
 class LiveMethodEvent<T> extends LiveInvocationEvent<T>
     implements MethodEvent<T> {
-  /// Creates a new `LiveMethodEvent`.
+  /// Creates a new [LiveMethodEvent].
   LiveMethodEvent(
     Object object,
     this.method,
     List<dynamic> positionalArguments,
     Map<Symbol, dynamic> namedArguments,
     T result,
+    dynamic error,
     int timestamp,
   )
       : this.positionalArguments =
             new List<dynamic>.unmodifiable(positionalArguments),
         this.namedArguments =
             new Map<Symbol, dynamic>.unmodifiable(namedArguments),
-        super(object, result, timestamp);
+        super(object, result, error, timestamp);
 
   @override
   final Symbol method;
diff --git a/lib/src/backends/record_replay/mutable_recording.dart b/lib/src/backends/record_replay/mutable_recording.dart
index 2693291..4a7681b 100644
--- a/lib/src/backends/record_replay/mutable_recording.dart
+++ b/lib/src/backends/record_replay/mutable_recording.dart
@@ -32,19 +32,19 @@
       new List<LiveInvocationEvent<dynamic>>.unmodifiable(_events);
 
   @override
-  Future<Null> flush({Duration awaitPendingResults}) async {
+  Future<Null> flush({Duration pendingResultTimeout}) 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<Null>(futures)
-            .timeout(awaitPendingResults, onTimeout: () {});
+      Iterable<Future<Null>> futures =
+          _events.map((LiveInvocationEvent<dynamic> event) => event.done);
+      Future<List<Null>> results = Future.wait<Null>(futures);
+      if (pendingResultTimeout != null) {
+        results = results.timeout(pendingResultTimeout, onTimeout: () {});
       }
+      await results;
       Directory dir = destination;
       String json = new JsonEncoder.withIndent('  ').convert(encode(_events));
       String filename = dir.fileSystem.path.join(dir.path, kManifestName);
diff --git a/lib/src/backends/record_replay/recording.dart b/lib/src/backends/record_replay/recording.dart
index 3932905..6676195 100644
--- a/lib/src/backends/record_replay/recording.dart
+++ b/lib/src/backends/record_replay/recording.dart
@@ -38,7 +38,7 @@
   /// callers to call this method when they wish to write the recording to
   /// disk.
   ///
-  /// If [awaitPendingResults] is specified, this will wait the specified
+  /// If [pendingResultTimeout] 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`,
@@ -46,9 +46,13 @@
   /// will have their results recorded as the list of events the stream has
   /// fired thus far.
   ///
+  /// If [pendingResultTimeout] is not specified (or is `null`), this will wait
+  /// indefinitely for for any results that are `Future`s or `Stream`s to
+  /// complete before serializing the recording to disk.
+  ///
   /// 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({Duration awaitPendingResults});
+  Future<Null> flush({Duration pendingResultTimeout});
 }
diff --git a/lib/src/backends/record_replay/recording_proxy_mixin.dart b/lib/src/backends/record_replay/recording_proxy_mixin.dart
index ddf1f43..055b7a1 100644
--- a/lib/src/backends/record_replay/recording_proxy_mixin.dart
+++ b/lib/src/backends/record_replay/recording_proxy_mixin.dart
@@ -113,10 +113,30 @@
           : super.noSuchMethod(invocation);
     }
 
-    // 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);
+    InvocationEvent<dynamic> createEvent({dynamic result, dynamic error}) {
+      if (invocation.isGetter) {
+        return new LivePropertyGetEvent<dynamic>(
+            this, name, result, error, time);
+      } else if (invocation.isSetter) {
+        return new LivePropertySetEvent<dynamic>(
+            this, name, args[0], error, time);
+      } else {
+        return new LiveMethodEvent<dynamic>(
+            this, name, args, namedArgs, result, error, time);
+      }
+    }
+
+    // Invoke the configured delegate method, recording an error if one occurs.
+    dynamic value;
+    try {
+      value = Function.apply(method, args, namedArgs);
+    } catch (error) {
+      recording.add(createEvent(error: error));
+      rethrow;
+    }
+
+    // Wrap Future and Stream results so that we record their values as they
+    // become available.
     if (value is Stream) {
       value = new StreamReference<dynamic>(value);
     } else if (value is Future) {
@@ -124,19 +144,7 @@
     }
 
     // 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 {
-      event = new LiveMethodEvent<dynamic>(
-          this, name, args, namedArgs, value, time);
-    }
-    recording.add(event);
+    recording.add(createEvent(result: value));
 
     // Unwrap any result references before returning to the caller.
     dynamic result = value;
diff --git a/lib/src/backends/record_replay/replay_proxy_mixin.dart b/lib/src/backends/record_replay/replay_proxy_mixin.dart
index 56d8258..9f37b9c 100644
--- a/lib/src/backends/record_replay/replay_proxy_mixin.dart
+++ b/lib/src/backends/record_replay/replay_proxy_mixin.dart
@@ -126,6 +126,10 @@
     }
     entry[kManifestOrdinalKey] = _nextOrdinal++;
 
+    dynamic error = entry[kManifestErrorKey];
+    if (error != null) {
+      throw const ToError().convert(error);
+    }
     dynamic result = reviver.convert(entry[kManifestResultKey]);
     result = onResult(invocation, result);
     return result;
diff --git a/lib/src/backends/record_replay/result_reference.dart b/lib/src/backends/record_replay/result_reference.dart
index fa6e9c4..ab4378b 100644
--- a/lib/src/backends/record_replay/result_reference.dart
+++ b/lib/src/backends/record_replay/result_reference.dart
@@ -89,9 +89,9 @@
   @override
   T get recordedValue => _value;
 
-  // TODO(tvolkert): remove `.then()` once Dart 1.22 is in stable
+  // TODO(tvolkert): remove `as Future<Null>` once Dart 1.22 is in stable
   @override
-  Future<Null> get complete => value.then<Null>((_) {});
+  Future<Null> get complete => value.catchError((_) {}) as Future<Null>;
 }
 
 /// Wraps a stream result.
@@ -159,5 +159,5 @@
   List<T> get recordedValue => _data;
 
   @override
-  Future<Null> get complete => _completer.future;
+  Future<Null> get complete => _completer.future.catchError((_) {});
 }
diff --git a/test/recording_test.dart b/test/recording_test.dart
index 7efcf2f..99bb54a 100644
--- a/test/recording_test.dart
+++ b/test/recording_test.dart
@@ -130,6 +130,7 @@
             'value': 'foo',
             'object': '_RecordingClass',
             'result': null,
+            'error': null,
             'timestamp': 10,
           });
           expect(manifest[1], <String, dynamic>{
@@ -137,6 +138,7 @@
             'property': 'basicProperty',
             'object': '_RecordingClass',
             'result': 'foo',
+            'error': null,
             'timestamp': 11,
           });
           expect(manifest[2], <String, dynamic>{
@@ -146,21 +148,21 @@
             'namedArguments': <String, dynamic>{'namedArg': 'bar'},
             'object': '_RecordingClass',
             'result': 'foo.bar',
+            'error': null,
             'timestamp': 12
           });
         });
 
-        test('doesntAwaitPendingResultsUnlessToldToDoSo', () async {
-          rc.futureMethod('foo', namedArg: 'bar'); // ignore: unawaited_futures
-          await recording.flush();
-          List<Map<String, dynamic>> manifest = _loadManifest(recording);
-          expect(manifest[0], containsPair('result', null));
+        test('awaitsPendingResultsIndefinitelyByDefault', () async {
+          rc.veryLongFutureMethod(); // ignore: unawaited_futures
+          expect(recording.flush().timeout(const Duration(milliseconds: 50)),
+              throwsTimeoutException);
         });
 
         test('succeedsIfAwaitPendingResultsThatComplete', () async {
           rc.futureMethod('foo', namedArg: 'bar'); // ignore: unawaited_futures
           await recording.flush(
-              awaitPendingResults: const Duration(seconds: 30));
+              pendingResultTimeout: const Duration(seconds: 30));
           List<Map<String, dynamic>> manifest = _loadManifest(recording);
           expect(manifest[0], containsPair('result', 'future.foo.bar'));
         });
@@ -169,7 +171,7 @@
           rc.veryLongFutureMethod(); // ignore: unawaited_futures
           DateTime before = new DateTime.now();
           await recording.flush(
-              awaitPendingResults: const Duration(milliseconds: 250));
+              pendingResultTimeout: const Duration(milliseconds: 250));
           DateTime after = new DateTime.now();
           Duration delta = after.difference(before);
           List<Map<String, dynamic>> manifest = _loadManifest(recording);
@@ -200,6 +202,7 @@
           'value': 'foo',
           'object': '_RecordingClass',
           'result': isNull,
+          'error': null,
           'timestamp': 10,
         });
         expect(manifest[1], <String, dynamic>{
@@ -207,6 +210,7 @@
           'property': 'basicProperty',
           'object': '_RecordingClass',
           'result': 'foo',
+          'error': null,
           'timestamp': 11,
         });
         expect(manifest[2], <String, dynamic>{
@@ -216,6 +220,7 @@
           'namedArguments': <String, String>{'namedArg': 'baz'},
           'object': '_RecordingClass',
           'result': 'bar.baz',
+          'error': null,
           'timestamp': 12,
         });
         expect(manifest[3], <String, dynamic>{
@@ -223,6 +228,7 @@
           'property': 'futureProperty',
           'object': '_RecordingClass',
           'result': 'future.foo',
+          'error': null,
           'timestamp': 13,
         });
         expect(manifest[4], <String, dynamic>{
@@ -232,6 +238,7 @@
           'namedArguments': <String, String>{'namedArg': 'quz'},
           'object': '_RecordingClass',
           'result': 'future.qux.quz',
+          'error': null,
           'timestamp': 14,
         });
         expect(manifest[5], <String, dynamic>{
@@ -241,6 +248,7 @@
           'namedArguments': <String, String>{'namedArg': 'quuz'},
           'object': '_RecordingClass',
           'result': <String>['stream', 'quux', 'quuz'],
+          'error': null,
           'timestamp': 15,
         });
       });
@@ -920,3 +928,7 @@
   @override
   dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
 }
+
+/// Successfully matches against a function that throws a [TimeoutException].
+const Matcher throwsTimeoutException =
+    const Throws(const isInstanceOf<TimeoutException>());
diff --git a/test/replay_test.dart b/test/replay_test.dart
index f9f7fa9..4b2ea18 100644
--- a/test/replay_test.dart
+++ b/test/replay_test.dart
@@ -39,8 +39,11 @@
       () => recordingFileSystem,
       replay: replay,
       skip: <String>[
-        // ReplayFileSystem does not yet replay exceptions
-        '.*(disallows|throws|blocks).*',
+        // ReplayFileSystem does not yet support futures & streams that throw
+        'File > openRead > throws.*',
+        'File > openWrite > throws.*',
+        'File > openWrite > ioSink > throwsIfAddError',
+        'File > openWrite > ioSink > addStream > blocks.*',
 
         'File > open', // Not yet implemented in MemoryFileSystem
       ],