// 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 runProcess( String executable, List arguments, { String? workingDirectory, bool failureIsSuccess = false, Map environment = const {}, }) 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 evalProcess( String executable, List arguments, { String? workingDirectory, Map environment = const {}, }) 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 startProcess( String executable, List arguments, { String? workingDirectory, bool failureIsSuccess = false, bool evalOutput = false, Map environment = const {}, }) 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 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> 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 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 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 evalStdout() async { return (await eval()).stdout; } /// A convenience method on top of [eval] that only extracts standard error. Future 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 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 on Command { /// 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 processesToCleanUp = []; /// There might be temporary directories created during the tests. /// /// Use this list to store those directories and for deleteing them before /// shutdown. final List temporaryDirectories = []; typedef AsyncCallback = Future 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 cleanupCallbacks = []; /// Cleanup the remaning processes, close open browsers, delete temp files. Future 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'); }