Add --replay-from argument to Flutter tools (#7146)

This argument will enable mocking of os-layer process invocations,
where the mock behavior will come from replaying a previously-
recorded set of invocations. At the point of process invocation,
the key metadata for the invocation will be looked up in the
recording's manifest, and iff a matching record exists in the
manifest, the process will be mocked out with data derived from
the corresponding recorded process (e.g. stdout, stderr, exit code).
diff --git a/packages/flutter_tools/lib/src/base/process_manager.dart b/packages/flutter_tools/lib/src/base/process_manager.dart
index 9a819c3..18ede47 100644
--- a/packages/flutter_tools/lib/src/base/process_manager.dart
+++ b/packages/flutter_tools/lib/src/base/process_manager.dart
@@ -11,21 +11,33 @@
 import 'package:path/path.dart' as path;
 
 import 'context.dart';
+import 'os.dart';
 import 'process.dart';
 
 ProcessManager get processManager => context[ProcessManager];
 
+const String _kManifestName = 'MANIFEST.txt';
+
+bool _areListsEqual/*<T>*/(List<dynamic/*=T*/> list1, List<dynamic/*=T*/> list2) {
+  int i = 0;
+  return list1 != null
+      && list2 != null
+      && list1.length == list2.length
+      && list1.every((dynamic element) => element == list2[i++]);
+}
+
 /// A class that manages the creation of operating system processes. This
 /// provides a lightweight wrapper around the underlying [Process] static
 /// methods to allow the implementation of these methods to be mocked out or
 /// decorated for testing or debugging purposes.
 class ProcessManager {
   Future<Process> start(
-      String executable,
-      List<String> arguments,
-      {String workingDirectory,
-       Map<String, String> environment,
-       ProcessStartMode mode: ProcessStartMode.NORMAL}) {
+    String executable,
+    List<String> arguments, {
+    String workingDirectory,
+    Map<String, String> environment,
+    ProcessStartMode mode: ProcessStartMode.NORMAL,
+  }) {
     return Process.start(
       executable,
       arguments,
@@ -36,12 +48,13 @@
   }
 
   Future<ProcessResult> run(
-      String executable,
-      List<String> arguments,
-      {String workingDirectory,
-       Map<String, String> environment,
-       Encoding stdoutEncoding: SYSTEM_ENCODING,
-       Encoding stderrEncoding: SYSTEM_ENCODING}) {
+    String executable,
+    List<String> arguments, {
+    String workingDirectory,
+    Map<String, String> environment,
+    Encoding stdoutEncoding: SYSTEM_ENCODING,
+    Encoding stderrEncoding: SYSTEM_ENCODING,
+  }) {
     return Process.run(
       executable,
       arguments,
@@ -53,12 +66,13 @@
   }
 
   ProcessResult runSync(
-      String executable,
-      List<String> arguments,
-      {String workingDirectory,
-       Map<String, String> environment,
-       Encoding stdoutEncoding: SYSTEM_ENCODING,
-       Encoding stderrEncoding: SYSTEM_ENCODING}) {
+    String executable,
+    List<String> arguments, {
+    String workingDirectory,
+    Map<String, String> environment,
+    Encoding stdoutEncoding: SYSTEM_ENCODING,
+    Encoding stderrEncoding: SYSTEM_ENCODING,
+  }) {
     return Process.runSync(
       executable,
       arguments,
@@ -85,7 +99,7 @@
     'xcrun',
   ];
 
-  final FileSystemEntity _recordTo;
+  final String _recordTo;
   final ProcessManager _delegate = new ProcessManager();
   final Directory _tmpDir = Directory.systemTemp.createTempSync('flutter_tools_');
   final List<Map<String, dynamic>> _manifest = <Map<String, dynamic>>[];
@@ -102,18 +116,18 @@
   /// If [recordTo] is a file (or doesn't exist), it is taken to be the name
   /// of the ZIP file that will be created, and the containing folder will be
   /// created as needed.
-  RecordingProcessManager({FileSystemEntity recordTo})
-      : _recordTo = recordTo ?? Directory.current {
+  RecordingProcessManager(this._recordTo) {
     addShutdownHook(_onShutdown);
   }
 
   @override
   Future<Process> start(
-      String executable,
-      List<String> arguments,
-      {String workingDirectory,
-       Map<String, String> environment,
-       ProcessStartMode mode: ProcessStartMode.NORMAL}) async {
+    String executable,
+    List<String> arguments, {
+    String workingDirectory,
+    Map<String, String> environment,
+    ProcessStartMode mode: ProcessStartMode.NORMAL,
+  }) async {
     Process process = await _delegate.start(
       executable,
       arguments,
@@ -122,8 +136,10 @@
       mode: mode,
     );
 
+    String basename = _getBasename(process.pid, executable, arguments);
     Map<String, dynamic> manifestEntry = _createManifestEntry(
       pid: process.pid,
+      basename: basename,
       executable: executable,
       arguments: arguments,
       workingDirectory: workingDirectory,
@@ -134,7 +150,7 @@
 
     _RecordingProcess result = new _RecordingProcess(
       manager: this,
-      basename: _getBasename(process.pid, executable, arguments),
+      basename: basename,
       delegate: process,
     );
     await result.startRecording();
@@ -148,12 +164,13 @@
 
   @override
   Future<ProcessResult> run(
-      String executable,
-      List<String> arguments,
-      {String workingDirectory,
-       Map<String, String> environment,
-       Encoding stdoutEncoding: SYSTEM_ENCODING,
-       Encoding stderrEncoding: SYSTEM_ENCODING}) async {
+    String executable,
+    List<String> arguments, {
+    String workingDirectory,
+    Map<String, String> environment,
+    Encoding stdoutEncoding: SYSTEM_ENCODING,
+    Encoding stderrEncoding: SYSTEM_ENCODING,
+  }) async {
     ProcessResult result = await _delegate.run(
       executable,
       arguments,
@@ -163,8 +180,10 @@
       stderrEncoding: stderrEncoding,
     );
 
+    String basename = _getBasename(result.pid, executable, arguments);
     _manifest.add(_createManifestEntry(
       pid: result.pid,
+      basename: basename,
       executable: executable,
       arguments: arguments,
       workingDirectory: workingDirectory,
@@ -174,7 +193,6 @@
       exitCode: result.exitCode,
     ));
 
-    String basename = _getBasename(result.pid, executable, arguments);
     await _recordData(result.stdout, stdoutEncoding, '$basename.stdout');
     await _recordData(result.stderr, stderrEncoding, '$basename.stderr');
 
@@ -198,12 +216,13 @@
 
   @override
   ProcessResult runSync(
-      String executable,
-      List<String> arguments,
-      {String workingDirectory,
-       Map<String, String> environment,
-       Encoding stdoutEncoding: SYSTEM_ENCODING,
-       Encoding stderrEncoding: SYSTEM_ENCODING}) {
+    String executable,
+    List<String> arguments, {
+    String workingDirectory,
+    Map<String, String> environment,
+    Encoding stdoutEncoding: SYSTEM_ENCODING,
+    Encoding stderrEncoding: SYSTEM_ENCODING,
+  }) {
     ProcessResult result = _delegate.runSync(
       executable,
       arguments,
@@ -213,8 +232,10 @@
       stderrEncoding: stderrEncoding,
     );
 
+    String basename = _getBasename(result.pid, executable, arguments);
     _manifest.add(_createManifestEntry(
       pid: result.pid,
+      basename: basename,
       executable: executable,
       arguments: arguments,
       workingDirectory: workingDirectory,
@@ -224,7 +245,6 @@
       exitCode: result.exitCode,
     ));
 
-    String basename = _getBasename(result.pid, executable, arguments);
     _recordDataSync(result.stdout, stdoutEncoding, '$basename.stdout');
     _recordDataSync(result.stderr, stderrEncoding, '$basename.stderr');
 
@@ -254,27 +274,29 @@
   /// Creates a JSON-encodable manifest entry representing the specified
   /// process invocation.
   Map<String, dynamic> _createManifestEntry({
-      int pid,
-      String executable,
-      List<String> arguments,
-      String workingDirectory,
-      Map<String, String> environment,
-      ProcessStartMode mode,
-      Encoding stdoutEncoding,
-      Encoding stderrEncoding,
-      int exitCode,
+    int pid,
+    String basename,
+    String executable,
+    List<String> arguments,
+    String workingDirectory,
+    Map<String, String> environment,
+    ProcessStartMode mode,
+    Encoding stdoutEncoding,
+    Encoding stderrEncoding,
+    int exitCode,
   }) {
-    Map<String, dynamic> entry = <String, dynamic>{};
-    if (pid != null) entry['pid'] = pid;
-    if (executable != null) entry['executable'] = executable;
-    if (arguments != null) entry['arguments'] = arguments;
-    if (workingDirectory != null) entry['workingDirectory'] = workingDirectory;
-    if (environment != null) entry['environment'] = environment;
-    if (mode != null) entry['mode'] = mode.toString();
-    if (stdoutEncoding != null) entry['stdoutEncoding'] = stdoutEncoding.name;
-    if (stderrEncoding != null) entry['stderrEncoding'] = stderrEncoding.name;
-    if (exitCode != null) entry['exitCode'] = exitCode;
-    return entry;
+    return new _ManifestEntryBuilder()
+      .add('pid', pid)
+      .add('basename', basename)
+      .add('executable', executable)
+      .add('arguments', arguments)
+      .add('workingDirectory', workingDirectory)
+      .add('environment', environment)
+      .add('mode', mode, () => mode.toString())
+      .add('stdoutEncoding', stdoutEncoding, () => stdoutEncoding.name)
+      .add('stderrEncoding', stderrEncoding, () => stderrEncoding.name)
+      .add('exitCode', exitCode)
+      .entry;
   }
 
   /// Returns a human-readable identifier for the specified executable.
@@ -323,7 +345,7 @@
     void onTimeout(int pid, Map<String, dynamic> manifestEntry),
   }) async {
     await Future.wait(new List<Future<int>>.from(_runningProcesses.values))
-        .timeout(new Duration(milliseconds: 20), onTimeout: () {
+        .timeout(const Duration(milliseconds: 20), onTimeout: () {
           _runningProcesses.forEach((int pid, Future<int> future) {
             Map<String, dynamic> manifestEntry = _manifest
                 .firstWhere((Map<String, dynamic> entry) => entry['pid'] == pid);
@@ -336,7 +358,7 @@
   Future<Null> _writeManifestToDisk() async {
     JsonEncoder encoder = new JsonEncoder.withIndent('  ');
     String encodedManifest = encoder.convert(_manifest);
-    File manifestFile = await new File('${_tmpDir.path}/process-manifest.txt').create();
+    File manifestFile = await new File('${_tmpDir.path}/$_kManifestName').create();
     await manifestFile.writeAsString(encodedManifest, flush: true);
   }
 
@@ -351,17 +373,18 @@
   /// in the [new RecordingProcessManager] constructor.
   Future<File> _createZipFile() async {
     File zipFile;
-    if (await FileSystemEntity.type(_recordTo.path) == FileSystemEntityType.DIRECTORY) {
-      zipFile = new File('${_recordTo.path}/$kDefaultRecordTo');
+    String recordTo = _recordTo ?? Directory.current.path;
+    if (FileSystemEntity.typeSync(recordTo) == FileSystemEntityType.DIRECTORY) {
+      zipFile = new File('$recordTo/$kDefaultRecordTo');
     } else {
-      zipFile = new File(_recordTo.path);
+      zipFile = new File(recordTo);
       await new Directory(path.dirname(zipFile.path)).create(recursive: true);
     }
 
     // Resolve collisions.
     String basename = path.basename(zipFile.path);
-    for (int i = 1; await zipFile.exists(); i++) {
-      assert(await FileSystemEntity.isFile(zipFile.path));
+    for (int i = 1; zipFile.existsSync(); i++) {
+      assert(FileSystemEntity.isFileSync(zipFile.path));
       String disambiguator = new NumberFormat('00').format(i);
       String newBasename = basename;
       if (basename.contains('.')) {
@@ -397,6 +420,21 @@
   }
 }
 
+/// A lightweight class that provides a builder pattern for building a
+/// manifest entry.
+class _ManifestEntryBuilder {
+  Map<String, dynamic> entry;
+
+  /// Adds the specified key/value pair to the manifest entry iff the value
+  /// is non-null. If [jsonValue] is specified, its value will be used instead
+  /// of the raw value.
+  _ManifestEntryBuilder add(String name, dynamic value, [dynamic jsonValue()]) {
+    if (value != null)
+      entry[name] = jsonValue == null ? value : jsonValue();
+    return this;
+  }
+}
+
 /// A [Process] implementation that records `stdout` and `stderr` stream events
 /// to disk before forwarding them on to the underlying stream listener.
 class _RecordingProcess implements Process {
@@ -479,3 +517,327 @@
   @override
   bool kill([ProcessSignal signal = ProcessSignal.SIGTERM]) => delegate.kill(signal);
 }
+
+/// A [ProcessManager] implementation that mocks out all process invocations
+/// by replaying a previously-recorded series of invocations, throwing an
+/// exception if the requested invocations substantively differ in any way
+/// from those in the recording.
+///
+/// Recordings are expected to be of the form produced by
+/// [RecordingProcessManager]. Namely, this includes:
+///
+/// - a [_kManifestName](manifest file) encoded as UTF-8 JSON that lists all
+///   invocations in order, along with the following metadata for each
+///   invocation:
+///   - `pid` (required): The process id integer.
+///   - `basename` (required): A string specifying the base filename from which
+///     the incovation's `stdout` and `stderr` files can be located.
+///   - `executable` (required): A string specifying the path to the executable
+///     command that kicked off the process.
+///   - `arguments` (required): A list of strings that were passed as arguments
+///     to the executable.
+///   - `workingDirectory` (required): The current working directory from which
+///     the process was spawned.
+///   - `environment` (required): A map from string environment variable keys
+///     to their corresponding string values.
+///   - `mode` (optional): A string specifying the [ProcessStartMode].
+///   - `stdoutEncoding` (optional): The name of the encoding scheme that was
+///     used in the `stdout` file. If unspecified, then the file was written
+///     as binary data.
+///   - `stderrEncoding` (optional): The name of the encoding scheme that was
+///     used in the `stderr` file. If unspecified, then the file was written
+///     as binary data.
+///   - `exitCode` (required): The exit code of the process, or null if the
+///     process was not responding.
+///   - `daemon` (optional): A boolean indicating that the process is to stay
+///     resident during the entire lifetime of the master Flutter tools process.
+/// - a `stdout` file for each process invocation. The location of this file
+///   can be derived from the `basename` manifest property like so:
+///   `'$basename.stdout'`.
+/// - a `stderr` file for each process invocation. The location of this file
+///   can be derived from the `basename` manifest property like so:
+///   `'$basename.stderr'`.
+class ReplayProcessManager implements ProcessManager {
+  final List<Map<String, dynamic>> _manifest;
+  final Directory _dir;
+
+  ReplayProcessManager._(this._manifest, this._dir);
+
+  /// Creates a new `ReplayProcessManager` capable of replaying a recording at
+  /// the specified location.
+  ///
+  /// If [location] represents a file, it will be treated like a recording
+  /// ZIP file. If it points to a directory, it will be treated like an
+  /// unzipped recording. If [location] points to a non-existent file or
+  /// directory, an [ArgumentError] will be thrown.
+  static Future<ReplayProcessManager> create(String location) async {
+    Directory dir;
+    switch (FileSystemEntity.typeSync(location)) {
+      case FileSystemEntityType.FILE:
+        dir = await Directory.systemTemp.createTemp('flutter_tools_');
+        os.unzip(new File(location), dir);
+        addShutdownHook(() async {
+          await dir.delete(recursive: true);
+        });
+        break;
+      case FileSystemEntityType.DIRECTORY:
+        dir = new Directory(location);
+        break;
+      case FileSystemEntityType.NOT_FOUND:
+        throw new ArgumentError.value(location, 'location', 'Does not exist');
+    }
+
+    File manifestFile = new File(path.join(dir.path, _kManifestName));
+    if (!manifestFile.existsSync()) {
+      // We use the existence of the manifest as a proxy for this being a
+      // valid replay directory. Namely, we don't validate the structure of the
+      // JSON within the manifest, and we don't validate the existence of
+      // all stdout and stderr files referenced in the manifest.
+      throw new ArgumentError.value(location, 'location',
+          'Does not represent a valid recording (it does not '
+          'contain $_kManifestName).');
+    }
+
+    String content = await manifestFile.readAsString();
+    try {
+      List<Map<String, dynamic>> manifest = new JsonDecoder().convert(content);
+      return new ReplayProcessManager._(manifest, dir);
+    } on FormatException {
+      throw new ArgumentError('$_kManifestName is not a valid JSON file.');
+    }
+  }
+
+  @override
+  Future<Process> start(
+    String executable,
+    List<String> arguments, {
+    String workingDirectory,
+    Map<String, String> environment,
+    ProcessStartMode mode: ProcessStartMode.NORMAL,
+  }) async {
+    Map<String, dynamic> entry = _popEntry(executable, arguments, mode: mode);
+    _ReplayProcessResult result = await _ReplayProcessResult.create(
+        executable, arguments, _dir, entry);
+    return result.asProcess(entry['daemon'] ?? false);
+  }
+
+  @override
+  Future<ProcessResult> run(
+    String executable,
+    List<String> arguments, {
+    String workingDirectory,
+    Map<String, String> environment,
+    Encoding stdoutEncoding: SYSTEM_ENCODING,
+    Encoding stderrEncoding: SYSTEM_ENCODING,
+  }) async {
+    Map<String, dynamic> entry = _popEntry(executable, arguments,
+        stdoutEncoding: stdoutEncoding, stderrEncoding: stderrEncoding);
+    return await _ReplayProcessResult.create(
+        executable, arguments, _dir, entry);
+  }
+
+  @override
+  ProcessResult runSync(
+    String executable,
+    List<String> arguments, {
+    String workingDirectory,
+    Map<String, String> environment,
+    Encoding stdoutEncoding: SYSTEM_ENCODING,
+    Encoding stderrEncoding: SYSTEM_ENCODING,
+  }) {
+    Map<String, dynamic> entry = _popEntry(executable, arguments,
+        stdoutEncoding: stdoutEncoding, stderrEncoding: stderrEncoding);
+    return _ReplayProcessResult.createSync(
+        executable, arguments, _dir, entry);
+  }
+
+  /// Finds and returns the next entry in the process manifest that matches
+  /// the specified process arguments. Once found, it marks the manifest entry
+  /// as having been invoked and thus not eligible for invocation again.
+  Map<String, dynamic> _popEntry(String executable, List<String> arguments, {
+    ProcessStartMode mode,
+    Encoding stdoutEncoding,
+    Encoding stderrEncoding,
+  }) {
+    Map<String, dynamic> entry = _manifest.firstWhere(
+      (Map<String, dynamic> entry) {
+        // Ignore workingDirectory & environment, as they could
+        // yield false negatives.
+        return entry['executable'] == executable
+            && _areListsEqual(entry['arguments'], arguments)
+            && entry['mode'] == mode?.toString()
+            && entry['stdoutEncoding'] == stdoutEncoding?.name
+            && entry['stderrEncoding'] == stderrEncoding?.name
+            && !(entry['invoked'] ?? false);
+      },
+      orElse: () => null,
+    );
+
+    if (entry == null)
+      throw new StateError('No matching invocation found for $executable');
+
+    entry['invoked'] = true;
+    return entry;
+  }
+
+  @override
+  bool killPid(int pid, [ProcessSignal signal = ProcessSignal.SIGTERM]) {
+    throw new UnsupportedError(
+        "$runtimeType.killPid() has not been implemented because at the time "
+        "of its writing, it wasn't needed. If you're hitting this error, you "
+        "should implement it.");
+  }
+}
+
+/// A [ProcessResult] implementation that derives its data from a recording
+/// fragment.
+class _ReplayProcessResult implements ProcessResult {
+  @override
+  final int pid;
+
+  @override
+  final int exitCode;
+
+  @override
+  final dynamic stdout;
+
+  @override
+  final dynamic stderr;
+
+  _ReplayProcessResult._({this.pid, this.exitCode, this.stdout, this.stderr});
+
+  static Future<_ReplayProcessResult> create(
+    String executable,
+    List<String> arguments,
+    Directory dir,
+    Map<String, dynamic> entry,
+  ) async {
+    String basePath = path.join(dir.path, entry['basename']);
+    try {
+      return new _ReplayProcessResult._(
+        pid: entry['pid'],
+        exitCode: entry['exitCode'],
+        stdout: await _getData('$basePath.stdout', entry['stdoutEncoding']),
+        stderr: await _getData('$basePath.stderr', entry['stderrEncoding']),
+      );
+    } catch (e) {
+      throw new ProcessException(executable, arguments, e.toString());
+    }
+  }
+
+  static Future<dynamic> _getData(String path, String encoding) async {
+    File file = new File(path);
+    return encoding == null
+        ? await file.readAsBytes()
+        : await file.readAsString(encoding: _getEncodingByName(encoding));
+  }
+
+  static _ReplayProcessResult createSync(
+    String executable,
+    List<String> arguments,
+    Directory dir,
+    Map<String, dynamic> entry,
+  ) {
+    String basePath = path.join(dir.path, entry['basename']);
+    try {
+      return new _ReplayProcessResult._(
+        pid: entry['pid'],
+        exitCode: entry['exitCode'],
+        stdout: _getDataSync('$basePath.stdout', entry['stdoutEncoding']),
+        stderr: _getDataSync('$basePath.stderr', entry['stderrEncoding']),
+      );
+    } catch (e) {
+      throw new ProcessException(executable, arguments, e.toString());
+    }
+  }
+
+  static dynamic _getDataSync(String path, String encoding) {
+    File file = new File(path);
+    return encoding == null
+        ? file.readAsBytesSync()
+        : file.readAsStringSync(encoding: _getEncodingByName(encoding));
+  }
+
+  static Encoding _getEncodingByName(String encoding) {
+    if (encoding == 'system')
+      return const SystemEncoding();
+    else if (encoding != null)
+      return Encoding.getByName(encoding);
+    return null;
+  }
+
+  Process asProcess(bool daemon) {
+    assert(stdout is List<int>);
+    assert(stderr is List<int>);
+    return new _ReplayProcess(this, daemon);
+  }
+}
+
+/// A [Process] implementation derives its data from a recording fragment.
+class _ReplayProcess implements Process {
+  @override
+  final int pid;
+
+  final List<int> _stdout;
+  final List<int> _stderr;
+  final StreamController<List<int>> _stdoutController;
+  final StreamController<List<int>> _stderrController;
+  final int _exitCode;
+  final Completer<int> _exitCodeCompleter;
+
+  _ReplayProcess(_ReplayProcessResult result, bool daemon)
+      : pid = result.pid,
+        _stdout = result.stdout,
+        _stderr = result.stderr,
+        _stdoutController = new StreamController<List<int>>(),
+        _stderrController = new StreamController<List<int>>(),
+        _exitCode = result.exitCode,
+        _exitCodeCompleter = new Completer<int>() {
+    // Don't flush our stdio streams until we reach the outer event loop. This
+    // is necessary because some of our process invocations transform the stdio
+    // streams into broadcast streams (e.g. DeviceLogReader implementations),
+    // and delaying our stdio stream production until we reach the outer event
+    // loop allows all code running in the microtask loop to register as
+    // listeners on these streams before we flush them.
+    //
+    // TODO(tvolkert): Once https://github.com/flutter/flutter/issues/7166 is
+    //                 resolved, running on the outer event loop should be
+    //                 sufficient (as described above), and we should switch to
+    //                 Duration.ZERO. In the meantime, native file I/O
+    //                 operations are causing a Duration.ZERO callback here to
+    //                 run before our ProtocolDiscovery instantiation, and thus,
+    //                 we flush our stdio streams before our protocol discovery
+    //                 is listening on them (causing us to timeout waiting for
+    //                 the observatory port discovery).
+    new Timer(const Duration(milliseconds: 50), () {
+      _stdoutController.add(_stdout);
+      _stderrController.add(_stderr);
+      if (!daemon)
+        kill();
+    });
+  }
+
+  @override
+  Stream<List<int>> get stdout => _stdoutController.stream;
+
+  @override
+  Stream<List<int>> get stderr => _stderrController.stream;
+
+  @override
+  Future<int> get exitCode => _exitCodeCompleter.future;
+
+  @override
+  set exitCode(Future<int> exitCode) => throw new UnsupportedError('set exitCode');
+
+  @override
+  IOSink get stdin => throw new UnimplementedError();
+
+  @override
+  bool kill([ProcessSignal signal = ProcessSignal.SIGTERM]) {
+    if (!_exitCodeCompleter.isCompleted) {
+      _exitCodeCompleter.complete(_exitCode);
+      return true;
+    }
+    return false;
+  }
+}
diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart
index a0e5a570..bc48e69 100644
--- a/packages/flutter_tools/lib/src/ios/simulators.dart
+++ b/packages/flutter_tools/lib/src/ios/simulators.dart
@@ -422,7 +422,8 @@
 
       try {
         await _setupUpdatedApplicationBundle(app);
-      } on ToolExit {
+      } on ToolExit catch (e) {
+        printError(e.message);
         return new LaunchResult.failed();
       }
     } else {
@@ -507,7 +508,7 @@
     // Step 2: Assert that the Xcode project was successfully built.
     IOSApp iosApp = app;
     Directory bundle = new Directory(iosApp.simulatorBundlePath);
-    bool bundleExists = await bundle.exists();
+    bool bundleExists = bundle.existsSync();
     if (!bundleExists)
       throwToolExit('Could not find the built application bundle at ${bundle.path}.');
 
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 1d1ce7e..d825725 100644
--- a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart
+++ b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart
@@ -10,6 +10,7 @@
 import 'package:path/path.dart' as path;
 
 import '../android/android_sdk.dart';
+import '../base/common.dart';
 import '../base/context.dart';
 import '../base/logger.dart';
 import '../base/process.dart';
@@ -102,6 +103,15 @@
             'If the location is a directory, a ZIP file named `recording.zip` '
             'will be created in that directory. Otherwise, a ZIP file will be '
             'created with the path specified in this flag.');
+    argParser.addOption('replay-from',
+        help:
+            'Enables mocking of process invocations by replaying their stdout, '
+            'stderr, and exit code from the specified recording (obtained '
+            'via --record-to).\n'
+            'If the location is a file, it is assumed to be a ZIP file '
+            'structured according to the output of --record-to. If the '
+            'location is a directory, it is assumed to be an unzipped version '
+            'of such a ZIP file.');
   }
 
   @override
@@ -151,17 +161,28 @@
       context.setVariable(Logger, new VerboseLogger());
     }
 
+    if (globalResults['record-to'] != null &&
+        globalResults['replay-from'] != null)
+      throwToolExit('--record-to and --replay-from cannot be used together.');
+
     if (globalResults['record-to'] != null) {
-      // Turn on recording
-      String recordToPath = globalResults['record-to'].trim();
-      FileSystemEntity recordTo;
-      if (recordToPath.isNotEmpty) {
-        recordTo = await FileSystemEntity.isDirectory(recordToPath)
-            ? new Directory(recordToPath)
-            : new File(recordToPath);
-      }
+      // Turn on recording.
+      String recordTo = globalResults['record-to'].trim();
+      if (recordTo.isEmpty)
+        recordTo = null;
       context.setVariable(ProcessManager,
-          new RecordingProcessManager(recordTo: recordTo));
+          new RecordingProcessManager(recordTo));
+    }
+
+    if (globalResults['replay-from'] != null) {
+      // Turn on replay-based mocking.
+      try {
+        context.setVariable(ProcessManager, await ReplayProcessManager.create(
+          globalResults['replay-from'].trim(),
+        ));
+      } on ArgumentError {
+        throwToolExit('--replay-from must specify a valid file or directory.');
+      }
     }
 
     logger.quiet = globalResults['quiet'];