Add package:process (#2097)
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 60ddae2..a50b447 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -28,6 +28,7 @@
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 6bdbffd..dbaf8e5 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 0000000..9804433
--- /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 0000000..1f1b0df
--- /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 0000000..044d617
--- /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 <email address>
+
+Google Inc.
+
+Bartek Pacia / @bartekpacia
diff --git a/pkgs/process/CHANGELOG.md b/pkgs/process/CHANGELOG.md
new file mode 100644
index 0000000..3791f21
--- /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 0000000..ed771e6
--- /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 0000000..9cd7dde
--- /dev/null
+++ b/pkgs/process/README.md
@@ -0,0 +1,17 @@
+[](https://pub.dev/packages/process)
+[](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 0000000..91ec220
--- /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 0000000..2618e6d
--- /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 0000000..d374631
--- /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<String, String> _osToPathStyle = <String, String>{
+ '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<String> extensions = <String>[];
+ if (platform.isWindows && context.extension(executable).isEmpty) {
+ extensions = platform.environment['PATHEXT']!.split(pathSeparator);
+ }
+
+ List<String> candidates = <String>[];
+ List<String> searchPath;
+ if (executable.contains(context.separator)) {
+ // Deal with commands that specify a relative or absolute path differently.
+ searchPath = <String>[workingDirectory];
+ } else {
+ searchPath = platform.environment['PATH']!.split(pathSeparator);
+ }
+ candidates = _getCandidatePaths(executable, searchPath, extensions, context);
+ final List<String> foundCandidates = <String>[];
+ 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<String> _getCandidatePaths(
+ String command,
+ List<String> searchPaths,
+ List<String> extensions,
+ Context context,
+) {
+ final List<String> withExtensions = extensions.isNotEmpty
+ ? extensions.map((String ext) => '$command$ext').toList()
+ : <String>[command];
+ if (context.isAbsolute(command)) {
+ return withExtensions;
+ }
+ return searchPaths
+ .map((String path) =>
+ withExtensions.map((String command) => context.join(path, command)))
+ .expand((Iterable<String> e) => e)
+ .toList()
+ .cast<String>();
+}
diff --git a/pkgs/process/lib/src/interface/exceptions.dart b/pkgs/process/lib/src/interface/exceptions.dart
new file mode 100644
index 0000000..dec80d3
--- /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<String> arguments = const <String>[],
+ 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 <String>[],
+ this.searchPath = const <String>[],
+ });
+
+ /// The list of non-viable executable candidates found.
+ final List<String> candidates;
+
+ /// The search path used to find candidates.
+ final List<String> 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 0000000..ab7e96a
--- /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<String>` will trivially work as expected.
+class LocalProcessManager implements ProcessManager {
+ /// Creates a new `LocalProcessManager`.
+ const LocalProcessManager();
+
+ @override
+ Future<Process> start(
+ List<Object> command, {
+ String? workingDirectory,
+ Map<String, String>? 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<ProcessResult> run(
+ List<Object> command, {
+ String? workingDirectory,
+ Map<String, String>? 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<Object> command, {
+ String? workingDirectory,
+ Map<String, String>? 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<dynamic> command, String? workingDirectory, bool runInShell) {
+ final String commandName = command.first.toString();
+ if (runInShell) {
+ return commandName;
+ }
+ return getExecutablePath(
+ commandName,
+ workingDirectory,
+ throwOnFailure: true,
+ )!;
+}
+
+List<String> _getArguments(List<Object> 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<String>((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 0000000..69f6a2a
--- /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<Process>` 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<Process> start(
+ List<Object> command, {
+ String? workingDirectory,
+ Map<String, String>? 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<ProcessResult>` 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<ProcessResult> run(
+ List<Object> command, {
+ String? workingDirectory,
+ Map<String, String>? 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<Object> command, {
+ String? workingDirectory,
+ Map<String, String>? 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 0000000..9e53071
--- /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<List<int>>(),
+ _stderr = StreamController<List<int>>(),
+ _stdoutDone = Completer<void>(),
+ _stderrDone = Completer<void>() {
+ _monitorStdioStream(_delegate.stdout, _stdout, _stdoutDone);
+ _monitorStdioStream(_delegate.stderr, _stderr, _stderrDone);
+ }
+
+ final io.Process _delegate;
+ final StreamController<List<int>> _stdout;
+ final StreamController<List<int>> _stderr;
+ final Completer<void> _stdoutDone;
+ final Completer<void> _stderrDone;
+
+ /// Listens to the specified [stream], repeating events on it via
+ /// [controller], and completing [completer] once the stream is done.
+ void _monitorStdioStream(
+ Stream<List<int>> stream,
+ StreamController<List<int>> controller,
+ Completer<void> completer,
+ ) {
+ stream.listen(
+ controller.add,
+ onError: controller.addError,
+ onDone: () {
+ controller.close();
+ completer.complete();
+ },
+ );
+ }
+
+ @override
+ Future<int> 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<int> get done async {
+ late int result;
+ await Future.wait<void>(<Future<void>>[
+ _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<List<int>> get stdout => _stdout.stream;
+
+ @override
+ Stream<List<int>> get stderr => _stderr.stream;
+}
diff --git a/pkgs/process/pubspec.yaml b/pkgs/process/pubspec.yaml
new file mode 100644
index 0000000..38ad51c
--- /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 0000000..8290b5e
--- /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 <Directory>[
+ workingDir,
+ dir1,
+ dir2,
+ dir3
+ ]) {
+ directory.deleteSync(recursive: true);
+ }
+ });
+
+ group('on windows', () {
+ late Platform platform;
+
+ initialize(FileSystemStyle.windows);
+
+ setUp(() {
+ platform = FakePlatform(
+ operatingSystem: 'windows',
+ environment: <String, String>{
+ '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<ProcessPackageExecutableNotFoundException>()
+ .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: <String, String>{'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<ProcessPackageExecutableNotFoundException>()
+ .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: <String, String>{
+ 'PATH': <Directory>[
+ pathDir1,
+ pathDir2,
+ pathDir3,
+ pathDir4,
+ pathDir5,
+ ].map<String>((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', <String>['0644', '--', command1.path]);
+ // Only group executable permissions
+ io.Process.runSync('chmod', <String>['0645', '--', command2.path]);
+ // Only other executable permissions
+ io.Process.runSync('chmod', <String>['0654', '--', command3.path]);
+ // All executable permissions, but not readable
+ io.Process.runSync('chmod', <String>['0311', '--', command4.path]);
+ // All executable permissions
+ io.Process.runSync('chmod', <String>['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', <String>['0644', '--', command1.path]);
+ // Only group executable permissions
+ io.Process.runSync('chmod', <String>['0645', '--', command2.path]);
+ // Only other executable permissions
+ io.Process.runSync('chmod', <String>['0654', '--', command3.path]);
+ // All executable permissions, but not readable
+ io.Process.runSync('chmod', <String>['0311', '--', command4.path]);
+
+ expect(
+ () => getExecutablePath(
+ 'command',
+ tmpDir.path,
+ platform: platform,
+ fs: fs,
+ throwOnFailure: true,
+ ),
+ throwsA(isA<ProcessPackageExecutableNotFoundException>()
+ .having(
+ (ProcessPackageExecutableNotFoundException
+ notFoundException) =>
+ notFoundException.candidates,
+ 'candidates',
+ equals(<String>[
+ '${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<ProcessPackageExecutableNotFoundException>()
+ .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 0000000..27f8cfa
--- /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<void>.value();
+ expect(done, isFalse);
+ await delegate.stdoutController.close();
+ await Future<void>.value();
+ expect(done, isFalse);
+ await delegate.stderrController.close();
+ await Future<void>.value();
+ expect(done, isTrue);
+ expect(await process.exitCode, 0);
+ });
+
+ test('works in conjunction with subscribers to stdio streams', () async {
+ process.stdout
+ .transform<String>(utf8.decoder)
+ .transform<String>(const LineSplitter())
+ // ignore: avoid_print
+ .listen(print);
+ delegate.exitCodeCompleter.complete(0);
+ await delegate.stdoutController.close();
+ await delegate.stderrController.close();
+ await Future<void>.value();
+ expect(done, isTrue);
+ });
+ });
+
+ group('stdio', () {
+ test('streams properly close', () async {
+ Future<void> testStream(
+ Stream<List<int>> stream,
+ StreamController<List<int>> controller,
+ String name,
+ ) async {
+ bool closed = false;
+ stream.listen(
+ (_) {},
+ onDone: () {
+ closed = true;
+ },
+ );
+ await controller.close();
+ await Future<void>.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<int>(),
+ stdoutController = StreamController<List<int>>(),
+ stderrController = StreamController<List<int>>();
+
+ @override
+ final int pid;
+ final Completer<int> exitCodeCompleter;
+ final StreamController<List<int>> stdoutController;
+ final StreamController<List<int>> stderrController;
+
+ @override
+ Future<int> get exitCode => exitCodeCompleter.future;
+
+ @override
+ bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) {
+ exitCodeCompleter.complete(-1);
+ return true;
+ }
+
+ @override
+ Stream<List<int>> get stderr => stderrController.stream;
+
+ @override
+ io.IOSink get stdin => throw UnsupportedError('Not supported');
+
+ @override
+ Stream<List<int>> get stdout => stdoutController.stream;
+}
diff --git a/pkgs/process/test/utils.dart b/pkgs/process/test/utils.dart
new file mode 100644
index 0000000..d35e3cf
--- /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<String> decode(List<int> data) =>
+ const LineSplitter().convert(utf8.decode(data));
+
+/// Consumes and returns an entire stream of bytes.
+Future<List<int>> consume(Stream<List<int>> stream) =>
+ stream.expand((List<int> data) => data).toList();