| // 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:core' hide print; | |
| import 'dart:io' as io; | |
| import 'package:path/path.dart' as path; | |
| import 'utils.dart'; | |
| /// Runs the `executable` and returns standard output as a stream of lines. | |
| /// | |
| /// The returned stream reaches its end immediately after the command exits. | |
| /// | |
| /// If `expectNonZeroExit` is false and the process exits with a non-zero exit | |
| /// code fails the test immediately by exiting the test process with exit code | |
| /// 1. | |
| Stream<String> runAndGetStdout( | |
| String executable, | |
| List<String> arguments, { | |
| String? workingDirectory, | |
| Map<String, String>? environment, | |
| bool expectNonZeroExit = false, | |
| }) async* { | |
| final output = StreamController<String>(); | |
| final Future<CommandResult?> command = runCommand( | |
| executable, | |
| arguments, | |
| workingDirectory: workingDirectory, | |
| environment: environment, | |
| expectNonZeroExit: expectNonZeroExit, | |
| // Capture the output so it's not printed to the console by default. | |
| outputMode: OutputMode.capture, | |
| outputListener: (String line, io.Process process) { | |
| output.add(line); | |
| }, | |
| ); | |
| // Close the stream controller after the command is complete. Otherwise, | |
| // the yield* will never finish. | |
| // ignore: unawaited_futures | |
| command.whenComplete(output.close); | |
| yield* output.stream; | |
| } | |
| /// Represents a running process launched using [startCommand]. | |
| class Command { | |
| Command._(this.process, this._time, this._savedStdout, this._savedStderr); | |
| /// The raw process that was launched for this command. | |
| final io.Process process; | |
| final Stopwatch _time; | |
| final Future<String> _savedStdout; | |
| final Future<String> _savedStderr; | |
| } | |
| /// The result of running a command using [startCommand] and [runCommand]; | |
| class CommandResult { | |
| CommandResult._(this.exitCode, this.elapsedTime, this.flattenedStdout, this.flattenedStderr); | |
| /// The exit code of the process. | |
| final int exitCode; | |
| /// The amount of time it took the process to complete. | |
| final Duration elapsedTime; | |
| /// Standard output decoded as a string using UTF8 decoder. | |
| final String? flattenedStdout; | |
| /// Standard error output decoded as a string using UTF8 decoder. | |
| final String? flattenedStderr; | |
| } | |
| /// Starts the `executable` and returns a command object representing the | |
| /// running process. | |
| /// | |
| /// `outputListener` is called for every line of standard output from the | |
| /// process, and is given the [Process] object. This can be used to interrupt | |
| /// an indefinitely running process, for example, by waiting until the process | |
| /// emits certain output. | |
| /// | |
| /// `outputMode` controls where the standard output from the command process | |
| /// goes. See [OutputMode]. | |
| Future<Command> startCommand( | |
| String executable, | |
| List<String> arguments, { | |
| String? workingDirectory, | |
| Map<String, String>? environment, | |
| OutputMode outputMode = OutputMode.print, | |
| bool Function(String)? removeLine, | |
| void Function(String, io.Process)? outputListener, | |
| }) async { | |
| final String relativeWorkingDir = path.relative(workingDirectory ?? io.Directory.current.path); | |
| final commandDescription = | |
| '${path.relative(executable, from: workingDirectory)} ${arguments.join(' ')}'; | |
| print('RUNNING: cd $cyan$relativeWorkingDir$reset; $green$commandDescription$reset'); | |
| final time = Stopwatch()..start(); | |
| print('workingDirectory: $workingDirectory, executable: $executable, arguments: $arguments'); | |
| // Override ANDROID_NDK_PATH with a valid discovered path or empty string to clear a potentially bad value. | |
| // See also dev/devicelab/lib/framework/utils.dart. | |
| final finalEnvironment = <String, String>{ | |
| ...?environment, | |
| 'ANDROID_NDK_PATH': ?_discoverBestNdkPath(), | |
| }; | |
| final io.Process process = await io.Process.start( | |
| executable, | |
| arguments, | |
| workingDirectory: workingDirectory, | |
| environment: finalEnvironment, | |
| ); | |
| return Command._( | |
| process, | |
| time, | |
| process.stdout | |
| .transform<String>(const Utf8Decoder()) | |
| .transform(const LineSplitter()) | |
| .where((String line) => removeLine == null || !removeLine(line)) | |
| .map<String>((String line) { | |
| final formattedLine = '$line\n'; | |
| if (outputListener != null) { | |
| outputListener(formattedLine, process); | |
| } | |
| switch (outputMode) { | |
| case OutputMode.print: | |
| print(line); | |
| case OutputMode.capture: | |
| break; | |
| } | |
| return line; | |
| }) | |
| .join('\n'), | |
| process.stderr | |
| .transform<String>(const Utf8Decoder()) | |
| .transform(const LineSplitter()) | |
| .map<String>((String line) { | |
| switch (outputMode) { | |
| case OutputMode.print: | |
| print(line); | |
| case OutputMode.capture: | |
| break; | |
| } | |
| return line; | |
| }) | |
| .join('\n'), | |
| ); | |
| } | |
| String? _bestNdkPath; | |
| String? _discoverBestNdkPath() { | |
| if (_bestNdkPath != null) { | |
| return _bestNdkPath; | |
| } | |
| // If we found a valid NDK, return it. | |
| // Otherwise return empty string to clear any bad inherited value. | |
| final Map<String, String> env = io.Platform.environment; | |
| String? androidHome = env['ANDROID_HOME'] ?? env['ANDROID_SDK_ROOT']; | |
| if (androidHome == null) { | |
| // Try to find it in common default locations. | |
| if (io.Platform.isMacOS) { | |
| final String? home = env['HOME']; | |
| if (home != null) { | |
| androidHome = path.join(home, 'Library', 'Android', 'sdk'); | |
| } | |
| } else if (io.Platform.isLinux) { | |
| final String? home = env['HOME']; | |
| if (home != null) { | |
| androidHome = path.join(home, 'Android', 'Sdk'); | |
| } | |
| } else if (io.Platform.isWindows) { | |
| final String? homeDrive = env['HOMEDRIVE']; | |
| final String? homePath = env['HOMEPATH']; | |
| if (homeDrive != null && homePath != null) { | |
| androidHome = path.join(homeDrive + homePath, 'AppData', 'Local', 'Android', 'sdk'); | |
| } | |
| } | |
| } | |
| if (androidHome == null) { | |
| return _bestNdkPath = null; | |
| } | |
| final ndkDir = io.Directory(path.join(androidHome, 'ndk')); | |
| if (!ndkDir.existsSync()) { | |
| return _bestNdkPath = null; | |
| } | |
| final versions = <String>[]; | |
| for (final io.FileSystemEntity entity in ndkDir.listSync()) { | |
| if (entity is io.Directory) { | |
| final String name = path.basename(entity.path); | |
| // flutter_tools ignores non-SemVer directories. NDK versions should start with a number. | |
| if (int.tryParse(name.split('.').first) != null) { | |
| versions.add(name); | |
| } | |
| } | |
| } | |
| if (versions.isEmpty) { | |
| return _bestNdkPath = null; | |
| } | |
| // Sort numerically/lexicographically to find the "highest" version. | |
| // We assume versions are like "21.4.7075529" or "23.1.7779620". | |
| // A simple sort works for major versions if they have the same number of digits, | |
| // but to be safe we parse the major version. | |
| versions.sort((String a, String b) { | |
| // Try to parse major version. | |
| final int? aMajor = int.tryParse(a.split('.').first); | |
| final int? bMajor = int.tryParse(b.split('.').first); | |
| if (aMajor != null && bMajor != null) { | |
| if (aMajor != bMajor) { | |
| return aMajor.compareTo(bMajor); | |
| } | |
| } | |
| // Fallback to string comparison. | |
| return a.compareTo(b); | |
| }); | |
| return _bestNdkPath = path.join(ndkDir.path, versions.last); | |
| } | |
| /// Runs the `executable` and waits until the process exits. | |
| /// | |
| /// If the process exits with a non-zero exit code and `expectNonZeroExit` is | |
| /// false, calls foundError (which does not terminate execution!). | |
| /// | |
| /// `outputListener` is called for every line of standard output from the | |
| /// process, and is given the [Process] object. This can be used to interrupt | |
| /// an indefinitely running process, for example, by waiting until the process | |
| /// emits certain output. | |
| /// | |
| /// Returns the result of the finished process. | |
| /// | |
| /// `outputMode` controls where the standard output from the command process | |
| /// goes. See [OutputMode]. | |
| Future<CommandResult> runCommand( | |
| String executable, | |
| List<String> arguments, { | |
| String? workingDirectory, | |
| Map<String, String>? environment, | |
| bool expectNonZeroExit = false, | |
| int? expectedExitCode, | |
| String? failureMessage, | |
| OutputMode outputMode = OutputMode.print, | |
| bool Function(String)? removeLine, | |
| void Function(String, io.Process)? outputListener, | |
| }) async { | |
| final commandDescription = | |
| '${path.relative(executable, from: workingDirectory)} ${arguments.join(' ')}'; | |
| final String relativeWorkingDir = workingDirectory ?? path.relative(io.Directory.current.path); | |
| if (dryRun) { | |
| printProgress(_prettyPrintRunCommand(executable, arguments, workingDirectory)); | |
| return CommandResult._( | |
| 0, | |
| Duration.zero, | |
| '$executable ${arguments.join(' ')}', | |
| 'Simulated execution due to --dry-run', | |
| ); | |
| } | |
| final Command command = await startCommand( | |
| executable, | |
| arguments, | |
| workingDirectory: workingDirectory, | |
| environment: environment, | |
| outputMode: outputMode, | |
| removeLine: removeLine, | |
| outputListener: outputListener, | |
| ); | |
| final result = CommandResult._( | |
| await command.process.exitCode, | |
| command._time.elapsed, | |
| await command._savedStdout, | |
| await command._savedStderr, | |
| ); | |
| if ((result.exitCode == 0) == expectNonZeroExit || | |
| (expectedExitCode != null && result.exitCode != expectedExitCode)) { | |
| // Print the output when we get unexpected results (unless output was | |
| // printed already). | |
| switch (outputMode) { | |
| case OutputMode.print: | |
| break; | |
| case OutputMode.capture: | |
| print(result.flattenedStdout); | |
| print(result.flattenedStderr); | |
| } | |
| final allOutput = '${result.flattenedStdout}\n${result.flattenedStderr}'; | |
| foundError(<String>[ | |
| ?failureMessage, | |
| '${bold}Command: $green$commandDescription$reset', | |
| if (failureMessage == null) | |
| '$bold${red}Command exited with exit code ${result.exitCode} but expected ${expectNonZeroExit ? (expectedExitCode ?? 'non-zero') : 'zero'} exit code.$reset', | |
| '${bold}Working directory: $cyan${path.absolute(relativeWorkingDir)}$reset', | |
| if (allOutput.isNotEmpty && allOutput.length < 512) | |
| '${bold}stdout and stderr output:\n$allOutput', | |
| ]); | |
| } else { | |
| print( | |
| 'ELAPSED TIME: ${prettyPrintDuration(result.elapsedTime)} for $green$commandDescription$reset in $cyan$relativeWorkingDir$reset', | |
| ); | |
| } | |
| return result; | |
| } | |
| final String _flutterRoot = path.dirname( | |
| path.dirname(path.dirname(path.fromUri(io.Platform.script))), | |
| ); | |
| String _prettyPrintRunCommand(String executable, List<String> arguments, String? workingDirectory) { | |
| final output = StringBuffer(); | |
| // Print CWD relative to the root. | |
| output.write('|> '); | |
| output.write(path.relative(executable, from: _flutterRoot)); | |
| if (workingDirectory != null) { | |
| output.write(' (${path.relative(workingDirectory, from: _flutterRoot)})'); | |
| } | |
| output.writeln(': '); | |
| output.writeAll(arguments.map((String a) => ' $a'), '\n'); | |
| return output.toString(); | |
| } | |
| /// Specifies what to do with the command output from [runCommand] and [startCommand]. | |
| enum OutputMode { | |
| /// Forwards standard output and standard error streams to the test process' | |
| /// standard output stream (i.e. stderr is redirected to stdout). | |
| /// | |
| /// Use this mode if all you want is print the output of the command to the | |
| /// console. The output is no longer available after the process exits. | |
| print, | |
| /// Saves standard output and standard error streams in memory. | |
| /// | |
| /// Captured output can be retrieved from the [CommandResult] object. | |
| /// | |
| /// Use this mode in tests that need to inspect the output of a command, or | |
| /// when the output should not be printed to console. | |
| capture, | |
| } |