diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 60ddae203..a50b447bc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -28,6 +28,7 @@ pkgs/mime @lrhn pkgs/oauth2 @dart-lang/dart-pub-team pkgs/package_config @dart-lang/dart-ecosystem-team pkgs/pool @dart-lang/dart-ecosystem-team +pkgs/process @dart-lang/dart-ecosystem-team pkgs/pub_semver @dart-lang/dart-pub-team pkgs/pubspec_parse @dart-lang/dart-bat pkgs/source_maps @dart-lang/dart-ecosystem-team diff --git a/.github/labeler.yml b/.github/labeler.yml index 6bdbffd66..dbaf8e5fa 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -96,6 +96,10 @@ - changed-files: - any-glob-to-any-file: 'pkgs/pool/**' +'package:procoess': + - changed-files: + - any-glob-to-any-file: 'pkgs/procoess/**' + 'package:pub_semver': - changed-files: - any-glob-to-any-file: 'pkgs/pub_semver/**' diff --git a/.github/workflows/process.yaml b/.github/workflows/process.yaml new file mode 100644 index 000000000..98044335b --- /dev/null +++ b/.github/workflows/process.yaml @@ -0,0 +1,38 @@ +name: package:process +permissions: read-all + +on: + schedule: + # “At 00:00 (UTC) on Sunday.” + - cron: '0 0 * * 0' + push: + branches: [ main ] + paths: + - '.github/workflows/process.yaml' + - 'pkgs/process/**' + pull_request: + branches: [ main ] + paths: + - '.github/workflows/process.yaml' + - 'pkgs/process/**' + +defaults: + run: + working-directory: pkgs/process/ + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest] + sdk: [stable, dev] + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 + - uses: dart-lang/setup-dart@e51d8e571e22473a2ddebf0ef8a2123f0ab2c02c + + - run: dart pub get + - run: dart format --output=none --set-exit-if-changed . + if: ${{ matrix.sdk == 'stable' }} + - run: dart analyze --fatal-infos + - run: dart test diff --git a/pkgs/process/.gitignore b/pkgs/process/.gitignore new file mode 100644 index 000000000..1f1b0dfdf --- /dev/null +++ b/pkgs/process/.gitignore @@ -0,0 +1,3 @@ +# Don’t commit the following directories created by pub. +.dart_tool +pubspec.lock diff --git a/pkgs/process/AUTHORS b/pkgs/process/AUTHORS new file mode 100644 index 000000000..044d6170b --- /dev/null +++ b/pkgs/process/AUTHORS @@ -0,0 +1,8 @@ +# Below is a list of people and organizations that have contributed +# to the Process project. Names should be added to the list like so: +# +# Name/Organization + +Google Inc. + +Bartek Pacia / @bartekpacia diff --git a/pkgs/process/CHANGELOG.md b/pkgs/process/CHANGELOG.md new file mode 100644 index 000000000..3791f2195 --- /dev/null +++ b/pkgs/process/CHANGELOG.md @@ -0,0 +1,209 @@ +## 5.0.4 + +* Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. +* Move the package into the `dart-lang/tools` repository. + +## 5.0.3 + +* Adds `missing_code_block_language_in_doc_comment` lint. +* Updates minimum supported SDK version to Flutter 3.19/Dart 3.3. + +## 5.0.2 + +* Removes mention of the removed record/replay feature from README. +* Updates minimum supported SDK version to Flutter 3.10/Dart 3.0. +* Fixes new lint warnings. + +## 5.0.1 + +* Transfers the package source from https://github.com/google/process.dart to + https://github.com/flutter/packages. + +## 5.0.0 + +* Remove the `covariant` keyword from `stderrEncoding` and `stdoutEncoding` + parameters. +* Update dependencies to work on Dart 3. +* Bumped min SDK dependency to nearest non-prerelease version (2.14.0) + +## 4.2.4 + +* Mark `stderrEncoding` and `stdoutEncoding` parameters as nullable again, + now that the upstream SDK issue has been fixed. + +## 4.2.3 + +* Rollback to version 4.2.1 (https://github.com/google/process.dart/issues/64) + +## 4.2.2 + +* Mark `stderrEncoding` and `stdoutEncoding` parameters as nullable. + +## 4.2.1 + +* Added custom exception types `ProcessPackageException` and + `ProcessPackageExecutableNotFoundException` to provide extra + information from exception conditions. + +## 4.2.0 + +* Fix the signature of `ProcessManager.canRun` to be consistent with + `LocalProcessManager`. + +## 4.1.1 + +* Fixed `getExecutablePath()` to only return path items that are + executable and readable to the user. + +## 4.1.0 + +* Fix the signatures of `ProcessManager.run`, `.runSync`, and `.start` to be + consistent with `LocalProcessManager`'s. +* Added more details to the `ArgumentError` thrown when a command cannot be resolved + to an executable. + +## 4.0.0 + +* First stable null safe release. + +## 4.0.0-nullsafety.4 + +* Update supported SDK range. + +## 4.0.0-nullsafety.3 + +* Update supported SDK range. + +## 4.0.0-nullsafety.2 + +* Update supported SDK range. + +## 4.0.0-nullsafety.1 + +* Migrate to null-safety. +* Remove record/replay functionality. +* Remove implicit casts in preparation for null-safety. +* Remove dependency on `package:intl` and `package:meta`. + +## 3.0.13 + +* Handle `currentDirectory` throwing an exception in `getExecutablePath()`. + +## 3.0.12 + +* Updated version constraint on intl. + +## 3.0.11 + +* Fix bug: don't add quotes if the file name already has quotes. + +## 3.0.10 + +* Added quoted strings to indicate where the command name ends and the arguments +begin otherwise, the file name is ambiguous on Windows. + +## 3.0.9 + +* Fixed bug in `ProcessWrapper` + +## 3.0.8 + +* Fixed bug in `ProcessWrapper` + +## 3.0.7 + +* Renamed `Process` to `ProcessWrapper` + +## 3.0.6 + +* Added class `Process`, a simple wrapper around dart:io's `Process` class. + +## 3.0.5 + +* Fixes for missing_return analysis errors with 2.10.0-dev.1.0. + +## 3.0.4 + +* Fix unit tests +* Update SDK constraint to 3. + +## 3.0.3 + +* Update dependency on `package:file` + +## 3.0.2 + +* Remove upper case constants. +* Update SDK constraint to 2.0.0-dev.54.0. +* Fix tests for Dart 2. + +## 3.0.1 + +* General cleanup + +## 3.0.0 + +* Cleanup getExecutablePath() to better respect the platform + +## 2.0.9 + +* Bumped `package:file` dependency + +### 2.0.8 + +* Fixed method getArguments to qualify the map method with the specific + String type + +### 2.0.7 + +* Remove `set exitCode` instances + +### 2.0.6 + +* Fix SDK constraint. +* rename .analysis_options file to analaysis_options.yaml. +* Use covariant in place of @checked. +* Update comment style generics. + +### 2.0.5 + +* Bumped maximum Dart SDK version to 2.0.0-dev.infinity + +### 2.0.4 + +* relax dependency requirement for `intl` + +### 2.0.3 + +* relax dependency requirement for `platform` + +## 2.0.2 + +* Fix a strong mode function expression return type inference bug with Dart + 1.23.0-dev.10.0. + +## 2.0.1 + +* Fixed bug in `ReplayProcessManager` whereby it could try to write to `stdout` + or `stderr` after the streams were closed. + +## 2.0.0 + +* Bumped `package:file` dependency to 2.0.1 + +## 1.1.0 + +* Added support to transparently find the right executable under Windows. + +## 1.0.1 + +* The `executable` and `arguments` parameters have been merged into one + `command` parameter in the `run`, `runSync`, and `start` methods of + `ProcessManager`. +* Added support for sanitization of command elements in + `RecordingProcessManager` and `ReplayProcessManager` via the `CommandElement` + class. + +## 1.0.0 + +* Initial version diff --git a/pkgs/process/LICENSE b/pkgs/process/LICENSE new file mode 100644 index 000000000..ed771e647 --- /dev/null +++ b/pkgs/process/LICENSE @@ -0,0 +1,27 @@ +Copyright 2013, the Dart project authors. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google LLC nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/pkgs/process/README.md b/pkgs/process/README.md new file mode 100644 index 000000000..9cd7dde1d --- /dev/null +++ b/pkgs/process/README.md @@ -0,0 +1,17 @@ +[![pub package](https://img.shields.io/pub/v/process.svg)](https://pub.dev/packages/process) +[![package publisher](https://img.shields.io/pub/publisher/process.svg)](https://pub.dev/packages/process/publisher) + +A pluggable, mockable process invocation abstraction for Dart. + +## What's this? + +A generic process invocation abstraction for Dart. + +Like `dart:io`, `package:process` supplies a rich, Dart-idiomatic API for +spawning OS processes. + +Unlike `dart:io`, `package:process` requires processes to be started with +[ProcessManager], which allows for easy mocking and testing of code that +spawns processes in a hermetic way. + +[ProcessManager]: https://pub.dev/documentation/process/latest/process/ProcessManager-class.html diff --git a/pkgs/process/dart_test.yaml b/pkgs/process/dart_test.yaml new file mode 100644 index 000000000..91ec220b8 --- /dev/null +++ b/pkgs/process/dart_test.yaml @@ -0,0 +1 @@ +test_on: vm diff --git a/pkgs/process/lib/process.dart b/pkgs/process/lib/process.dart new file mode 100644 index 000000000..2618e6d2a --- /dev/null +++ b/pkgs/process/lib/process.dart @@ -0,0 +1,8 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +export 'src/interface/exceptions.dart'; +export 'src/interface/local_process_manager.dart'; +export 'src/interface/process_manager.dart'; +export 'src/interface/process_wrapper.dart'; diff --git a/pkgs/process/lib/src/interface/common.dart b/pkgs/process/lib/src/interface/common.dart new file mode 100644 index 000000000..d374631cb --- /dev/null +++ b/pkgs/process/lib/src/interface/common.dart @@ -0,0 +1,165 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:path/path.dart' show Context; +import 'package:platform/platform.dart'; + +import 'exceptions.dart'; + +const Map _osToPathStyle = { + 'linux': 'posix', + 'macos': 'posix', + 'android': 'posix', + 'ios': 'posix', + 'fuchsia': 'posix', + 'windows': 'windows', +}; + +/// Sanitizes the executable path on Windows. +/// https://github.com/dart-lang/sdk/issues/37751 +String sanitizeExecutablePath(String executable, + {Platform platform = const LocalPlatform()}) { + if (executable.isEmpty) { + return executable; + } + if (!platform.isWindows) { + return executable; + } + if (executable.contains(' ') && !executable.contains('"')) { + // Use quoted strings to indicate where the file name ends and the arguments begin; + // otherwise, the file name is ambiguous. + return '"$executable"'; + } + return executable; +} + +/// Searches the `PATH` for the executable that [executable] is supposed to launch. +/// +/// This first builds a list of candidate paths where the executable may reside. +/// If [executable] is already an absolute path, then the `PATH` environment +/// variable will not be consulted, and the specified absolute path will be the +/// only candidate that is considered. +/// +/// Once the list of candidate paths has been constructed, this will pick the +/// first such path that represents an existent file. +/// +/// Return `null` if there were no viable candidates, meaning the executable +/// could not be found. +/// +/// If [platform] is not specified, it will default to the current platform. +String? getExecutablePath( + String executable, + String? workingDirectory, { + Platform platform = const LocalPlatform(), + FileSystem fs = const LocalFileSystem(), + bool throwOnFailure = false, +}) { + assert(_osToPathStyle[platform.operatingSystem] == fs.path.style.name); + try { + workingDirectory ??= fs.currentDirectory.path; + } on FileSystemException { + // The `currentDirectory` getter can throw a FileSystemException for example + // when the process doesn't have read/list permissions in each component of + // the cwd path. In this case, fall back on '.'. + workingDirectory ??= '.'; + } + final Context context = + Context(style: fs.path.style, current: workingDirectory); + + // TODO(goderbauer): refactor when github.com/google/platform.dart/issues/2 + // is available. + final String pathSeparator = platform.isWindows ? ';' : ':'; + + List extensions = []; + if (platform.isWindows && context.extension(executable).isEmpty) { + extensions = platform.environment['PATHEXT']!.split(pathSeparator); + } + + List candidates = []; + List searchPath; + if (executable.contains(context.separator)) { + // Deal with commands that specify a relative or absolute path differently. + searchPath = [workingDirectory]; + } else { + searchPath = platform.environment['PATH']!.split(pathSeparator); + } + candidates = _getCandidatePaths(executable, searchPath, extensions, context); + final List foundCandidates = []; + for (final String path in candidates) { + final File candidate = fs.file(path); + final FileStat stat = candidate.statSync(); + // Only return files or links that exist. + if (stat.type == FileSystemEntityType.notFound || + stat.type == FileSystemEntityType.directory) { + continue; + } + + // File exists, but we don't know if it's readable/executable yet. + foundCandidates.add(candidate.path); + + const int isExecutable = 0x40; + const int isReadable = 0x100; + const int isExecutableAndReadable = isExecutable | isReadable; + // Should only return files or links that are readable and executable by the + // user. + + // On Windows it's not actually possible to only return files that are + // readable, since Dart reports files that have had read permission removed + // as being readable, but not checking for it is the same as checking for it + // and finding it readable, so we use the same check here on all platforms, + // so that if Dart ever gets fixed, it'll just work. + if (stat.mode & isExecutableAndReadable == isExecutableAndReadable) { + return path; + } + } + if (throwOnFailure) { + if (foundCandidates.isNotEmpty) { + throw ProcessPackageExecutableNotFoundException( + executable, + message: + 'Found candidates, but lacked sufficient permissions to execute "$executable".', + workingDirectory: workingDirectory, + candidates: foundCandidates, + searchPath: searchPath, + ); + } else { + throw ProcessPackageExecutableNotFoundException( + executable, + message: 'Failed to find "$executable" in the search path.', + workingDirectory: workingDirectory, + searchPath: searchPath, + ); + } + } + return null; +} + +/// Returns all possible combinations of `$searchPath\$command.$ext` for +/// `searchPath` in [searchPaths] and `ext` in [extensions]. +/// +/// If [extensions] is empty, it will just enumerate all +/// `$searchPath\$command`. +/// If [command] is an absolute path, it will just enumerate +/// `$command.$ext`. +List _getCandidatePaths( + String command, + List searchPaths, + List extensions, + Context context, +) { + final List withExtensions = extensions.isNotEmpty + ? extensions.map((String ext) => '$command$ext').toList() + : [command]; + if (context.isAbsolute(command)) { + return withExtensions; + } + return searchPaths + .map((String path) => + withExtensions.map((String command) => context.join(path, command))) + .expand((Iterable e) => e) + .toList() + .cast(); +} diff --git a/pkgs/process/lib/src/interface/exceptions.dart b/pkgs/process/lib/src/interface/exceptions.dart new file mode 100644 index 000000000..dec80d38c --- /dev/null +++ b/pkgs/process/lib/src/interface/exceptions.dart @@ -0,0 +1,101 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. 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:io' show ProcessException; + +/// A specialized exception class for this package, so that it can throw +/// customized exceptions with more information. +class ProcessPackageException extends ProcessException { + /// Create a const ProcessPackageException. + /// + /// The [executable] should be the name of the executable to be run. + /// + /// The optional [workingDirectory] is the directory where the command + /// execution is attempted. + /// + /// The optional [arguments] is a list of the arguments to given to the + /// executable, already separated. + /// + /// The optional [message] is an additional message to be included in the + /// exception string when printed. + /// + /// The optional [errorCode] is the error code received when the executable + /// was run. Zero means it ran successfully, or that no error code was + /// available. + /// + /// See [ProcessException] for more information. + const ProcessPackageException( + String executable, { + List arguments = const [], + String message = '', + int errorCode = 0, + this.workingDirectory, + }) : super(executable, arguments, message, errorCode); + + /// Creates a [ProcessPackageException] from a [ProcessException]. + factory ProcessPackageException.fromProcessException( + ProcessException exception, { + String? workingDirectory, + }) { + return ProcessPackageException( + exception.executable, + arguments: exception.arguments, + message: exception.message, + errorCode: exception.errorCode, + workingDirectory: workingDirectory, + ); + } + + /// The optional working directory that the command was being executed in. + final String? workingDirectory; + + // Don't implement a toString() for this exception, since code may be + // depending upon the format of ProcessException.toString(). +} + +/// An exception for when an executable is not found that was expected to be found. +class ProcessPackageExecutableNotFoundException + extends ProcessPackageException { + /// Creates a const ProcessPackageExecutableNotFoundException + /// + /// The optional [candidates] are the files matching the expected executable + /// on the [searchPath]. + /// + /// The optional [searchPath] is the list of directories searched for the + /// expected executable. + /// + /// See [ProcessPackageException] for more information. + const ProcessPackageExecutableNotFoundException( + super.executable, { + super.arguments, + super.message, + super.errorCode, + super.workingDirectory, + this.candidates = const [], + this.searchPath = const [], + }); + + /// The list of non-viable executable candidates found. + final List candidates; + + /// The search path used to find candidates. + final List searchPath; + + @override + String toString() { + final StringBuffer buffer = + StringBuffer('ProcessPackageExecutableNotFoundException: $message\n'); + // Don't add an extra space if there are no arguments. + final String args = arguments.isNotEmpty ? ' ${arguments.join(' ')}' : ''; + buffer.writeln(' Command: $executable$args'); + if (workingDirectory != null && workingDirectory!.isNotEmpty) { + buffer.writeln(' Working Directory: $workingDirectory'); + } + if (candidates.isNotEmpty) { + buffer.writeln(' Candidates:\n ${candidates.join('\n ')}'); + } + buffer.writeln(' Search Path:\n ${searchPath.join('\n ')}'); + return buffer.toString(); + } +} diff --git a/pkgs/process/lib/src/interface/local_process_manager.dart b/pkgs/process/lib/src/interface/local_process_manager.dart new file mode 100644 index 000000000..ab7e96a31 --- /dev/null +++ b/pkgs/process/lib/src/interface/local_process_manager.dart @@ -0,0 +1,152 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. 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:convert'; +import 'dart:io' + show + Process, + ProcessException, + ProcessResult, + ProcessSignal, + ProcessStartMode, + systemEncoding; + +import 'common.dart'; +import 'exceptions.dart'; +import 'process_manager.dart'; + +/// Local implementation of the `ProcessManager` interface. +/// +/// This implementation delegates directly to the corresponding static methods +/// in `dart:io`. +/// +/// All methods that take a `command` will run `toString()` on the command +/// elements to derive the executable and arguments that should be passed to +/// the underlying `dart:io` methods. Thus, the degenerate case of +/// `List` will trivially work as expected. +class LocalProcessManager implements ProcessManager { + /// Creates a new `LocalProcessManager`. + const LocalProcessManager(); + + @override + Future start( + List command, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) { + try { + return Process.start( + sanitizeExecutablePath(_getExecutable( + command, + workingDirectory, + runInShell, + )), + _getArguments(command), + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + mode: mode, + ); + } on ProcessException catch (exception) { + throw ProcessPackageException.fromProcessException(exception, + workingDirectory: workingDirectory); + } + } + + @override + Future run( + List command, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, + }) { + try { + return Process.run( + sanitizeExecutablePath(_getExecutable( + command, + workingDirectory, + runInShell, + )), + _getArguments(command), + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + ); + } on ProcessException catch (exception) { + throw ProcessPackageException.fromProcessException(exception, + workingDirectory: workingDirectory); + } + } + + @override + ProcessResult runSync( + List command, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, + }) { + try { + return Process.runSync( + sanitizeExecutablePath(_getExecutable( + command, + workingDirectory, + runInShell, + )), + _getArguments(command), + workingDirectory: workingDirectory, + environment: environment, + includeParentEnvironment: includeParentEnvironment, + runInShell: runInShell, + stdoutEncoding: stdoutEncoding, + stderrEncoding: stderrEncoding, + ); + } on ProcessException catch (exception) { + throw ProcessPackageException.fromProcessException(exception, + workingDirectory: workingDirectory); + } + } + + @override + bool canRun(covariant String executable, {String? workingDirectory}) => + getExecutablePath(executable, workingDirectory) != null; + + @override + bool killPid(int pid, [ProcessSignal signal = ProcessSignal.sigterm]) { + return Process.killPid(pid, signal); + } +} + +String _getExecutable( + List command, String? workingDirectory, bool runInShell) { + final String commandName = command.first.toString(); + if (runInShell) { + return commandName; + } + return getExecutablePath( + commandName, + workingDirectory, + throwOnFailure: true, + )!; +} + +List _getArguments(List command) => + // Adding a specific type to map in order to workaround dart issue + // https://github.com/dart-lang/sdk/issues/32414 + command + .skip(1) + .map((dynamic element) => element.toString()) + .toList(); diff --git a/pkgs/process/lib/src/interface/process_manager.dart b/pkgs/process/lib/src/interface/process_manager.dart new file mode 100644 index 000000000..69f6a2a79 --- /dev/null +++ b/pkgs/process/lib/src/interface/process_manager.dart @@ -0,0 +1,187 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. 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:convert'; +import 'dart:io' + show + Process, + ProcessResult, + ProcessSignal, + ProcessStartMode, + systemEncoding; + +/// Manages the creation of abstract processes. +/// +/// Using instances of this class provides level of indirection from the static +/// methods in the [Process] class, which in turn allows the underlying +/// implementation to be mocked out or decorated for testing and debugging +/// purposes. +abstract class ProcessManager { + /// Starts a process by running the specified [command]. + /// + /// The first element in [command] will be treated as the executable to run, + /// with subsequent elements being passed as arguments to the executable. It + /// is left to implementations to decide what element types they support in + /// the [command] list. + /// + /// Returns a `Future` that completes with a Process instance when + /// the process has been successfully started. That [Process] object can be + /// used to interact with the process. If the process cannot be started, the + /// returned [Future] completes with an exception. + /// + /// Use [workingDirectory] to set the working directory for the process. Note + /// that the change of directory occurs before executing the process on some + /// platforms, which may have impact when using relative paths for the + /// executable and the arguments. + /// + /// Use [environment] to set the environment variables for the process. If not + /// set, the environment of the parent process is inherited. Currently, only + /// US-ASCII environment variables are supported and errors are likely to occur + /// if an environment variable with code-points outside the US-ASCII range is + /// passed in. + /// + /// If [includeParentEnvironment] is `true`, the process's environment will + /// include the parent process's environment, with [environment] taking + /// precedence. Default is `true`. + /// + /// If [runInShell] is `true`, the process will be spawned through a system + /// shell. On Linux and OS X, `/bin/sh` is used, while + /// `%WINDIR%\system32\cmd.exe` is used on Windows. + /// + /// Users must read all data coming on the `stdout` and `stderr` + /// streams of processes started with [start]. If the user + /// does not read all data on the streams the underlying system + /// resources will not be released since there is still pending data. + /// + /// The following code uses `start` to grep for `main` in the + /// file `test.dart` on Linux. + /// + /// ```dart + /// ProcessManager mgr = new LocalProcessManager(); + /// mgr.start(['grep', '-i', 'main', 'test.dart']).then((process) { + /// stdout.addStream(process.stdout); + /// stderr.addStream(process.stderr); + /// }); + /// ``` + /// + /// If [mode] is [ProcessStartMode.normal] (the default) a child + /// process will be started with `stdin`, `stdout` and `stderr` + /// connected. + /// + /// If `mode` is [ProcessStartMode.detached] a detached process will + /// be created. A detached process has no connection to its parent, + /// and can keep running on its own when the parent dies. The only + /// information available from a detached process is its `pid`. There + /// is no connection to its `stdin`, `stdout` or `stderr`, nor will + /// the process' exit code become available when it terminates. + /// + /// If `mode` is [ProcessStartMode.detachedWithStdio] a detached + /// process will be created where the `stdin`, `stdout` and `stderr` + /// are connected. The creator can communicate with the child through + /// these. The detached process will keep running even if these + /// communication channels are closed. The process' exit code will + /// not become available when it terminated. + /// + /// The default value for `mode` is `ProcessStartMode.normal`. + Future start( + List command, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }); + + /// Starts a process and runs it non-interactively to completion. + /// + /// The first element in [command] will be treated as the executable to run, + /// with subsequent elements being passed as arguments to the executable. It + /// is left to implementations to decide what element types they support in + /// the [command] list. + /// + /// Use [workingDirectory] to set the working directory for the process. Note + /// that the change of directory occurs before executing the process on some + /// platforms, which may have impact when using relative paths for the + /// executable and the arguments. + /// + /// Use [environment] to set the environment variables for the process. If not + /// set the environment of the parent process is inherited. Currently, only + /// US-ASCII environment variables are supported and errors are likely to occur + /// if an environment variable with code-points outside the US-ASCII range is + /// passed in. + /// + /// If [includeParentEnvironment] is `true`, the process's environment will + /// include the parent process's environment, with [environment] taking + /// precedence. Default is `true`. + /// + /// If [runInShell] is true, the process will be spawned through a system + /// shell. On Linux and OS X, `/bin/sh` is used, while + /// `%WINDIR%\system32\cmd.exe` is used on Windows. + /// + /// The encoding used for decoding `stdout` and `stderr` into text is + /// controlled through [stdoutEncoding] and [stderrEncoding]. The + /// default encoding is [systemEncoding]. If `null` is used no + /// decoding will happen and the [ProcessResult] will hold binary + /// data. + /// + /// Returns a `Future` that completes with the + /// result of running the process, i.e., exit code, standard out and + /// standard in. + /// + /// The following code uses `run` to grep for `main` in the + /// file `test.dart` on Linux. + /// + /// ```dart + /// ProcessManager mgr = new LocalProcessManager(); + /// mgr.run('grep', ['-i', 'main', 'test.dart']).then((result) { + /// stdout.write(result.stdout); + /// stderr.write(result.stderr); + /// }); + /// ``` + Future run( + List command, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, + }); + + /// Starts a process and runs it to completion. This is a synchronous + /// call and will block until the child process terminates. + /// + /// The arguments are the same as for [run]`. + /// + /// Returns a `ProcessResult` with the result of running the process, + /// i.e., exit code, standard out and standard in. + ProcessResult runSync( + List command, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + Encoding? stdoutEncoding = systemEncoding, + Encoding? stderrEncoding = systemEncoding, + }); + + /// Returns `true` if the [executable] exists and if it can be executed. + bool canRun(dynamic executable, {String? workingDirectory}); + + /// Kills the process with id [pid]. + /// + /// Where possible, sends the [signal] to the process with id + /// `pid`. This includes Linux and OS X. The default signal is + /// [ProcessSignal.sigterm] which will normally terminate the + /// process. + /// + /// On platforms without signal support, including Windows, the call + /// just terminates the process with id `pid` in a platform specific + /// way, and the `signal` parameter is ignored. + /// + /// Returns `true` if the signal is successfully delivered to the + /// process. Otherwise the signal could not be sent, usually meaning + /// that the process is already dead. + bool killPid(int pid, [ProcessSignal signal = ProcessSignal.sigterm]); +} diff --git a/pkgs/process/lib/src/interface/process_wrapper.dart b/pkgs/process/lib/src/interface/process_wrapper.dart new file mode 100644 index 000000000..9e5307129 --- /dev/null +++ b/pkgs/process/lib/src/interface/process_wrapper.dart @@ -0,0 +1,83 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. 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:io' as io; + +/// A wrapper around an [io.Process] class that adds some convenience methods. +class ProcessWrapper implements io.Process { + /// Constructs a [ProcessWrapper] object that delegates to the specified + /// underlying object. + ProcessWrapper(this._delegate) + : _stdout = StreamController>(), + _stderr = StreamController>(), + _stdoutDone = Completer(), + _stderrDone = Completer() { + _monitorStdioStream(_delegate.stdout, _stdout, _stdoutDone); + _monitorStdioStream(_delegate.stderr, _stderr, _stderrDone); + } + + final io.Process _delegate; + final StreamController> _stdout; + final StreamController> _stderr; + final Completer _stdoutDone; + final Completer _stderrDone; + + /// Listens to the specified [stream], repeating events on it via + /// [controller], and completing [completer] once the stream is done. + void _monitorStdioStream( + Stream> stream, + StreamController> controller, + Completer completer, + ) { + stream.listen( + controller.add, + onError: controller.addError, + onDone: () { + controller.close(); + completer.complete(); + }, + ); + } + + @override + Future get exitCode => _delegate.exitCode; + + /// A [Future] that completes when the process has exited and its standard + /// output and error streams have closed. + /// + /// This exists as an alternative to [exitCode], which does not guarantee + /// that the stdio streams have closed (it is possible for the exit code to + /// be available before stdout and stderr have closed). + /// + /// The future returned here will complete with the exit code of the process. + Future get done async { + late int result; + await Future.wait(>[ + _stdoutDone.future, + _stderrDone.future, + _delegate.exitCode.then((int value) { + result = value; + }), + ]); + return result; + } + + @override + bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) { + return _delegate.kill(signal); + } + + @override + int get pid => _delegate.pid; + + @override + io.IOSink get stdin => _delegate.stdin; + + @override + Stream> get stdout => _stdout.stream; + + @override + Stream> get stderr => _stderr.stream; +} diff --git a/pkgs/process/pubspec.yaml b/pkgs/process/pubspec.yaml new file mode 100644 index 000000000..38ad51c8a --- /dev/null +++ b/pkgs/process/pubspec.yaml @@ -0,0 +1,19 @@ +name: process +description: A pluggable, mockable process invocation abstraction for Dart. +version: 5.0.4 +repository: https://github.com/dart-lang/tools/tree/main/pkgs/process +issue_tracker: https://github.com/dart-lang/tools/issues?q=is%3Aissue+is%3Aopen+label%3Apackage%3Aprocess + +topics: + - process + +environment: + sdk: ^3.4.0 + +dependencies: + file: '>=6.0.0 <8.0.0' + path: ^1.8.0 + platform: '^3.0.0' + +dev_dependencies: + test: ^1.16.8 diff --git a/pkgs/process/test/src/interface/common_test.dart b/pkgs/process/test/src/interface/common_test.dart new file mode 100644 index 000000000..8290b5ef4 --- /dev/null +++ b/pkgs/process/test/src/interface/common_test.dart @@ -0,0 +1,589 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. 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:io' as io; + +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:file/memory.dart'; +import 'package:platform/platform.dart'; +import 'package:process/process.dart'; +import 'package:process/src/interface/common.dart'; +import 'package:test/test.dart'; + +void main() { + group('getExecutablePath', () { + late FileSystem fs; + late Directory workingDir, dir1, dir2, dir3; + + void initialize(FileSystemStyle style) { + setUp(() { + fs = MemoryFileSystem(style: style); + workingDir = fs.systemTempDirectory.createTempSync('work_dir_'); + dir1 = fs.systemTempDirectory.createTempSync('dir1_'); + dir2 = fs.systemTempDirectory.createTempSync('dir2_'); + dir3 = fs.systemTempDirectory.createTempSync('dir3_'); + }); + } + + tearDown(() { + for (final Directory directory in [ + workingDir, + dir1, + dir2, + dir3 + ]) { + directory.deleteSync(recursive: true); + } + }); + + group('on windows', () { + late Platform platform; + + initialize(FileSystemStyle.windows); + + setUp(() { + platform = FakePlatform( + operatingSystem: 'windows', + environment: { + 'PATH': '${dir1.path};${dir2.path}', + 'PATHEXT': '.exe;.bat' + }, + ); + }); + + test('absolute', () { + String command = fs.path.join(dir3.path, 'bla.exe'); + final String expectedPath = command; + fs.file(command).createSync(); + + String? executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + + command = fs.path.withoutExtension(command); + executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + }); + + test('in path', () { + String command = 'bla.exe'; + final String expectedPath = fs.path.join(dir2.path, command); + fs.file(expectedPath).createSync(); + + String? executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + + command = fs.path.withoutExtension(command); + executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + }); + + test('in path multiple times', () { + String command = 'bla.exe'; + final String expectedPath = fs.path.join(dir1.path, command); + final String wrongPath = fs.path.join(dir2.path, command); + fs.file(expectedPath).createSync(); + fs.file(wrongPath).createSync(); + + String? executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + + command = fs.path.withoutExtension(command); + executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + }); + + test('in subdir of work dir', () { + String command = fs.path.join('.', 'foo', 'bla.exe'); + final String expectedPath = fs.path.join(workingDir.path, command); + fs.file(expectedPath).createSync(recursive: true); + + String? executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + + command = fs.path.withoutExtension(command); + executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + }); + + test('in work dir', () { + String command = fs.path.join('.', 'bla.exe'); + final String expectedPath = fs.path.join(workingDir.path, command); + final String wrongPath = fs.path.join(dir2.path, command); + fs.file(expectedPath).createSync(); + fs.file(wrongPath).createSync(); + + String? executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + + command = fs.path.withoutExtension(command); + executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + }); + + test('with multiple extensions', () { + const String command = 'foo'; + final String expectedPath = fs.path.join(dir1.path, '$command.exe'); + final String wrongPath1 = fs.path.join(dir1.path, '$command.bat'); + final String wrongPath2 = fs.path.join(dir2.path, '$command.exe'); + fs.file(expectedPath).createSync(); + fs.file(wrongPath1).createSync(); + fs.file(wrongPath2).createSync(); + + final String? executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + }); + + test('not found', () { + const String command = 'foo.exe'; + + final String? executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + expect(executablePath, isNull); + }); + + test('not found with throwOnFailure throws exception with match state', + () { + const String command = 'foo.exe'; + expect( + () => getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + throwOnFailure: true, + ), + throwsA(isA() + .having( + (ProcessPackageExecutableNotFoundException + notFoundException) => + notFoundException.candidates, + 'candidates', + isEmpty) + .having( + (ProcessPackageExecutableNotFoundException + notFoundException) => + notFoundException.workingDirectory, + 'workingDirectory', + equals(workingDir.path)) + .having( + (ProcessPackageExecutableNotFoundException + notFoundException) => + notFoundException.toString(), + 'toString', + contains( + ' Working Directory: C:\\.tmp_rand0\\work_dir_rand0\n' + ' Search Path:\n' + ' C:\\.tmp_rand0\\dir1_rand0\n' + ' C:\\.tmp_rand0\\dir2_rand0\n')))); + }); + + test('when path has spaces', () { + expect( + sanitizeExecutablePath(r'Program Files\bla.exe', + platform: platform), + r'"Program Files\bla.exe"'); + expect( + sanitizeExecutablePath(r'ProgramFiles\bla.exe', platform: platform), + r'ProgramFiles\bla.exe'); + expect( + sanitizeExecutablePath(r'"Program Files\bla.exe"', + platform: platform), + r'"Program Files\bla.exe"'); + expect( + sanitizeExecutablePath(r'"Program Files\bla.exe"', + platform: platform), + r'"Program Files\bla.exe"'); + expect( + sanitizeExecutablePath(r'C:\"Program Files"\bla.exe', + platform: platform), + r'C:\"Program Files"\bla.exe'); + }); + + test('with absolute path when currentDirectory getter throws', () { + final FileSystem fsNoCwd = MemoryFileSystemNoCwd(fs); + final String command = fs.path.join(dir3.path, 'bla.exe'); + final String expectedPath = command; + fs.file(command).createSync(); + + final String? executablePath = getExecutablePath( + command, + null, + platform: platform, + fs: fsNoCwd, + ); + _expectSamePath(executablePath, expectedPath); + }); + + test('with relative path when currentDirectory getter throws', () { + final FileSystem fsNoCwd = MemoryFileSystemNoCwd(fs); + final String command = fs.path.join('.', 'bla.exe'); + + final String? executablePath = getExecutablePath( + command, + null, + platform: platform, + fs: fsNoCwd, + ); + expect(executablePath, isNull); + }); + }); + + group('on Linux', () { + late Platform platform; + + initialize(FileSystemStyle.posix); + + setUp(() { + platform = FakePlatform( + operatingSystem: 'linux', + environment: {'PATH': '${dir1.path}:${dir2.path}'}); + }); + + test('absolute', () { + final String command = fs.path.join(dir3.path, 'bla'); + final String expectedPath = command; + final String wrongPath = fs.path.join(dir3.path, 'bla.bat'); + fs.file(command).createSync(); + fs.file(wrongPath).createSync(); + + final String? executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + }); + + test('in path multiple times', () { + const String command = 'xxx'; + final String expectedPath = fs.path.join(dir1.path, command); + final String wrongPath = fs.path.join(dir2.path, command); + fs.file(expectedPath).createSync(); + fs.file(wrongPath).createSync(); + + final String? executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + _expectSamePath(executablePath, expectedPath); + }); + + test('not found', () { + const String command = 'foo'; + + final String? executablePath = getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + ); + expect(executablePath, isNull); + }); + + test('not found with throwOnFailure throws exception with match state', + () { + const String command = 'foo'; + expect( + () => getExecutablePath( + command, + workingDir.path, + platform: platform, + fs: fs, + throwOnFailure: true, + ), + throwsA(isA() + .having( + (ProcessPackageExecutableNotFoundException + notFoundException) => + notFoundException.candidates, + 'candidates', + isEmpty) + .having( + (ProcessPackageExecutableNotFoundException + notFoundException) => + notFoundException.workingDirectory, + 'workingDirectory', + equals(workingDir.path)) + .having( + (ProcessPackageExecutableNotFoundException + notFoundException) => + notFoundException.toString(), + 'toString', + contains(' Working Directory: /.tmp_rand0/work_dir_rand0\n' + ' Search Path:\n' + ' /.tmp_rand0/dir1_rand0\n' + ' /.tmp_rand0/dir2_rand0\n')))); + }); + + test('when path has spaces', () { + expect( + sanitizeExecutablePath('/usr/local/bin/foo bar', + platform: platform), + '/usr/local/bin/foo bar'); + }); + }); + }); + group('Real Filesystem', () { + // These tests don't use the memory filesystem because Dart can't modify file + // executable permissions, so we have to create them with actual commands. + + late Platform platform; + late Directory tmpDir; + late Directory pathDir1; + late Directory pathDir2; + late Directory pathDir3; + late Directory pathDir4; + late Directory pathDir5; + late File command1; + late File command2; + late File command3; + late File command4; + late File command5; + const Platform localPlatform = LocalPlatform(); + late FileSystem fs; + + setUp(() { + fs = const LocalFileSystem(); + tmpDir = fs.systemTempDirectory.createTempSync(); + pathDir1 = tmpDir.childDirectory('path1')..createSync(); + pathDir2 = tmpDir.childDirectory('path2')..createSync(); + pathDir3 = tmpDir.childDirectory('path3')..createSync(); + pathDir4 = tmpDir.childDirectory('path4')..createSync(); + pathDir5 = tmpDir.childDirectory('path5')..createSync(); + command1 = pathDir1.childFile('command')..createSync(); + command2 = pathDir2.childFile('command')..createSync(); + command3 = pathDir3.childFile('command')..createSync(); + command4 = pathDir4.childFile('command')..createSync(); + command5 = pathDir5.childFile('command')..createSync(); + platform = FakePlatform( + operatingSystem: localPlatform.operatingSystem, + environment: { + 'PATH': [ + pathDir1, + pathDir2, + pathDir3, + pathDir4, + pathDir5, + ].map((Directory dir) => dir.absolute.path).join(':'), + }, + ); + }); + + tearDown(() { + tmpDir.deleteSync(recursive: true); + }); + + test('Only returns executables in PATH', () { + if (localPlatform.isWindows) { + // Windows doesn't check for executable-ness, and we can't run 'chmod' + // on Windows anyhow. + return; + } + + // Make the second command in the path executable, but not the first. + // No executable permissions + io.Process.runSync('chmod', ['0644', '--', command1.path]); + // Only group executable permissions + io.Process.runSync('chmod', ['0645', '--', command2.path]); + // Only other executable permissions + io.Process.runSync('chmod', ['0654', '--', command3.path]); + // All executable permissions, but not readable + io.Process.runSync('chmod', ['0311', '--', command4.path]); + // All executable permissions + io.Process.runSync('chmod', ['0755', '--', command5.path]); + + final String? executablePath = getExecutablePath( + 'command', + tmpDir.path, + platform: platform, + fs: fs, + ); + + // Make sure that the path returned is for the last command, since that + // one comes last in the PATH, but is the only one executable by the + // user. + _expectSamePath(executablePath, command5.absolute.path); + }); + + test( + 'Test that finding non-executable paths throws with proper information', + () { + if (localPlatform.isWindows) { + // Windows doesn't check for executable-ness, and we can't run 'chmod' + // on Windows anyhow. + return; + } + + // Make the second command in the path executable, but not the first. + // No executable permissions + io.Process.runSync('chmod', ['0644', '--', command1.path]); + // Only group executable permissions + io.Process.runSync('chmod', ['0645', '--', command2.path]); + // Only other executable permissions + io.Process.runSync('chmod', ['0654', '--', command3.path]); + // All executable permissions, but not readable + io.Process.runSync('chmod', ['0311', '--', command4.path]); + + expect( + () => getExecutablePath( + 'command', + tmpDir.path, + platform: platform, + fs: fs, + throwOnFailure: true, + ), + throwsA(isA() + .having( + (ProcessPackageExecutableNotFoundException + notFoundException) => + notFoundException.candidates, + 'candidates', + equals([ + '${tmpDir.path}/path1/command', + '${tmpDir.path}/path2/command', + '${tmpDir.path}/path3/command', + '${tmpDir.path}/path4/command', + '${tmpDir.path}/path5/command', + ])) + .having( + (ProcessPackageExecutableNotFoundException + notFoundException) => + notFoundException.toString(), + 'toString', + contains( + 'ProcessPackageExecutableNotFoundException: Found candidates, but lacked sufficient permissions to execute "command".\n' + ' Command: command\n' + ' Working Directory: ${tmpDir.path}\n' + ' Candidates:\n' + ' ${tmpDir.path}/path1/command\n' + ' ${tmpDir.path}/path2/command\n' + ' ${tmpDir.path}/path3/command\n' + ' ${tmpDir.path}/path4/command\n' + ' ${tmpDir.path}/path5/command\n' + ' Search Path:\n' + ' ${tmpDir.path}/path1\n' + ' ${tmpDir.path}/path2\n' + ' ${tmpDir.path}/path3\n' + ' ${tmpDir.path}/path4\n' + ' ${tmpDir.path}/path5\n')))); + }); + + test('Test that finding no executable paths throws with proper information', + () { + if (localPlatform.isWindows) { + // Windows doesn't check for executable-ness, and we can't run 'chmod' + // on Windows anyhow. + return; + } + + expect( + () => getExecutablePath( + 'non-existent-command', + tmpDir.path, + platform: platform, + fs: fs, + throwOnFailure: true, + ), + throwsA(isA() + .having( + (ProcessPackageExecutableNotFoundException + notFoundException) => + notFoundException.candidates, + 'candidates', + isEmpty) + .having( + (ProcessPackageExecutableNotFoundException + notFoundException) => + notFoundException.toString(), + 'toString', + contains( + 'ProcessPackageExecutableNotFoundException: Failed to find "non-existent-command" in the search path.\n' + ' Command: non-existent-command\n' + ' Working Directory: ${tmpDir.path}\n' + ' Search Path:\n' + ' ${tmpDir.path}/path1\n' + ' ${tmpDir.path}/path2\n' + ' ${tmpDir.path}/path3\n' + ' ${tmpDir.path}/path4\n' + ' ${tmpDir.path}/path5\n')))); + }); + }); +} + +void _expectSamePath(String? actual, String? expected) { + expect(actual, isNotNull); + expect(actual!.toLowerCase(), expected!.toLowerCase()); +} + +class MemoryFileSystemNoCwd extends ForwardingFileSystem { + MemoryFileSystemNoCwd(super.delegate); + + @override + Directory get currentDirectory { + throw const FileSystemException('Access denied'); + } +} diff --git a/pkgs/process/test/src/interface/process_wrapper_test.dart b/pkgs/process/test/src/interface/process_wrapper_test.dart new file mode 100644 index 000000000..27f8cfa2f --- /dev/null +++ b/pkgs/process/test/src/interface/process_wrapper_test.dart @@ -0,0 +1,116 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. 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; + +import 'package:process/process.dart'; +import 'package:test/test.dart'; + +void main() { + group('ProcessWrapper', () { + late TestProcess delegate; + late ProcessWrapper process; + + setUp(() { + delegate = TestProcess(); + process = ProcessWrapper(delegate); + }); + + group('done', () { + late bool done; + + setUp(() { + done = false; + // ignore: unawaited_futures + process.done.then((int result) { + done = true; + }); + }); + + test('completes only when all done', () async { + expect(done, isFalse); + delegate.exitCodeCompleter.complete(0); + await Future.value(); + expect(done, isFalse); + await delegate.stdoutController.close(); + await Future.value(); + expect(done, isFalse); + await delegate.stderrController.close(); + await Future.value(); + expect(done, isTrue); + expect(await process.exitCode, 0); + }); + + test('works in conjunction with subscribers to stdio streams', () async { + process.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + // ignore: avoid_print + .listen(print); + delegate.exitCodeCompleter.complete(0); + await delegate.stdoutController.close(); + await delegate.stderrController.close(); + await Future.value(); + expect(done, isTrue); + }); + }); + + group('stdio', () { + test('streams properly close', () async { + Future testStream( + Stream> stream, + StreamController> controller, + String name, + ) async { + bool closed = false; + stream.listen( + (_) {}, + onDone: () { + closed = true; + }, + ); + await controller.close(); + await Future.value(); + expect(closed, isTrue, reason: 'for $name'); + } + + await testStream(process.stdout, delegate.stdoutController, 'stdout'); + await testStream(process.stderr, delegate.stderrController, 'stderr'); + }); + }); + }); +} + +class TestProcess implements io.Process { + TestProcess([this.pid = 123]) + : exitCodeCompleter = Completer(), + stdoutController = StreamController>(), + stderrController = StreamController>(); + + @override + final int pid; + final Completer exitCodeCompleter; + final StreamController> stdoutController; + final StreamController> stderrController; + + @override + Future get exitCode => exitCodeCompleter.future; + + @override + bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) { + exitCodeCompleter.complete(-1); + return true; + } + + @override + Stream> get stderr => stderrController.stream; + + @override + io.IOSink get stdin => throw UnsupportedError('Not supported'); + + @override + Stream> get stdout => stdoutController.stream; +} diff --git a/pkgs/process/test/utils.dart b/pkgs/process/test/utils.dart new file mode 100644 index 000000000..d35e3cf75 --- /dev/null +++ b/pkgs/process/test/utils.dart @@ -0,0 +1,14 @@ +// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file +// for details. 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:convert'; + +/// Decodes a UTF8-encoded byte array into a list of Strings, where each list +/// entry represents a line of text. +List decode(List data) => + const LineSplitter().convert(utf8.decode(data)); + +/// Consumes and returns an entire stream of bytes. +Future> consume(Stream> stream) => + stream.expand((List data) => data).toList();