1// Copyright 2014 The Flutter Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import 'dart:async';
6import 'dart:convert';
7import 'dart:core' hide print;
8import 'dart:io' as io;
9
10import 'package:path/path.dart' as path;
11
12import 'utils.dart';
13
14/// Runs the `executable` and returns standard output as a stream of lines.
15///
16/// The returned stream reaches its end immediately after the command exits.
17///
18/// If `expectNonZeroExit` is false and the process exits with a non-zero exit
19/// code fails the test immediately by exiting the test process with exit code
20/// 1.
21Stream<String> runAndGetStdout(
22 String executable,
23 List<String> arguments, {
24 String? workingDirectory,
25 Map<String, String>? environment,
26 bool expectNonZeroExit = false,
27}) async* {
28 final StreamController<String> output = StreamController<String>();
29 final Future<CommandResult?> command = runCommand(
30 executable,
31 arguments,
32 workingDirectory: workingDirectory,
33 environment: environment,
34 expectNonZeroExit: expectNonZeroExit,
35 // Capture the output so it's not printed to the console by default.
36 outputMode: OutputMode.capture,
37 outputListener: (String line, io.Process process) {
38 output.add(line);
39 },
40 );
41
42 // Close the stream controller after the command is complete. Otherwise,
43 // the yield* will never finish.
44 command.whenComplete(output.close);
45
46 yield* output.stream;
47}
48
49/// Represents a running process launched using [startCommand].
50class Command {
51 Command._(this.process, this._time, this._savedStdout, this._savedStderr);
52
53 /// The raw process that was launched for this command.
54 final io.Process process;
55 final Stopwatch _time;
56 final Future<String> _savedStdout;
57 final Future<String> _savedStderr;
58}
59
60/// The result of running a command using [startCommand] and [runCommand];
61class CommandResult {
62 CommandResult._(this.exitCode, this.elapsedTime, this.flattenedStdout, this.flattenedStderr);
63
64 /// The exit code of the process.
65 final int exitCode;
66
67 /// The amount of time it took the process to complete.
68 final Duration elapsedTime;
69
70 /// Standard output decoded as a string using UTF8 decoder.
71 final String? flattenedStdout;
72
73 /// Standard error output decoded as a string using UTF8 decoder.
74 final String? flattenedStderr;
75}
76
77/// Starts the `executable` and returns a command object representing the
78/// running process.
79///
80/// `outputListener` is called for every line of standard output from the
81/// process, and is given the [Process] object. This can be used to interrupt
82/// an indefinitely running process, for example, by waiting until the process
83/// emits certain output.
84///
85/// `outputMode` controls where the standard output from the command process
86/// goes. See [OutputMode].
87Future<Command> startCommand(
88 String executable,
89 List<String> arguments, {
90 String? workingDirectory,
91 Map<String, String>? environment,
92 OutputMode outputMode = OutputMode.print,
93 bool Function(String)? removeLine,
94 void Function(String, io.Process)? outputListener,
95}) async {
96 final String relativeWorkingDir = path.relative(workingDirectory ?? io.Directory.current.path);
97 final String commandDescription =
98 '${path.relative(executable, from: workingDirectory)} ${arguments.join(' ')}';
99 print('RUNNING: cd $cyan$relativeWorkingDir$reset; $green$commandDescription$reset');
100
101 final Stopwatch time = Stopwatch()..start();
102 print('workingDirectory: $workingDirectory, executable: $executable, arguments: $arguments');
103 final io.Process process = await io.Process.start(
104 executable,
105 arguments,
106 workingDirectory: workingDirectory,
107 environment: environment,
108 );
109 return Command._(
110 process,
111 time,
112 process.stdout
113 .transform<String>(const Utf8Decoder())
114 .transform(const LineSplitter())
115 .where((String line) => removeLine == null || !removeLine(line))
116 .map<String>((String line) {
117 final String formattedLine = '$line\n';
118 if (outputListener != null) {
119 outputListener(formattedLine, process);
120 }
121 switch (outputMode) {
122 case OutputMode.print:
123 print(line);
124 case OutputMode.capture:
125 break;
126 }
127 return line;
128 })
129 .join('\n'),
130 process.stderr
131 .transform<String>(const Utf8Decoder())
132 .transform(const LineSplitter())
133 .map<String>((String line) {
134 switch (outputMode) {
135 case OutputMode.print:
136 print(line);
137 case OutputMode.capture:
138 break;
139 }
140 return line;
141 })
142 .join('\n'),
143 );
144}
145
146/// Runs the `executable` and waits until the process exits.
147///
148/// If the process exits with a non-zero exit code and `expectNonZeroExit` is
149/// false, calls foundError (which does not terminate execution!).
150///
151/// `outputListener` is called for every line of standard output from the
152/// process, and is given the [Process] object. This can be used to interrupt
153/// an indefinitely running process, for example, by waiting until the process
154/// emits certain output.
155///
156/// Returns the result of the finished process.
157///
158/// `outputMode` controls where the standard output from the command process
159/// goes. See [OutputMode].
160Future<CommandResult> runCommand(
161 String executable,
162 List<String> arguments, {
163 String? workingDirectory,
164 Map<String, String>? environment,
165 bool expectNonZeroExit = false,
166 int? expectedExitCode,
167 String? failureMessage,
168 OutputMode outputMode = OutputMode.print,
169 bool Function(String)? removeLine,
170 void Function(String, io.Process)? outputListener,
171}) async {
172 final String commandDescription =
173 '${path.relative(executable, from: workingDirectory)} ${arguments.join(' ')}';
174 final String relativeWorkingDir = workingDirectory ?? path.relative(io.Directory.current.path);
175 if (dryRun) {
176 printProgress(_prettyPrintRunCommand(executable, arguments, workingDirectory));
177 return CommandResult._(
178 0,
179 Duration.zero,
180 '$executable ${arguments.join(' ')}',
181 'Simulated execution due to --dry-run',
182 );
183 }
184
185 final Command command = await startCommand(
186 executable,
187 arguments,
188 workingDirectory: workingDirectory,
189 environment: environment,
190 outputMode: outputMode,
191 removeLine: removeLine,
192 outputListener: outputListener,
193 );
194
195 final CommandResult result = CommandResult._(
196 await command.process.exitCode,
197 command._time.elapsed,
198 await command._savedStdout,
199 await command._savedStderr,
200 );
201
202 if ((result.exitCode == 0) == expectNonZeroExit ||
203 (expectedExitCode != null && result.exitCode != expectedExitCode)) {
204 // Print the output when we get unexpected results (unless output was
205 // printed already).
206 switch (outputMode) {
207 case OutputMode.print:
208 break;
209 case OutputMode.capture:
210 print(result.flattenedStdout);
211 print(result.flattenedStderr);
212 }
213 final String allOutput = '${result.flattenedStdout}\n${result.flattenedStderr}';
214 foundError(<String>[
215 if (failureMessage != null) failureMessage,
216 '${bold}Command: $green$commandDescription$reset',
217 if (failureMessage == null)
218 '$bold${red}Command exited with exit code ${result.exitCode} but expected ${expectNonZeroExit ? (expectedExitCode ?? 'non-zero') : 'zero'} exit code.$reset',
219 '${bold}Working directory: $cyan${path.absolute(relativeWorkingDir)}$reset',
220 if (allOutput.isNotEmpty && allOutput.length < 512)
221 '${bold}stdout and stderr output:\n$allOutput',
222 ]);
223 } else {
224 print(
225 'ELAPSED TIME: ${prettyPrintDuration(result.elapsedTime)} for $green$commandDescription$reset in $cyan$relativeWorkingDir$reset',
226 );
227 }
228 return result;
229}
230
231final String _flutterRoot = path.dirname(
232 path.dirname(path.dirname(path.fromUri(io.Platform.script))),
233);
234
235String _prettyPrintRunCommand(String executable, List<String> arguments, String? workingDirectory) {
236 final StringBuffer output = StringBuffer();
237
238 // Print CWD relative to the root.
239 output.write('|> ');
240 output.write(path.relative(executable, from: _flutterRoot));
241 if (workingDirectory != null) {
242 output.write(' (${path.relative(workingDirectory, from: _flutterRoot)})');
243 }
244 output.writeln(': ');
245 output.writeAll(arguments.map((String a) => ' $a'), '\n');
246
247 return output.toString();
248}
249
250/// Specifies what to do with the command output from [runCommand] and [startCommand].
251enum OutputMode {
252 /// Forwards standard output and standard error streams to the test process'
253 /// standard output stream (i.e. stderr is redirected to stdout).
254 ///
255 /// Use this mode if all you want is print the output of the command to the
256 /// console. The output is no longer available after the process exits.
257 print,
258
259 /// Saves standard output and standard error streams in memory.
260 ///
261 /// Captured output can be retrieved from the [CommandResult] object.
262 ///
263 /// Use this mode in tests that need to inspect the output of a command, or
264 /// when the output should not be printed to console.
265 capture,
266}
267