// Copyright 2014 The Flutter Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. import 'dart:async'; import 'dart:convert'; import 'dart:io' as io show Process, ProcessResult, ProcessSignal, ProcessStartMode, systemEncoding; import 'package:file/file.dart'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; import 'test_wrapper.dart'; export 'package:process/process.dart' show ProcessManager; typedef VoidCallback = void Function(); /// A command for [FakeProcessManager]. @immutable class FakeCommand { const FakeCommand({ required this.command, this.workingDirectory, this.environment, this.encoding, this.duration = Duration.zero, this.onRun, this.exitCode = 0, this.stdout = '', this.stderr = '', this.completer, this.stdin, this.exception, this.outputFollowsExit = false, }) : assert(command != null), assert(duration != null), assert(exitCode != null); /// The exact commands that must be matched for this [FakeCommand] to be /// considered correct. final List<String> command; /// The exact working directory that must be matched for this [FakeCommand] to /// be considered correct. /// /// If this is null, the working directory is ignored. final String? workingDirectory; /// The environment that must be matched for this [FakeCommand] to be considered correct. /// /// If this is null, then the environment is ignored. /// /// Otherwise, each key in this environment must be present and must have a /// value that matches the one given here for the [FakeCommand] to match. final Map<String, String>? environment; /// The stdout and stderr encoding that must be matched for this [FakeCommand] /// to be considered correct. /// /// If this is null, then the encodings are ignored. final Encoding? encoding; /// The time to allow to elapse before returning the [exitCode], if this command /// is "executed". /// /// If you set this to a non-zero time, you should use a [FakeAsync] zone, /// otherwise the test will be artificially slow. final Duration duration; /// A callback that is run after [duration] expires but before the [exitCode] /// (and output) are passed back. final VoidCallback? onRun; /// The process' exit code. /// /// To simulate a never-ending process, set [duration] to a value greater than /// 15 minutes (the timeout for our tests). /// /// To simulate a crash, subtract the crash signal number from 256. For example, /// SIGPIPE (-13) is 243. final int exitCode; /// The output to simulate on stdout. This will be encoded as UTF-8 and /// returned in one go. final String stdout; /// The output to simulate on stderr. This will be encoded as UTF-8 and /// returned in one go. final String stderr; /// If provided, allows the command completion to be blocked until the future /// resolves. final Completer<void>? completer; /// An optional stdin sink that will be exposed through the resulting /// [FakeProcess]. final IOSink? stdin; /// If provided, this exception will be thrown when the fake command is run. final Object? exception; /// When true, stdout and stderr will only be emitted after the `exitCode` /// [Future] on [io.Process] completes. final bool outputFollowsExit; void _matches( List<String> command, String? workingDirectory, Map<String, String>? environment, Encoding? encoding, ) { expect(command, equals(this.command)); if (this.workingDirectory != null) { expect(this.workingDirectory, workingDirectory); } if (this.environment != null) { expect(this.environment, environment); } if (this.encoding != null) { expect(this.encoding, encoding); } } } /// A fake process for use with [FakeProcessManager]. /// /// The process delays exit until both [duration] (if specified) has elapsed /// and [completer] (if specified) has completed. /// /// When [outputFollowsExit] is specified, bytes are streamed to [stderr] and /// [stdout] after the process exits. @visibleForTesting class FakeProcess implements io.Process { FakeProcess({ int exitCode = 0, Duration duration = Duration.zero, this.pid = 1234, List<int> stderr = const <int>[], IOSink? stdin, List<int> stdout = const <int>[], Completer<void>? completer, bool outputFollowsExit = false, }) : _exitCode = exitCode, exitCode = Future<void>.delayed(duration).then((void value) { if (completer != null) { return completer.future.then((void _) => exitCode); } return exitCode; }), _stderr = stderr, stdin = stdin ?? IOSink(StreamController<List<int>>().sink), _stdout = stdout, _completer = completer { if (_stderr.isEmpty) { this.stderr = const Stream<List<int>>.empty(); } else if (outputFollowsExit) { // Wait for the process to exit before emitting stderr. this.stderr = Stream<List<int>>.fromFuture(this.exitCode.then((_) { // Return a Future so stderr isn't immediately available to those who // await exitCode, but is available asynchronously later. return Future<List<int>>(() => _stderr); })); } else { this.stderr = Stream<List<int>>.value(_stderr); } if (_stdout.isEmpty) { this.stdout = const Stream<List<int>>.empty(); } else if (outputFollowsExit) { // Wait for the process to exit before emitting stdout. this.stdout = Stream<List<int>>.fromFuture(this.exitCode.then((_) { // Return a Future so stdout isn't immediately available to those who // await exitCode, but is available asynchronously later. return Future<List<int>>(() => _stdout); })); } else { this.stdout = Stream<List<int>>.value(_stdout); } } /// The process exit code. final int _exitCode; /// When specified, blocks process exit until completed. final Completer<void>? _completer; @override final Future<int> exitCode; @override final int pid; /// The raw byte content of stderr. final List<int> _stderr; @override late final Stream<List<int>> stderr; @override final IOSink stdin; @override late final Stream<List<int>> stdout; /// The raw byte content of stdout. final List<int> _stdout; @override bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) { // Killing a fake process has no effect. return false; } } abstract class FakeProcessManager implements ProcessManager { /// A fake [ProcessManager] which responds to all commands as if they had run /// instantaneously with an exit code of 0 and no output. factory FakeProcessManager.any() = _FakeAnyProcessManager; /// A fake [ProcessManager] which responds to particular commands with /// particular results. /// /// On creation, pass in a list of [FakeCommand] objects. When the /// [ProcessManager] methods such as [start] are invoked, the next /// [FakeCommand] must match (otherwise the test fails); its settings are used /// to simulate the result of running that command. /// /// If no command is found, then one is implied which immediately returns exit /// code 0 with no output. /// /// There is no logic to ensure that all the listed commands are run. Use /// [FakeCommand.onRun] to set a flag, or specify a sentinel command as your /// last command and verify its execution is successful, to ensure that all /// the specified commands are actually called. factory FakeProcessManager.list(List<FakeCommand> commands) = _SequenceProcessManager; factory FakeProcessManager.empty() => _SequenceProcessManager(<FakeCommand>[]); FakeProcessManager._(); /// Adds a new [FakeCommand] to the current process manager. /// /// This can be used to configure test expectations after the [ProcessManager] has been /// provided to another interface. /// /// This is a no-op on [FakeProcessManager.any]. void addCommand(FakeCommand command); /// Add multiple [FakeCommand] to the current process manager. void addCommands(Iterable<FakeCommand> commands) { commands.forEach(addCommand); } final Map<int, FakeProcess> _fakeRunningProcesses = <int, FakeProcess>{}; /// Whether this fake has more [FakeCommand]s that are expected to run. /// /// This is always `true` for [FakeProcessManager.any]. bool get hasRemainingExpectations; /// The expected [FakeCommand]s that have not yet run. List<FakeCommand> get _remainingExpectations; @protected FakeCommand findCommand( List<String> command, String? workingDirectory, Map<String, String>? environment, Encoding? encoding, ); int _pid = 9999; FakeProcess _runCommand( List<String> command, String? workingDirectory, Map<String, String>? environment, Encoding? encoding, ) { _pid += 1; final FakeCommand fakeCommand = findCommand(command, workingDirectory, environment, encoding); if (fakeCommand.exception != null) { assert(fakeCommand.exception is Exception || fakeCommand.exception is Error); throw fakeCommand.exception!; // ignore: only_throw_errors } if (fakeCommand.onRun != null) { fakeCommand.onRun!(); } return FakeProcess( duration: fakeCommand.duration, exitCode: fakeCommand.exitCode, pid: _pid, stderr: encoding?.encode(fakeCommand.stderr) ?? fakeCommand.stderr.codeUnits, stdin: fakeCommand.stdin, stdout: encoding?.encode(fakeCommand.stdout) ?? fakeCommand.stdout.codeUnits, completer: fakeCommand.completer, outputFollowsExit: fakeCommand.outputFollowsExit, ); } @override Future<io.Process> start( List<dynamic> command, { String? workingDirectory, Map<String, String>? environment, bool includeParentEnvironment = true, // ignored bool runInShell = false, // ignored io.ProcessStartMode mode = io.ProcessStartMode.normal, // ignored }) { final FakeProcess process = _runCommand(command.cast<String>(), workingDirectory, environment, io.systemEncoding); if (process._completer != null) { _fakeRunningProcesses[process.pid] = process; process.exitCode.whenComplete(() { _fakeRunningProcesses.remove(process.pid); }); } return Future<io.Process>.value(process); } @override Future<io.ProcessResult> run( List<dynamic> command, { String? workingDirectory, Map<String, String>? environment, bool includeParentEnvironment = true, // ignored bool runInShell = false, // ignored Encoding? stdoutEncoding = io.systemEncoding, Encoding? stderrEncoding = io.systemEncoding, }) async { final FakeProcess process = _runCommand(command.cast<String>(), workingDirectory, environment, stdoutEncoding); await process.exitCode; return io.ProcessResult( process.pid, process._exitCode, stdoutEncoding == null ? process._stdout : await stdoutEncoding.decodeStream(process.stdout), stderrEncoding == null ? process._stderr : await stderrEncoding.decodeStream(process.stderr), ); } @override io.ProcessResult runSync( List<dynamic> command, { String? workingDirectory, Map<String, String>? environment, bool includeParentEnvironment = true, // ignored bool runInShell = false, // ignored Encoding? stdoutEncoding = io.systemEncoding, Encoding? stderrEncoding = io.systemEncoding, }) { final FakeProcess process = _runCommand(command.cast<String>(), workingDirectory, environment, stdoutEncoding); return io.ProcessResult( process.pid, process._exitCode, stdoutEncoding == null ? process._stdout : stdoutEncoding.decode(process._stdout), stderrEncoding == null ? process._stderr : stderrEncoding.decode(process._stderr), ); } /// Returns false if executable in [excludedExecutables]. @override bool canRun(dynamic executable, {String? workingDirectory}) => !excludedExecutables.contains(executable); Set<String> excludedExecutables = <String>{}; @override bool killPid(int pid, [io.ProcessSignal signal = io.ProcessSignal.sigterm]) { // Killing a fake process has no effect unless it has an attached completer. final FakeProcess? fakeProcess = _fakeRunningProcesses[pid]; if (fakeProcess == null) { return false; } if (fakeProcess._completer != null) { fakeProcess._completer!.complete(); } return true; } } class _FakeAnyProcessManager extends FakeProcessManager { _FakeAnyProcessManager() : super._(); @override FakeCommand findCommand( List<String> command, String? workingDirectory, Map<String, String>? environment, Encoding? encoding, ) { return FakeCommand( command: command, workingDirectory: workingDirectory, environment: environment, encoding: encoding, ); } @override void addCommand(FakeCommand command) { } @override bool get hasRemainingExpectations => true; @override List<FakeCommand> get _remainingExpectations => <FakeCommand>[]; } class _SequenceProcessManager extends FakeProcessManager { _SequenceProcessManager(this._commands) : super._(); final List<FakeCommand> _commands; @override FakeCommand findCommand( List<String> command, String? workingDirectory, Map<String, String>? environment, Encoding? encoding, ) { expect(_commands, isNotEmpty, reason: 'ProcessManager was told to execute $command (in $workingDirectory) ' 'but the FakeProcessManager.list expected no more processes.' ); _commands.first._matches(command, workingDirectory, environment, encoding); return _commands.removeAt(0); } @override void addCommand(FakeCommand command) { _commands.add(command); } @override bool get hasRemainingExpectations => _commands.isNotEmpty; @override List<FakeCommand> get _remainingExpectations => _commands; } /// Matcher that successfully matches against a [FakeProcessManager] with /// no remaining expectations ([item.hasRemainingExpectations] returns false). const Matcher hasNoRemainingExpectations = _HasNoRemainingExpectations(); class _HasNoRemainingExpectations extends Matcher { const _HasNoRemainingExpectations(); @override bool matches(dynamic item, Map<dynamic, dynamic> matchState) => item is FakeProcessManager && !item.hasRemainingExpectations; @override Description describe(Description description) => description.add('a fake process manager with no remaining expectations'); @override Description describeMismatch( dynamic item, Description description, Map<dynamic, dynamic> matchState, bool verbose, ) { final FakeProcessManager fakeProcessManager = item as FakeProcessManager; return description.add( 'has remaining expectations:\n${fakeProcessManager._remainingExpectations.map((FakeCommand command) => command.command).join('\n')}'); } }