Enable record/replay of file system activity and platform metadata (#8104)

diff --git a/packages/flutter_tools/lib/src/base/file_system.dart b/packages/flutter_tools/lib/src/base/file_system.dart
index 5ec714b..e95eefe 100644
--- a/packages/flutter_tools/lib/src/base/file_system.dart
+++ b/packages/flutter_tools/lib/src/base/file_system.dart
@@ -5,14 +5,17 @@
 import 'package:file/file.dart';
 import 'package:file/local.dart';
 import 'package:file/memory.dart';
+import 'package:file/record_replay.dart';
 import 'package:path/path.dart' as path;
 
 import 'common.dart' show throwToolExit;
 import 'context.dart';
+import 'process.dart';
 
 export 'package:file/file.dart';
 export 'package:file/local.dart';
 
+const String _kRecordingType = 'file';
 const FileSystem _kLocalFs = const LocalFileSystem();
 
 /// Currently active implementation of the file system.
@@ -21,6 +24,36 @@
 /// with [MemoryFileSystem].
 FileSystem get fs => context == null ? _kLocalFs : context[FileSystem];
 
+/// Enables recording of file system activity to the specified base recording
+/// [location].
+///
+/// This sets the [active file system](fs) to one that records all invocation
+/// activity before delegating to a [LocalFileSystem].
+///
+/// Activity will be recorded in a subdirectory of [location] named `"file"`.
+/// It is permissible for [location] to represent an existing non-empty
+/// directory as long as there is no collision with the `"file"` subdirectory.
+void enableRecordingFileSystem(String location) {
+  Directory dir = getRecordingSink(location, _kRecordingType);
+  RecordingFileSystem fileSystem = new RecordingFileSystem(
+      delegate: _kLocalFs, destination: dir);
+  addShutdownHook(() => fileSystem.recording.flush());
+  context.setVariable(FileSystem, fileSystem);
+}
+
+/// Enables file system replay mode.
+///
+/// This sets the [active file system](fs) to one that replays invocation
+/// activity from a previously recorded set of invocations.
+///
+/// [location] must represent a directory to which file system activity has
+/// been recorded (i.e. the result of having been previously passed to
+/// [enableRecordingFileSystem]), or a [ToolExit] will be thrown.
+void enableReplayFileSystem(String location) {
+  Directory dir = getReplaySource(location, _kRecordingType);
+  context.setVariable(FileSystem, new ReplayFileSystem(recording: dir));
+}
+
 /// Create the ancestor directories of a file path if they do not already exist.
 void ensureDirectoryExists(String filePath) {
   String dirPath = path.dirname(filePath);
@@ -56,3 +89,44 @@
     }
   });
 }
+
+/// Gets a directory to act as a recording destination, creating the directory
+/// as necessary.
+///
+/// The directory will exist in the local file system, be named [basename], and
+/// be a child of the directory identified by [dirname].
+///
+/// If the target directory already exists as a directory, the existing
+/// directory must be empty, or a [ToolExit] will be thrown. If the target
+/// directory exists as an entity other than a directory, a [ToolExit] will
+/// also be thrown.
+Directory getRecordingSink(String dirname, String basename) {
+  String location = _kLocalFs.path.join(dirname, basename);
+  switch (_kLocalFs.typeSync(location, followLinks: false)) {
+    case FileSystemEntityType.FILE:
+    case FileSystemEntityType.LINK:
+      throwToolExit('Invalid record-to location: $dirname ("$basename" exists as non-directory)');
+      break;
+    case FileSystemEntityType.DIRECTORY:
+      if (_kLocalFs.directory(location).listSync(followLinks: false).isNotEmpty)
+        throwToolExit('Invalid record-to location: $dirname ("$basename" is not empty)');
+      break;
+    case FileSystemEntityType.NOT_FOUND:
+      _kLocalFs.directory(location).createSync(recursive: true);
+  }
+  return _kLocalFs.directory(location);
+}
+
+/// Gets a directory that holds a saved recording to be used for the purpose of
+/// replay.
+///
+/// The directory will exist in the local file system, be named [basename], and
+/// be a child of the directory identified by [dirname].
+///
+/// If the target directory does not exist, a [ToolExit] will be thrown.
+Directory getReplaySource(String dirname, String basename) {
+  Directory dir = _kLocalFs.directory(_kLocalFs.path.join(dirname, basename));
+  if (!dir.existsSync())
+    throwToolExit('Invalid replay-from location: $dirname ("$basename" does not exist)');
+  return dir;
+}
diff --git a/packages/flutter_tools/lib/src/base/platform.dart b/packages/flutter_tools/lib/src/base/platform.dart
index 6de07dd..2645a94 100644
--- a/packages/flutter_tools/lib/src/base/platform.dart
+++ b/packages/flutter_tools/lib/src/base/platform.dart
@@ -2,12 +2,41 @@
 // 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:platform/platform.dart';
 
 import 'context.dart';
+import 'file_system.dart';
 
 export 'package:platform/platform.dart';
 
 const Platform _kLocalPlatform = const LocalPlatform();
+const String _kRecordingType = 'platform';
 
 Platform get platform => context == null ? _kLocalPlatform : context[Platform];
+
+/// Enables serialization of the current [platform] to the specified base
+/// recording [location].
+///
+/// Platform metadata will be recorded in a subdirectory of [location] named
+/// `"platform"`. It is permissible for [location] to represent an existing
+/// non-empty directory as long as there is no collision with the `"platform"`
+/// subdirectory.
+Future<Null> enableRecordingPlatform(String location) async {
+  Directory dir = getRecordingSink(location, _kRecordingType);
+  File file = _getPlatformManifest(dir);
+  await file.writeAsString(platform.toJson(), flush: true);
+}
+
+Future<Null> enableReplayPlatform(String location) async {
+  Directory dir = getReplaySource(location, _kRecordingType);
+  File file = _getPlatformManifest(dir);
+  String json = await file.readAsString();
+  context.setVariable(Platform, new FakePlatform.fromJson(json));
+}
+
+File _getPlatformManifest(Directory dir) {
+  String path = dir.fileSystem.path.join(dir.path, 'MANIFEST.txt');
+  return dir.fileSystem.file(path);
+}
diff --git a/packages/flutter_tools/lib/src/base/process_manager.dart b/packages/flutter_tools/lib/src/base/process_manager.dart
index e5cab3d..382ef86 100644
--- a/packages/flutter_tools/lib/src/base/process_manager.dart
+++ b/packages/flutter_tools/lib/src/base/process_manager.dart
@@ -12,42 +12,26 @@
 import 'file_system.dart';
 import 'process.dart';
 
+const String _kRecordingType = 'process';
+
 /// The active process manager.
 ProcessManager get processManager => context[ProcessManager];
 
-/// Enables recording of process invocation activity to the specified location.
+/// Enables recording of process invocation activity to the specified base
+/// recording [location].
 ///
 /// This sets the [active process manager](processManager) to one that records
 /// all process activity before delegating to a [LocalProcessManager].
 ///
-/// [location] must either represent a valid, empty directory or a non-existent
-/// file system entity, in which case a directory will be created at that path.
-/// Process invocation activity will be serialized to opaque files in that
-/// directory. The resulting (populated) directory will be suitable for use
-/// with [enableReplayProcessManager].
+/// Activity will be recorded in a subdirectory of [location] named `"process"`.
+/// It is permissible for [location] to represent an existing non-empty
+/// directory as long as there is no collision with the `"process"`
+/// subdirectory.
 void enableRecordingProcessManager(String location) {
-  if (location.isEmpty)
-    throwToolExit('record-to location not specified');
-  switch (fs.typeSync(location, followLinks: false)) {
-    case FileSystemEntityType.FILE:
-    case FileSystemEntityType.LINK:
-      throwToolExit('record-to location must reference a directory');
-      break;
-    case FileSystemEntityType.DIRECTORY:
-      if (fs.directory(location).listSync(followLinks: false).isNotEmpty)
-        throwToolExit('record-to directory must be empty');
-      break;
-    case FileSystemEntityType.NOT_FOUND:
-      fs.directory(location).createSync(recursive: true);
-  }
-  Directory dir = fs.directory(location);
-
+  Directory dir = getRecordingSink(location, _kRecordingType);
   ProcessManager delegate = new LocalProcessManager();
   RecordingProcessManager manager = new RecordingProcessManager(delegate, dir);
-  addShutdownHook(() async {
-    await manager.flush(finishRunningProcesses: true);
-  });
-
+  addShutdownHook(() => manager.flush(finishRunningProcesses: true));
   context.setVariable(ProcessManager, manager);
 }
 
@@ -58,13 +42,9 @@
 ///
 /// [location] must represent a directory to which process activity has been
 /// recorded (i.e. the result of having been previously passed to
-/// [enableRecordingProcessManager]).
+/// [enableRecordingProcessManager]), or a [ToolExit] will be thrown.
 Future<Null> enableReplayProcessManager(String location) async {
-  if (location.isEmpty)
-    throwToolExit('replay-from location not specified');
-  Directory dir = fs.directory(location);
-  if (!dir.existsSync())
-    throwToolExit('replay-from location must reference a directory');
+  Directory dir = getReplaySource(location, _kRecordingType);
 
   ProcessManager manager;
   try {
diff --git a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart
index 9a57f51..78f2efb 100644
--- a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart
+++ b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart
@@ -100,7 +100,8 @@
         hide: !verboseHelp,
         help:
             'Enables recording of process invocations (including stdout and stderr of all such invocations),\n'
-            'and serializes that recording to a directory with the path specified in this flag. If the\n'
+            'and file system access (reads and writes).\n'
+            'Serializes that recording to a directory with the path specified in this flag. If the\n'
             'directory does not already exist, it will be created.');
     argParser.addOption('replay-from',
         hide: !verboseHelp,
@@ -163,11 +164,21 @@
       throwToolExit('--record-to and --replay-from cannot be used together.');
 
     if (globalResults['record-to'] != null) {
-      enableRecordingProcessManager(globalResults['record-to'].trim());
+      String recordTo = globalResults['record-to'].trim();
+      if (recordTo.isEmpty)
+        throwToolExit('record-to location not specified');
+      enableRecordingProcessManager(recordTo);
+      enableRecordingFileSystem(recordTo);
+      await enableRecordingPlatform(recordTo);
     }
 
     if (globalResults['replay-from'] != null) {
-      await enableReplayProcessManager(globalResults['replay-from'].trim());
+      String replayFrom = globalResults['replay-from'].trim();
+      if (replayFrom.isEmpty)
+        throwToolExit('replay-from location not specified');
+      await enableReplayProcessManager(replayFrom);
+      enableReplayFileSystem(replayFrom);
+      await enableReplayPlatform(replayFrom);
     }
 
     logger.quiet = globalResults['quiet'];