| // Copyright 2013 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' show LineSplitter, utf8; | |
| import 'dart:io' as io; | |
| import 'package:args/command_runner.dart'; | |
| import 'package:meta/meta.dart'; | |
| import 'package:path/path.dart' as path; | |
| import 'common.dart'; | |
| import 'environment.dart'; | |
| import 'exceptions.dart'; | |
| import 'felt_config.dart'; | |
| enum RuntimeMode { | |
| debug, | |
| profile, | |
| release, | |
| } | |
| class FilePath { | |
| FilePath.fromCwd(String relativePath) | |
| : _absolutePath = path.absolute(relativePath); | |
| FilePath.fromWebUi(String relativePath) | |
| : _absolutePath = path.join(environment.webUiRootDir.path, relativePath); | |
| FilePath.fromTestSet(TestSet testSet, String relativePath) | |
| : _absolutePath = path.join(getTestSetDirectory(testSet).path, relativePath); | |
| final String _absolutePath; | |
| String get absolute => _absolutePath; | |
| String get relativeToCwd => path.relative(_absolutePath); | |
| String get relativeToWebUi => | |
| path.relative(_absolutePath, from: environment.webUiRootDir.path); | |
| @override | |
| bool operator ==(Object other) { | |
| return other is FilePath && other._absolutePath == _absolutePath; | |
| } | |
| @override | |
| int get hashCode => _absolutePath.hashCode; | |
| @override | |
| String toString() => _absolutePath; | |
| } | |
| /// Runs [executable] merging its output into the current process' standard out and standard error. | |
| Future<int> runProcess( | |
| String executable, | |
| List<String> arguments, { | |
| String? workingDirectory, | |
| bool failureIsSuccess = false, | |
| Map<String, String> environment = const <String, String>{}, | |
| }) async { | |
| final ProcessManager manager = await startProcess( | |
| executable, | |
| arguments, | |
| workingDirectory: workingDirectory, | |
| failureIsSuccess: failureIsSuccess, | |
| environment: environment, | |
| ); | |
| return manager.wait(); | |
| } | |
| /// Runs the process and returns its standard output as a string. | |
| /// | |
| /// Standard error output is ignored (use [ProcessManager.evalStderr] for that). | |
| /// | |
| /// Throws an exception if the process exited with a non-zero exit code. | |
| Future<String> evalProcess( | |
| String executable, | |
| List<String> arguments, { | |
| String? workingDirectory, | |
| Map<String, String> environment = const <String, String>{}, | |
| }) async { | |
| final ProcessManager manager = await startProcess( | |
| executable, | |
| arguments, | |
| workingDirectory: workingDirectory, | |
| environment: environment, | |
| evalOutput: true, | |
| ); | |
| return manager.evalStdout(); | |
| } | |
| /// Starts a process using the [executable], passing it [arguments]. | |
| /// | |
| /// Returns a process manager that decorates the process with extra | |
| /// functionality. See [ProcessManager] for what it can do. | |
| /// | |
| /// If [workingDirectory] is not null makes it the current working directory of | |
| /// the process. Otherwise, the process inherits this processes working | |
| /// directory. | |
| /// | |
| /// If [failureIsSuccess] is set to true, the returned [ProcessManager] treats | |
| /// non-zero exit codes as success, and zero exit code as failure. | |
| /// | |
| /// If [evalOutput] is set to true, collects and decodes the process' standard | |
| /// streams into in-memory strings. | |
| Future<ProcessManager> startProcess( | |
| String executable, | |
| List<String> arguments, { | |
| String? workingDirectory, | |
| bool failureIsSuccess = false, | |
| bool evalOutput = false, | |
| Map<String, String> environment = const <String, String>{}, | |
| }) async { | |
| final io.Process process = await io.Process.start( | |
| executable, | |
| arguments, | |
| workingDirectory: workingDirectory, | |
| // Running the process in a system shell for Windows. Otherwise | |
| // the process is not able to get Dart from path. | |
| runInShell: io.Platform.isWindows, | |
| // When [evalOutput] is false, we don't need to intercept the stdout of the | |
| // sub-process. In this case, it's better to run the sub-process in the | |
| // `inheritStdio` mode which lets it print directly to the terminal. | |
| // This allows sub-processes such as `ninja` to use all kinds of terminal | |
| // features like printing colors, printing progress on the same line, etc. | |
| mode: evalOutput ? io.ProcessStartMode.normal : io.ProcessStartMode.inheritStdio, | |
| environment: environment, | |
| ); | |
| processesToCleanUp.add(process); | |
| return ProcessManager._( | |
| executable: executable, | |
| arguments: arguments, | |
| workingDirectory: workingDirectory, | |
| process: process, | |
| evalOutput: evalOutput, | |
| failureIsSuccess: failureIsSuccess, | |
| ); | |
| } | |
| /// Manages a process running outside `felt`. | |
| class ProcessManager { | |
| /// Creates a process manager that manages [process]. | |
| ProcessManager._({ | |
| required this.executable, | |
| required this.arguments, | |
| required this.workingDirectory, | |
| required this.process, | |
| required bool evalOutput, | |
| required bool failureIsSuccess, | |
| }) : _evalOutput = evalOutput, _failureIsSuccess = failureIsSuccess { | |
| if (_evalOutput) { | |
| _forwardStream(process.stdout, _stdout); | |
| _forwardStream(process.stderr, _stderr); | |
| } | |
| } | |
| /// The executable, from which the process was spawned. | |
| final String executable; | |
| /// The arguments passed to the prcess. | |
| final List<String> arguments; | |
| /// The current working directory (CWD) of the child process. | |
| /// | |
| /// If null, the child process inherits `felt`'s CWD. | |
| final String? workingDirectory; | |
| /// The process being managed by this manager. | |
| final io.Process process; | |
| /// Whether the standard output and standard error should be decoded into | |
| /// strings while running the process. | |
| final bool _evalOutput; | |
| /// Whether non-zero exit code is considered successful completion of the | |
| /// process. | |
| /// | |
| /// See also [wait]. | |
| final bool _failureIsSuccess; | |
| final StringBuffer _stdout = StringBuffer(); | |
| final StringBuffer _stderr = StringBuffer(); | |
| void _forwardStream(Stream<List<int>> stream, StringSink buffer) { | |
| stream | |
| .transform(utf8.decoder) | |
| .transform(const LineSplitter()) | |
| .listen(buffer.writeln); | |
| } | |
| /// Waits for the [process] to exit. Returns the exit code. | |
| /// | |
| /// The returned future completes successfully if: | |
| /// | |
| /// * [failureIsSuccess] is false and the process exited with exit code 0. | |
| /// * [failureIsSuccess] is true and the process exited with a non-zero exit code. | |
| /// | |
| /// In all other cicumstances the future completes with an error. | |
| Future<int> wait() async { | |
| final int exitCode = await process.exitCode; | |
| if (!_failureIsSuccess && exitCode != 0) { | |
| _throwProcessException( | |
| description: 'Sub-process failed.', | |
| exitCode: exitCode, | |
| ); | |
| } | |
| return exitCode; | |
| } | |
| /// If [evalOutput] is true, wait for the process to finish then returns the | |
| /// decoded standard streams. | |
| Future<ProcessOutput> eval() async { | |
| if (!_evalOutput) { | |
| kill(); | |
| _throwProcessException( | |
| description: 'Cannot eval process output. The process was launched ' | |
| 'with `evalOutput` set to false.', | |
| ); | |
| } | |
| final int exitCode = await wait(); | |
| return ProcessOutput( | |
| exitCode: exitCode, | |
| stdout: _stdout.toString(), | |
| stderr: _stderr.toString(), | |
| ); | |
| } | |
| /// A convenience method on top of [eval] that only extracts standard output. | |
| Future<String> evalStdout() async { | |
| return (await eval()).stdout; | |
| } | |
| /// A convenience method on top of [eval] that only extracts standard error. | |
| Future<String> evalStderr() async { | |
| return (await eval()).stderr; | |
| } | |
| Never _throwProcessException({required String description, int? exitCode}) { | |
| throw ProcessException( | |
| description: description, | |
| executable: executable, | |
| arguments: arguments, | |
| workingDirectory: workingDirectory, | |
| exitCode: exitCode, | |
| ); | |
| } | |
| /// Kills the [process] by sending it the [signal]. | |
| bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) { | |
| return process.kill(signal); | |
| } | |
| } | |
| /// Stringified standard output and standard error streams from a process. | |
| class ProcessOutput { | |
| ProcessOutput({ | |
| required this.exitCode, | |
| required this.stdout, | |
| required this.stderr, | |
| }); | |
| /// The exit code of the process. | |
| final int exitCode; | |
| /// Standard output of the process decoded as a string. | |
| final String stdout; | |
| /// Standard error of the process decoded as a string. | |
| final String stderr; | |
| } | |
| /// An exception related to an attempt to spawn a sub-process. | |
| @immutable | |
| class ProcessException implements Exception { | |
| const ProcessException({ | |
| required this.description, | |
| required this.executable, | |
| required this.arguments, | |
| required this.workingDirectory, | |
| this.exitCode, | |
| }); | |
| final String description; | |
| final String executable; | |
| final List<String> arguments; | |
| final String? workingDirectory; | |
| /// The exit code of the process. | |
| /// | |
| /// The value is null if the exception is thrown before the process exits. | |
| /// For example, this can happen on invalid attempts to start a process, or | |
| /// when a process is stuck and is unable to exit. | |
| final int? exitCode; | |
| @override | |
| String toString() { | |
| final StringBuffer message = StringBuffer(); | |
| message | |
| ..writeln(description) | |
| ..writeln('Command: $executable ${arguments.join(' ')}') | |
| ..writeln('Working directory: ${workingDirectory ?? io.Directory.current.path}'); | |
| if (exitCode != null) { | |
| message.writeln('Exit code: $exitCode'); | |
| } | |
| return '$message'; | |
| } | |
| } | |
| /// Adds utility methods | |
| mixin ArgUtils<T> on Command<T> { | |
| /// Extracts a boolean argument from [argResults]. | |
| bool boolArg(String name) => argResults![name] as bool; | |
| /// Extracts a string argument from [argResults]. | |
| String stringArg(String name) => argResults![name] as String; | |
| RuntimeMode get runtimeMode { | |
| final bool isProfile = boolArg('profile'); | |
| final bool isDebug = boolArg('debug'); | |
| if (isProfile && isDebug) { | |
| throw ToolExit('Cannot specify both --profile and --debug at the same time.'); | |
| } | |
| if (isProfile) { | |
| return RuntimeMode.profile; | |
| } else if (isDebug) { | |
| return RuntimeMode.debug; | |
| } else { | |
| return RuntimeMode.release; | |
| } | |
| } | |
| } | |
| io.Directory getBuildDirectoryForRuntimeMode(RuntimeMode runtimeMode) => | |
| switch (runtimeMode) { | |
| RuntimeMode.debug => environment.wasmDebugUnoptOutDir, | |
| RuntimeMode.profile => environment.wasmProfileOutDir, | |
| RuntimeMode.release => environment.wasmReleaseOutDir, | |
| }; | |
| /// There might be proccesses started during the tests. | |
| /// | |
| /// Use this list to store those Processes, for cleaning up before shutdown. | |
| final List<io.Process> processesToCleanUp = <io.Process>[]; | |
| /// There might be temporary directories created during the tests. | |
| /// | |
| /// Use this list to store those directories and for deleteing them before | |
| /// shutdown. | |
| final List<io.Directory> temporaryDirectories = <io.Directory>[]; | |
| typedef AsyncCallback = Future<void> Function(); | |
| /// There might be additional cleanup needs to be done after the tools ran. | |
| /// | |
| /// Add these operations here to make sure that they will run before felt | |
| /// exit. | |
| final List<AsyncCallback> cleanupCallbacks = <AsyncCallback>[]; | |
| /// Cleanup the remaning processes, close open browsers, delete temp files. | |
| Future<void> cleanup() async { | |
| // Cleanup remaining processes if any. | |
| if (processesToCleanUp.isNotEmpty) { | |
| for (final io.Process process in processesToCleanUp) { | |
| process.kill(); | |
| } | |
| } | |
| // Delete temporary directories. | |
| if (temporaryDirectories.isNotEmpty) { | |
| for (final io.Directory directory in temporaryDirectories) { | |
| if (!directory.existsSync()) { | |
| directory.deleteSync(recursive: true); | |
| } | |
| } | |
| } | |
| for (final AsyncCallback callback in cleanupCallbacks) { | |
| await callback(); | |
| } | |
| } | |
| io.Directory getTestSetDirectory(TestSet testSet) { | |
| return io.Directory( | |
| path.join( | |
| environment.webUiTestDir.path, | |
| testSet.directory, | |
| ) | |
| ); | |
| } | |
| io.Directory getBundleBuildDirectory(TestBundle bundle) { | |
| return io.Directory( | |
| path.join( | |
| environment.webUiBuildDir.path, | |
| 'test_bundles', | |
| bundle.name, | |
| ) | |
| ); | |
| } | |
| io.Directory getSkiaGoldDirectoryForSuite(TestSuite suite) { | |
| return io.Directory( | |
| path.join( | |
| environment.webUiSkiaGoldDirectory.path, | |
| suite.name, | |
| ) | |
| ); | |
| } | |
| extension AnsiColors on String { | |
| static bool shouldEscape = () { | |
| if (isLuci) { | |
| // Produce clean output on LUCI. | |
| return false; | |
| } | |
| return io.stdout.hasTerminal && io.stdout.supportsAnsiEscapes; | |
| }(); | |
| static const String _noColorCode = '\u001b[39m'; | |
| String _wrapText(String prefix, String suffix) => shouldEscape | |
| ? '$prefix$this$suffix' : this; | |
| String _colorText(String colorCode) => _wrapText(colorCode, _noColorCode); | |
| String get ansiBlack => _colorText('\u001b[30m'); | |
| String get ansiRed => _colorText('\u001b[31m'); | |
| String get ansiGreen => _colorText('\u001b[32m'); | |
| String get ansiYellow => _colorText('\u001b[33m'); | |
| String get ansiBlue => _colorText('\u001b[34m'); | |
| String get ansiMagenta => _colorText('\u001b[35m'); | |
| String get ansiCyan => _colorText('\u001b[36m'); | |
| String get ansiWhite => _colorText('\u001b[37m'); | |
| String get ansiBold => _wrapText('\u001b[1m', '\u001b[0m'); | |
| } |