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'];