Add an operation handle to allow inserting FileSystemExceptions in the memory fs without mocking (#181)

diff --git a/packages/file/CHANGELOG.md b/packages/file/CHANGELOG.md
index 4537832..4bf571e 100644
--- a/packages/file/CHANGELOG.md
+++ b/packages/file/CHANGELOG.md
@@ -1,3 +1,9 @@
+#### 6.1.0
+
+* Reading invalid UTF8 with the `MemoryFileSystem` now correctly throws a `FileSystemException` instead of a `FormatError`.
+* `MemoryFileSystem` now provides an `opHandle` to inspect read/write operations.
+* `MemoryFileSystem` now creates the tempory directory before returning in `createTemp`/`createTempSync`.
+
 #### 6.0.1
 
 * Fix sound type error in memory backend when reading non-existent `MemoryDirectory`.
diff --git a/packages/file/lib/memory.dart b/packages/file/lib/memory.dart
index 31e90e7..c5705ef 100644
--- a/packages/file/lib/memory.dart
+++ b/packages/file/lib/memory.dart
@@ -5,3 +5,4 @@
 /// An implementation of `FileSystem` that exists entirely in memory with an
 /// internal representation loosely based on the Filesystem Hierarchy Standard.
 export 'src/backends/memory.dart';
+export 'src/backends/memory/operations.dart';
diff --git a/packages/file/lib/src/backends/memory/memory_directory.dart b/packages/file/lib/src/backends/memory/memory_directory.dart
index 6b11263..c5c590e 100644
--- a/packages/file/lib/src/backends/memory/memory_directory.dart
+++ b/packages/file/lib/src/backends/memory/memory_directory.dart
@@ -12,6 +12,7 @@
 import 'memory_file_system_entity.dart';
 import 'memory_link.dart';
 import 'node.dart';
+import 'operations.dart';
 import 'style.dart';
 import 'utils.dart' as utils;
 
@@ -47,6 +48,7 @@
 
   @override
   void createSync({bool recursive = false}) {
+    fileSystem.opHandle(path, FileSystemOp.create);
     Node? node = internalCreateSync(
       followTailLink: true,
       visitLinks: true,
@@ -84,7 +86,8 @@
     _systemTempCounter[fileSystem] = _tempCounter;
     DirectoryNode tempDir = DirectoryNode(node);
     node.children[name()] = tempDir;
-    return MemoryDirectory(fileSystem, fileSystem.path.join(dirname, name()));
+    return MemoryDirectory(fileSystem, fileSystem.path.join(dirname, name()))
+      ..createSync();
   }
 
   @override
diff --git a/packages/file/lib/src/backends/memory/memory_file.dart b/packages/file/lib/src/backends/memory/memory_file.dart
index e285f49..a3ce724 100644
--- a/packages/file/lib/src/backends/memory/memory_file.dart
+++ b/packages/file/lib/src/backends/memory/memory_file.dart
@@ -8,6 +8,7 @@
 import 'dart:typed_data';
 
 import 'package:file/file.dart';
+import 'package:file/src/backends/memory/operations.dart';
 import 'package:file/src/common.dart' as common;
 import 'package:file/src/io.dart' as io;
 import 'package:meta/meta.dart';
@@ -51,6 +52,7 @@
 
   @override
   void createSync({bool recursive = false}) {
+    fileSystem.opHandle(path, FileSystemOp.create);
     _doCreate(recursive: recursive);
   }
 
@@ -219,16 +221,23 @@
   Future<Uint8List> readAsBytes() async => readAsBytesSync();
 
   @override
-  Uint8List readAsBytesSync() =>
-      Uint8List.fromList((resolvedBacking as FileNode).content);
+  Uint8List readAsBytesSync() {
+    fileSystem.opHandle(path, FileSystemOp.read);
+    return Uint8List.fromList((resolvedBacking as FileNode).content);
+  }
 
   @override
   Future<String> readAsString({Encoding encoding = utf8}) async =>
       readAsStringSync(encoding: encoding);
 
   @override
-  String readAsStringSync({Encoding encoding = utf8}) =>
-      encoding.decode(readAsBytesSync());
+  String readAsStringSync({Encoding encoding = utf8}) {
+    try {
+      return encoding.decode(readAsBytesSync());
+    } on FormatException catch (err) {
+      throw FileSystemException(err.message, path);
+    }
+  }
 
   @override
   Future<List<String>> readAsLines({Encoding encoding = utf8}) async =>
@@ -272,6 +281,7 @@
     }
     FileNode node = _resolvedBackingOrCreate;
     _truncateIfNecessary(node, mode);
+    fileSystem.opHandle(path, FileSystemOp.write);
     node.write(bytes);
     node.touch();
   }
diff --git a/packages/file/lib/src/backends/memory/memory_file_system.dart b/packages/file/lib/src/backends/memory/memory_file_system.dart
index adb5e0a..8d63ea5 100644
--- a/packages/file/lib/src/backends/memory/memory_file_system.dart
+++ b/packages/file/lib/src/backends/memory/memory_file_system.dart
@@ -3,6 +3,7 @@
 // BSD-style license that can be found in the LICENSE file.
 
 import 'package:file/file.dart';
+import 'package:file/src/backends/memory/operations.dart';
 import 'package:file/src/io.dart' as io;
 import 'package:path/path.dart' as p;
 
@@ -19,6 +20,8 @@
 const String _thisDir = '.';
 const String _parentDir = '..';
 
+void _defaultOpHandle(String context, FileSystemOp operation) {}
+
 /// An implementation of [FileSystem] that exists entirely in memory with an
 /// internal representation loosely based on the Filesystem Hierarchy Standard.
 ///
@@ -41,10 +44,13 @@
   /// style. The default is [FileSystemStyle.posix].
   factory MemoryFileSystem({
     FileSystemStyle style = FileSystemStyle.posix,
+    void Function(String context, FileSystemOp operation) opHandle =
+        _defaultOpHandle,
   }) =>
       _MemoryFileSystem(
         style: style,
         clock: const Clock.realTime(),
+        opHandle: opHandle,
       );
 
   /// Creates a new `MemoryFileSystem` that has a fake clock.
@@ -59,10 +65,13 @@
   /// style. The default is [FileSystemStyle.posix].
   factory MemoryFileSystem.test({
     FileSystemStyle style = FileSystemStyle.posix,
+    void Function(String context, FileSystemOp operation) opHandle =
+        _defaultOpHandle,
   }) =>
       _MemoryFileSystem(
         style: style,
         clock: Clock.monotonicTest(),
+        opHandle: opHandle,
       );
 }
 
@@ -72,14 +81,17 @@
   _MemoryFileSystem({
     this.style = FileSystemStyle.posix,
     required this.clock,
-  }) {
-    _context = style.contextFor(style.root);
+    this.opHandle = _defaultOpHandle,
+  }) : _context = style.contextFor(style.root) {
     _root = RootNode(this);
   }
 
   RootNode? _root;
   String? _systemTemp;
-  late p.Context _context;
+  p.Context _context;
+
+  @override
+  final Function(String context, FileSystemOp operation) opHandle;
 
   @override
   final Clock clock;
diff --git a/packages/file/lib/src/backends/memory/memory_file_system_entity.dart b/packages/file/lib/src/backends/memory/memory_file_system_entity.dart
index cdf64e3..c2f9c4d 100644
--- a/packages/file/lib/src/backends/memory/memory_file_system_entity.dart
+++ b/packages/file/lib/src/backends/memory/memory_file_system_entity.dart
@@ -10,6 +10,7 @@
 import 'common.dart';
 import 'memory_directory.dart';
 import 'node.dart';
+import 'operations.dart';
 import 'style.dart';
 import 'utils.dart' as utils;
 
@@ -286,6 +287,7 @@
     bool recursive = false,
     utils.TypeChecker? checkType,
   }) {
+    fileSystem.opHandle(path, FileSystemOp.delete);
     Node node = backing;
     if (!recursive) {
       if (node is DirectoryNode && node.children.isNotEmpty) {
diff --git a/packages/file/lib/src/backends/memory/memory_link.dart b/packages/file/lib/src/backends/memory/memory_link.dart
index 31c084a..a3cba7c 100644
--- a/packages/file/lib/src/backends/memory/memory_link.dart
+++ b/packages/file/lib/src/backends/memory/memory_link.dart
@@ -9,6 +9,7 @@
 
 import 'memory_file_system_entity.dart';
 import 'node.dart';
+import 'operations.dart';
 import 'utils.dart' as utils;
 
 /// Internal implementation of [Link].
@@ -47,6 +48,7 @@
   @override
   void createSync(String target, {bool recursive = false}) {
     bool preexisting = true;
+    fileSystem.opHandle(path, FileSystemOp.create);
     internalCreateSync(
         createChild: (DirectoryNode parent, bool isFinalSegment) {
       if (isFinalSegment) {
diff --git a/packages/file/lib/src/backends/memory/node.dart b/packages/file/lib/src/backends/memory/node.dart
index 71c86d2..4cbb25c 100644
--- a/packages/file/lib/src/backends/memory/node.dart
+++ b/packages/file/lib/src/backends/memory/node.dart
@@ -5,6 +5,7 @@
 import 'dart:typed_data';
 
 import 'package:file/file.dart';
+import 'package:file/src/backends/memory/operations.dart';
 import 'package:file/src/io.dart' as io;
 
 import 'clock.dart';
@@ -41,6 +42,9 @@
 /// A [FileSystem] whose internal structure is made up of a tree of [Node]
 /// instances, rooted at a single node.
 abstract class NodeBasedFileSystem implements StyleableFileSystem {
+  /// An optional handle to hook into common file system operations.
+  void Function(String context, FileSystemOp operation) get opHandle;
+
   /// The root node.
   RootNode? get root;
 
diff --git a/packages/file/lib/src/backends/memory/operations.dart b/packages/file/lib/src/backends/memory/operations.dart
new file mode 100644
index 0000000..a3e47f4
--- /dev/null
+++ b/packages/file/lib/src/backends/memory/operations.dart
@@ -0,0 +1,60 @@
+// 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.
+
+/// A file system operation used by the [MemoryFileSytem] to allow
+/// tests to insert errors for certain operations.
+///
+/// This is not implemented as an enum to allow new values to be added in a
+/// backwards compatible manner.
+class FileSystemOp {
+  const FileSystemOp._(this._value);
+
+  // This field added to ensure const values can be different.
+  // ignore: unused_field
+  final int _value;
+
+  /// A file system operation used for all read methods.
+  ///
+  /// * [FileSystemEntity.readAsString]
+  /// * [FileSystemEntity.readAsStringSync]
+  /// * [FileSystemEntity.readAsBytes]
+  /// * [FileSystemEntity.readAsBytesSync]
+  static const FileSystemOp read = FileSystemOp._(0);
+
+  /// A file system operation used for all write methods.
+  ///
+  /// * [FileSystemEntity.writeAsString]
+  /// * [FileSystemEntity.writeAsStringSync]
+  /// * [FileSystemEntity.writeAsBytes]
+  /// * [FileSystemEntity.writeAsBytesSync]
+  static const FileSystemOp write = FileSystemOp._(1);
+
+  /// A file system operation used for all delete methods.
+  ///
+  /// * [FileSystemEntity.delete]
+  /// * [FileSystemEntity.deleteSync]
+  static const FileSystemOp delete = FileSystemOp._(2);
+
+  /// A file system operation used for all create methods.
+  ///
+  /// * [FileSystemEntity.create]
+  /// * [FileSystemEntity.createSync]
+  static const FileSystemOp create = FileSystemOp._(3);
+
+  @override
+  String toString() {
+    switch (_value) {
+      case 0:
+        return 'FileSystemOp.read';
+      case 1:
+        return 'FileSystemOp.write';
+      case 2:
+        return 'FileSystemOp.delete';
+      case 3:
+        return 'FileSystemOp.create';
+      default:
+        throw StateError('Invalid FileSytemOp type: $this');
+    }
+  }
+}
diff --git a/packages/file/pubspec.yaml b/packages/file/pubspec.yaml
index f7485f1..a627171 100644
--- a/packages/file/pubspec.yaml
+++ b/packages/file/pubspec.yaml
@@ -1,5 +1,5 @@
 name: file
-version: 6.0.1
+version: 6.1.0
 description:
   A pluggable, mockable file system abstraction for Dart. Supports local file
   system access, as well as in-memory file systems, record-replay file systems,
diff --git a/packages/file/test/memory_operations_test.dart b/packages/file/test/memory_operations_test.dart
new file mode 100644
index 0000000..e878317
--- /dev/null
+++ b/packages/file/test/memory_operations_test.dart
@@ -0,0 +1,148 @@
+// Copyright (c) 2017, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import 'package:file/file.dart';
+import 'package:file/memory.dart';
+import 'package:file/src/interface/file.dart';
+import 'package:test/test.dart';
+
+void main() {
+  test('Read operations invoke opHandle', () async {
+    List<String> contexts = <String>[];
+    List<FileSystemOp> operations = <FileSystemOp>[];
+    MemoryFileSystem fs = MemoryFileSystem.test(
+        opHandle: (String context, FileSystemOp operation) {
+      if (operation == FileSystemOp.read) {
+        contexts.add(context);
+        operations.add(operation);
+      }
+    });
+    final File file = fs.file('test')..createSync();
+
+    await file.readAsBytes();
+    file.readAsBytesSync();
+    await file.readAsString();
+    file.readAsStringSync();
+
+    expect(contexts, <String>['test', 'test', 'test', 'test']);
+    expect(operations, <FileSystemOp>[
+      FileSystemOp.read,
+      FileSystemOp.read,
+      FileSystemOp.read,
+      FileSystemOp.read
+    ]);
+  });
+
+  test('Write operations invoke opHandle', () async {
+    List<String> contexts = <String>[];
+    List<FileSystemOp> operations = <FileSystemOp>[];
+    MemoryFileSystem fs = MemoryFileSystem.test(
+        opHandle: (String context, FileSystemOp operation) {
+      if (operation == FileSystemOp.write) {
+        contexts.add(context);
+        operations.add(operation);
+      }
+    });
+    final File file = fs.file('test')..createSync();
+
+    await file.writeAsBytes(<int>[]);
+    file.writeAsBytesSync(<int>[]);
+    await file.writeAsString('');
+    file.writeAsStringSync('');
+
+    expect(contexts, <String>['test', 'test', 'test', 'test']);
+    expect(operations, <FileSystemOp>[
+      FileSystemOp.write,
+      FileSystemOp.write,
+      FileSystemOp.write,
+      FileSystemOp.write
+    ]);
+  });
+
+  test('Delete operations invoke opHandle', () async {
+    List<String> contexts = <String>[];
+    List<FileSystemOp> operations = <FileSystemOp>[];
+    MemoryFileSystem fs = MemoryFileSystem.test(
+        opHandle: (String context, FileSystemOp operation) {
+      if (operation == FileSystemOp.delete) {
+        contexts.add(context);
+        operations.add(operation);
+      }
+    });
+    final File file = fs.file('test')..createSync();
+    final Directory directory = fs.directory('testDir')..createSync();
+    final Link link = fs.link('testLink')..createSync('foo');
+
+    await file.delete();
+    file.createSync();
+    file.deleteSync();
+
+    await directory.delete();
+    directory.createSync();
+    directory.deleteSync();
+
+    await link.delete();
+    link.createSync('foo');
+    link.deleteSync();
+
+    expect(contexts,
+        <String>['test', 'test', 'testDir', 'testDir', 'testLink', 'testLink']);
+    expect(operations, <FileSystemOp>[
+      FileSystemOp.delete,
+      FileSystemOp.delete,
+      FileSystemOp.delete,
+      FileSystemOp.delete,
+      FileSystemOp.delete,
+      FileSystemOp.delete,
+    ]);
+  });
+
+  test('Create operations invoke opHandle', () async {
+    List<String> contexts = <String>[];
+    List<FileSystemOp> operations = <FileSystemOp>[];
+    MemoryFileSystem fs = MemoryFileSystem.test(
+        opHandle: (String context, FileSystemOp operation) {
+      if (operation == FileSystemOp.create) {
+        contexts.add(context);
+        operations.add(operation);
+      }
+    });
+    fs.file('testA').createSync();
+    await fs.file('testB').create();
+    fs.directory('testDirA').createSync();
+    await fs.directory('testDirB').create();
+    fs.link('testLinkA').createSync('foo');
+    await fs.link('testLinkB').create('foo');
+    fs.currentDirectory.createTempSync('tmp.bar');
+    await fs.currentDirectory.createTemp('tmp.bar');
+
+    expect(contexts, <dynamic>[
+      'testA',
+      'testB',
+      'testDirA',
+      'testDirB',
+      'testLinkA',
+      'testLinkB',
+      startsWith('/tmp.bar'),
+      startsWith('/tmp.bar'),
+    ]);
+    expect(operations, <FileSystemOp>[
+      FileSystemOp.create,
+      FileSystemOp.create,
+      FileSystemOp.create,
+      FileSystemOp.create,
+      FileSystemOp.create,
+      FileSystemOp.create,
+      FileSystemOp.create,
+      FileSystemOp.create,
+    ]);
+  });
+
+  test('FileSystemOp toString', () {
+    expect(FileSystemOp.create.toString(), 'FileSystemOp.create');
+    expect(FileSystemOp.delete.toString(), 'FileSystemOp.delete');
+    expect(FileSystemOp.read.toString(), 'FileSystemOp.read');
+    expect(FileSystemOp.write.toString(), 'FileSystemOp.write');
+  });
+}
diff --git a/packages/file/test/memory_test.dart b/packages/file/test/memory_test.dart
index cb241d2..8e358be 100644
--- a/packages/file/test/memory_test.dart
+++ b/packages/file/test/memory_test.dart
@@ -4,6 +4,7 @@
 
 import 'dart:io' as io;
 
+import 'package:file/file.dart';
 import 'package:file/memory.dart';
 import 'package:file/src/backends/memory/memory_random_access_file.dart';
 import 'package:test/test.dart';
@@ -131,4 +132,20 @@
     expect(fooAA.path, '/.tmp_rand0/foorand0');
     expect(fooBB.path, '/.tmp_rand0/foorand1');
   });
+
+  test('Failed UTF8 decoding in MemoryFileSystem throws a FileSystemException',
+      () {
+    final MemoryFileSystem fileSystem = MemoryFileSystem.test();
+    final File file = fileSystem.file('foo')
+      ..writeAsBytesSync(<int>[0xFFFE]); // Invalid UTF8
+
+    expect(file.readAsStringSync, throwsA(isA<FileSystemException>()));
+  });
+
+  test('Creating a temporary directory actually creates the directory', () {
+    final MemoryFileSystem fileSystem = MemoryFileSystem.test();
+    final Directory tempDir = fileSystem.currentDirectory.createTempSync('foo');
+
+    expect(tempDir.existsSync(), true);
+  });
 }