diff --git a/composer.json b/composer.json
index 1d5d1b8d1..1f1912412 100644
--- a/composer.json
+++ b/composer.json
@@ -34,7 +34,7 @@
"psr/log": "^3.0.0",
"symfony/cache": "^7.3",
"symfony/mailer": "^7.2.6",
- "symfony/process": "^7.1.7",
+ "symfony/process": "^7.3",
"symfony/uid": "^7.1",
"symfony/var-dumper": "^7.1",
"symfony/var-exporter": "^7.1",
@@ -98,6 +98,7 @@
"tempest/log": "self.version",
"tempest/mail": "self.version",
"tempest/mapper": "self.version",
+ "tempest/process": "self.version",
"tempest/reflection": "self.version",
"tempest/router": "self.version",
"tempest/storage": "self.version",
@@ -137,6 +138,7 @@
"Tempest\\Log\\": "packages/log/src",
"Tempest\\Mail\\": "packages/mail/src",
"Tempest\\Mapper\\": "packages/mapper/src",
+ "Tempest\\Process\\": "packages/process/src",
"Tempest\\Reflection\\": "packages/reflection/src",
"Tempest\\Router\\": "packages/router/src",
"Tempest\\Storage\\": "packages/storage/src",
@@ -201,6 +203,7 @@
"Tempest\\Log\\Tests\\": "packages/log/tests",
"Tempest\\Mail\\Tests\\": "packages/mail/tests",
"Tempest\\Mapper\\Tests\\": "packages/mapper/tests",
+ "Tempest\\Process\\Tests\\": "packages/process/tests",
"Tempest\\Reflection\\Tests\\": "packages/reflection/tests",
"Tempest\\Router\\Tests\\": "packages/router/tests",
"Tempest\\Storage\\Tests\\": "packages/storage/tests",
diff --git a/packages/console/src/Initializers/InvocationExecutorInitializer.php b/packages/console/src/Initializers/InvocationExecutorInitializer.php
deleted file mode 100644
index 8e71567b6..000000000
--- a/packages/console/src/Initializers/InvocationExecutorInitializer.php
+++ /dev/null
@@ -1,29 +0,0 @@
-get(Application::class);
-
- if (! ($app instanceof ConsoleApplication)) {
- return new NullShellExecutor();
- }
-
- return new GenericShellExecutor();
- }
-}
diff --git a/packages/console/src/Initializers/SchedulerInitializer.php b/packages/console/src/Initializers/SchedulerInitializer.php
index 0f98c378a..836fcc8ba 100644
--- a/packages/console/src/Initializers/SchedulerInitializer.php
+++ b/packages/console/src/Initializers/SchedulerInitializer.php
@@ -14,7 +14,7 @@
use Tempest\Container\Initializer;
use Tempest\Container\Singleton;
use Tempest\Core\Application;
-use Tempest\Core\ShellExecutor;
+use Tempest\Process\ProcessExecutor;
final readonly class SchedulerInitializer implements Initializer
{
@@ -30,7 +30,7 @@ public function initialize(Container $container): Scheduler
return new GenericScheduler(
$container->get(SchedulerConfig::class),
$container->get(ConsoleArgumentBag::class),
- $container->get(ShellExecutor::class),
+ $container->get(ProcessExecutor::class),
);
}
}
diff --git a/packages/console/src/Scheduler/GenericScheduler.php b/packages/console/src/Scheduler/GenericScheduler.php
index e71332b9f..1cfc370ca 100644
--- a/packages/console/src/Scheduler/GenericScheduler.php
+++ b/packages/console/src/Scheduler/GenericScheduler.php
@@ -7,7 +7,7 @@
use DateTime;
use Tempest\Console\Input\ConsoleArgumentBag;
use Tempest\Console\Scheduler;
-use Tempest\Core\ShellExecutor;
+use Tempest\Process\ProcessExecutor;
use Tempest\Support\Filesystem;
use function Tempest\event;
@@ -18,7 +18,7 @@
public function __construct(
private SchedulerConfig $config,
private ConsoleArgumentBag $argumentBag,
- private ShellExecutor $executor,
+ private ProcessExecutor $executor,
) {}
public static function getCachePath(): string
@@ -43,7 +43,7 @@ private function execute(ScheduledInvocation $invocation): void
{
$command = $this->compileInvocation($invocation);
- $this->executor->execute($command);
+ $this->executor->run($command);
}
private function compileInvocation(ScheduledInvocation $invocation): string
diff --git a/packages/core/src/Composer.php b/packages/core/src/Composer.php
index d6da77af9..c7f5651b5 100644
--- a/packages/core/src/Composer.php
+++ b/packages/core/src/Composer.php
@@ -4,15 +4,14 @@
namespace Tempest\Core;
+use Tempest\Process\ProcessExecutor;
+use Tempest\Support\Arr;
use Tempest\Support\Filesystem;
-use Tempest\Support\Json;
use Tempest\Support\Namespace\Psr4Namespace;
+use Tempest\Support\Path;
+use Tempest\Support\Str;
use function Tempest\Support\arr;
-use function Tempest\Support\Arr\wrap;
-use function Tempest\Support\Path\normalize;
-use function Tempest\Support\Str\ensure_ends_with;
-use function Tempest\Support\Str\starts_with;
final class Composer
{
@@ -27,12 +26,12 @@ final class Composer
public function __construct(
private readonly string $root,
- private ShellExecutor $executor,
+ private ProcessExecutor $executor,
) {}
public function load(): self
{
- $this->composerPath = normalize($this->root, 'composer.json');
+ $this->composerPath = Path\normalize($this->root, 'composer.json');
$this->composer = $this->loadComposerFile($this->composerPath);
$this->namespaces = arr($this->composer)
->get('autoload.psr-4', default: arr())
@@ -42,7 +41,7 @@ public function load(): self
->toArray();
foreach ($this->namespaces as $namespace) {
- if (starts_with(ensure_ends_with($namespace->path, '/'), ['app/', 'src/', 'source/', 'lib/'])) {
+ if (Str\starts_with(Str\ensure_ends_with($namespace->path, '/'), ['app/', 'src/', 'source/', 'lib/'])) {
$this->mainNamespace = $namespace;
break;
@@ -73,12 +72,12 @@ public function setMainNamespace(Psr4Namespace $namespace): self
public function setNamespaces(Psr4Namespace|array $namespaces): self
{
- $this->namespaces = wrap($namespaces);
+ $this->namespaces = Arr\wrap($namespaces);
return $this;
}
- public function setShellExecutor(ShellExecutor $executor): self
+ public function setProcessExecutor(ProcessExecutor $executor): self
{
$this->executor = $executor;
@@ -103,7 +102,7 @@ public function save(): self
public function executeUpdate(): self
{
- $this->executor->execute('composer up');
+ $this->executor->run('composer up');
return $this;
}
diff --git a/packages/core/src/FrameworkKernel.php b/packages/core/src/FrameworkKernel.php
index 66dec6767..aa21311a5 100644
--- a/packages/core/src/FrameworkKernel.php
+++ b/packages/core/src/FrameworkKernel.php
@@ -12,8 +12,8 @@
use Tempest\Core\Kernel\LoadDiscoveryClasses;
use Tempest\Core\Kernel\LoadDiscoveryLocations;
use Tempest\Core\Kernel\RegisterEmergencyExceptionHandler;
-use Tempest\Core\ShellExecutors\GenericShellExecutor;
use Tempest\EventBus\EventBus;
+use Tempest\Process\GenericProcessExecutor;
final class FrameworkKernel implements Kernel
{
@@ -98,7 +98,7 @@ public function loadComposer(): self
{
$composer = new Composer(
root: $this->root,
- executor: new GenericShellExecutor(),
+ executor: new GenericProcessExecutor(),
)->load();
$this->container->singleton(Composer::class, $composer);
diff --git a/packages/core/src/ShellExecutor.php b/packages/core/src/ShellExecutor.php
deleted file mode 100644
index 661fd0dd1..000000000
--- a/packages/core/src/ShellExecutor.php
+++ /dev/null
@@ -1,10 +0,0 @@
-executedCommands[] = $command;
- }
-}
diff --git a/packages/process/.gitattributes b/packages/process/.gitattributes
new file mode 100644
index 000000000..3f7775660
--- /dev/null
+++ b/packages/process/.gitattributes
@@ -0,0 +1,14 @@
+# Exclude build/test files from the release
+.github/ export-ignore
+tests/ export-ignore
+.gitattributes export-ignore
+.gitignore export-ignore
+phpunit.xml export-ignore
+README.md export-ignore
+
+# Configure diff output
+*.view.php diff=html
+*.php diff=php
+*.css diff=css
+*.html diff=html
+*.md diff=markdown
diff --git a/packages/process/LICENSE.md b/packages/process/LICENSE.md
new file mode 100644
index 000000000..54215b726
--- /dev/null
+++ b/packages/process/LICENSE.md
@@ -0,0 +1,9 @@
+The MIT License (MIT)
+
+Copyright (c) 2024 Brent Roose brendt@stitcher.io
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/packages/process/composer.json b/packages/process/composer.json
new file mode 100644
index 000000000..2a174dd87
--- /dev/null
+++ b/packages/process/composer.json
@@ -0,0 +1,23 @@
+{
+ "name": "tempest/process",
+ "description": "A component for working with processes.",
+ "license": "MIT",
+ "minimum-stability": "dev",
+ "require": {
+ "php": "^8.4",
+ "symfony/process": "^7.3",
+ "tempest/container": "dev-main",
+ "tempest/support": "dev-main",
+ "tempest/datetime": "dev-main"
+ },
+ "autoload": {
+ "psr-4": {
+ "Tempest\\Process\\": "src"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Tempest\\Process\\Tests\\": "tests"
+ }
+ }
+}
diff --git a/packages/process/phpunit.xml b/packages/process/phpunit.xml
new file mode 100644
index 000000000..3a01cb642
--- /dev/null
+++ b/packages/process/phpunit.xml
@@ -0,0 +1,23 @@
+
+
+
+
+ tests
+
+
+
+
+ src
+
+
+
diff --git a/packages/process/src/Exceptions/ProcessException.php b/packages/process/src/Exceptions/ProcessException.php
new file mode 100644
index 000000000..00c0f6925
--- /dev/null
+++ b/packages/process/src/Exceptions/ProcessException.php
@@ -0,0 +1,9 @@
+getMessage(), $original->getCode(), $original);
+ }
+}
diff --git a/packages/process/src/GenericProcessExecutor.php b/packages/process/src/GenericProcessExecutor.php
new file mode 100644
index 000000000..0683b9b22
--- /dev/null
+++ b/packages/process/src/GenericProcessExecutor.php
@@ -0,0 +1,77 @@
+createPendingProcess($command);
+ $command = $this->createSymfonyProcess($pending);
+ $command->start();
+
+ return new InvokedSystemProcess($command);
+ }
+
+ public function run(string|PendingProcess $command): ProcessResult
+ {
+ return $this->start($command)->wait();
+ }
+
+ public function pool(iterable $pool): Pool
+ {
+ return new Pool(
+ pendingProcesses: new ImmutableArray($pool)->map($this->createPendingProcess(...)),
+ processExecutor: $this,
+ );
+ }
+
+ public function concurrently(iterable $pool): ProcessPoolResults
+ {
+ return $this->pool($pool)->start()->wait();
+ }
+
+ private function createPendingProcess(array|string|PendingProcess $processOrCommand): PendingProcess
+ {
+ if ($processOrCommand instanceof PendingProcess) {
+ return $processOrCommand;
+ }
+
+ return new PendingProcess(command: $processOrCommand);
+ }
+
+ private function createSymfonyProcess(PendingProcess $pending): SymfonyProcess
+ {
+ $process = is_iterable($pending->command)
+ ? new SymfonyProcess($pending->command, env: $pending->environment)
+ : SymfonyProcess::fromShellCommandline((string) $pending->command, env: $pending->environment);
+
+ $process->setWorkingDirectory((string) ($pending->path ?? getcwd()));
+ $process->setTimeout($pending->timeout?->getTotalSeconds());
+
+ if ($pending->idleTimeout) {
+ $process->setIdleTimeout($pending->idleTimeout->getTotalSeconds());
+ }
+
+ if ($pending->input) {
+ $process->setInput($pending->input);
+ }
+
+ if ($pending->quietly) {
+ $process->disableOutput();
+ }
+
+ if ($pending->tty) {
+ $process->setTty(true);
+ }
+
+ if ($pending->options !== []) {
+ $process->setOptions($pending->options);
+ }
+
+ return $process;
+ }
+}
diff --git a/packages/process/src/InvokedProcess.php b/packages/process/src/InvokedProcess.php
new file mode 100644
index 000000000..d4a96f8a8
--- /dev/null
+++ b/packages/process/src/InvokedProcess.php
@@ -0,0 +1,56 @@
+ $this->processes
+ ->toImmutableArray()
+ ->filter(fn (InvokedProcess $process) => $process->running);
+ }
+
+ /**
+ * All processes in the pool.
+ *
+ * @var ImmutableArray
+ */
+ public ImmutableArray $all {
+ get => $this->processes->toImmutableArray();
+ }
+
+ public function __construct(
+ /** @var MutableArray */
+ private MutableArray $processes,
+ ) {}
+
+ /**
+ * Send a signal to each running process in the pool.
+ */
+ public function signal(int $signal): ImmutableArray
+ {
+ return $this->running->each(fn (InvokedProcess $process) => $process->signal($signal));
+ }
+
+ /**
+ * Stops all processes that are currently running.
+ */
+ public function stop(float|int|Duration $timeout = 10, ?int $signal = null): ImmutableArray
+ {
+ return $this->running->each(fn (InvokedProcess $process) => $process->stop($timeout, $signal));
+ }
+
+ /**
+ * Waits for all processes in the pool to finish and returns their results.
+ */
+ public function wait(): ProcessPoolResults
+ {
+ return new ProcessPoolResults(
+ $this->all->map(fn (InvokedProcess $process) => $process->wait()),
+ );
+ }
+
+ /**
+ * Iterates over each running process in the pool and applies the given callback.
+ */
+ public function forEachRunning(\Closure $callback): self
+ {
+ $this->running->each(fn (InvokedProcess $process) => $callback($process));
+
+ return $this;
+ }
+
+ /**
+ * Iterates over each invoked process in the pool and applies the given callback.
+ */
+ public function forEach(\Closure $callback): self
+ {
+ $this->processes->each(fn (InvokedProcess $process) => $callback($process));
+
+ return $this;
+ }
+
+ public function count(): int
+ {
+ return $this->processes->count();
+ }
+}
diff --git a/packages/process/src/InvokedSystemProcess.php b/packages/process/src/InvokedSystemProcess.php
new file mode 100644
index 000000000..dada1c23a
--- /dev/null
+++ b/packages/process/src/InvokedSystemProcess.php
@@ -0,0 +1,82 @@
+ $this->process->getPid();
+ }
+
+ /**
+ * Whether the process is running.
+ */
+ public bool $running {
+ get => $this->process->isRunning();
+ }
+
+ /**
+ * Gets the output of the process.
+ */
+ public string $output {
+ get => $this->process->getOutput();
+ }
+
+ /**
+ * Gets the error output of the process.
+ */
+ public string $errorOutput {
+ get => $this->process->getErrorOutput();
+ }
+
+ public function __construct(
+ private readonly SymfonyProcess $process,
+ ) {}
+
+ public function signal(int $signal): self
+ {
+ $this->process->signal($signal);
+
+ return $this;
+ }
+
+ public function stop(float|int|Duration $timeout = 10, ?int $signal = null): self
+ {
+ if ($timeout instanceof Duration) {
+ $timeout = $timeout->getTotalSeconds();
+ }
+
+ $this->process->stop((float) $timeout, $signal);
+
+ return $this;
+ }
+
+ public function wait(?callable $output = null): ProcessResult
+ {
+ try {
+ $callback = $output
+ ? fn (string $type, mixed $data) => $output(OutputChannel::fromSymfonyOutputType($type), $data)
+ : null;
+
+ $this->process->wait($callback);
+
+ return ProcessResult::fromSymfonyProcess($this->process);
+ } catch (SymfonyTimeoutException $exception) {
+ throw new ProcessHasTimedOut(
+ result: ProcessResult::fromSymfonyProcess($this->process),
+ original: $exception,
+ );
+ }
+ }
+}
diff --git a/packages/process/src/OutputChannel.php b/packages/process/src/OutputChannel.php
new file mode 100644
index 000000000..1eaea91b3
--- /dev/null
+++ b/packages/process/src/OutputChannel.php
@@ -0,0 +1,19 @@
+ self::OUTPUT,
+ Process::ERR => self::ERROR,
+ };
+ }
+}
diff --git a/packages/process/src/PendingProcess.php b/packages/process/src/PendingProcess.php
new file mode 100644
index 000000000..ba2f018f9
--- /dev/null
+++ b/packages/process/src/PendingProcess.php
@@ -0,0 +1,34 @@
+ $environment Environment variables to set for the process.
+ * @param array $options Underlying `proc_open` options.
+ */
+ public function __construct(
+ private(set) array|string $command = [],
+ private(set) ?Duration $timeout = null,
+ private(set) ?Duration $idleTimeout = null,
+ private(set) ?string $path = null,
+ private(set) ?string $input = null,
+ private(set) bool $quietly = false,
+ private(set) bool $tty = false,
+ private(set) array $environment = [],
+ private(set) array $options = [],
+ ) {}
+}
diff --git a/packages/process/src/Pool.php b/packages/process/src/Pool.php
new file mode 100644
index 000000000..e65852535
--- /dev/null
+++ b/packages/process/src/Pool.php
@@ -0,0 +1,42 @@
+ */
+ private ImmutableArray $pendingProcesses,
+ private ProcessExecutor $processExecutor,
+ ) {}
+
+ /**
+ * Gets all pending processes.
+ */
+ public function processes(): ImmutableArray
+ {
+ return $this->pendingProcesses;
+ }
+
+ /**
+ * Start all pending processes in the pool.
+ */
+ public function start(): InvokedProcessPool
+ {
+ $processes = $this->pendingProcesses
+ ->map(fn (PendingProcess $pending) => $this->processExecutor->start($pending))
+ ->toMutableArray();
+
+ return new InvokedProcessPool($processes);
+ }
+
+ /**
+ * Starts all pending processes in the pool and wait for them to finish.
+ */
+ public function run(): ProcessPoolResults
+ {
+ return $this->start()->wait();
+ }
+}
diff --git a/packages/process/src/ProcessExecutor.php b/packages/process/src/ProcessExecutor.php
new file mode 100644
index 000000000..3a061a927
--- /dev/null
+++ b/packages/process/src/ProcessExecutor.php
@@ -0,0 +1,34 @@
+ $pool
+ */
+ public function pool(iterable $pool): Pool;
+
+ /**
+ * Executes a pool of processes concurrently and returns the results.
+ *
+ * @param iterable $pool
+ */
+ public function concurrently(iterable $pool): ProcessPoolResults;
+}
diff --git a/packages/process/src/ProcessExecutorInitializer.php b/packages/process/src/ProcessExecutorInitializer.php
new file mode 100644
index 000000000..53d1c3054
--- /dev/null
+++ b/packages/process/src/ProcessExecutorInitializer.php
@@ -0,0 +1,16 @@
+ */
+ private ImmutableArray $results,
+ ) {}
+
+ /**
+ * Determines whether all results in the pool were successful.
+ */
+ public function allSuccessful(): bool
+ {
+ return $this->results->every(fn (ProcessResult $result) => $result->successful());
+ }
+
+ /**
+ * Determines whether all results in the pool failed.
+ */
+ public function allFailed(): bool
+ {
+ return $this->results->every(fn (ProcessResult $result) => $result->failed());
+ }
+
+ /**
+ * Determines whether there are any successful results in the pool.
+ */
+ public function someSuccessful(): bool
+ {
+ return $this->results->filter(fn (ProcessResult $result) => $result->successful())->count() > 0;
+ }
+
+ /**
+ * Determines whether there are any failed results in the pool.
+ */
+ public function someFailed(): bool
+ {
+ return $this->results->filter(fn (ProcessResult $result) => $result->failed())->count() > 0;
+ }
+
+ /**
+ * Returns all results that were successful.
+ */
+ public function successful(): ImmutableArray
+ {
+ return $this->results->filter(fn (ProcessResult $result) => $result->successful());
+ }
+
+ /**
+ * Returns all results that failed.
+ */
+ public function failed(): ImmutableArray
+ {
+ return $this->results->filter(fn (ProcessResult $result) => ! $result->successful());
+ }
+
+ public function toImmutableArray(): ImmutableArray
+ {
+ return $this->results;
+ }
+
+ public function toArray(): array
+ {
+ return $this->results->toArray();
+ }
+
+ public function current(): ProcessResult
+ {
+ return $this->results->current();
+ }
+
+ public function next(): void
+ {
+ $this->results->next();
+ }
+
+ public function key(): int|string
+ {
+ return $this->results->key();
+ }
+
+ public function valid(): bool
+ {
+ return $this->results->valid();
+ }
+
+ public function rewind(): void
+ {
+ $this->results->rewind();
+ }
+
+ public function offsetExists(mixed $offset): bool
+ {
+ return $this->results->offsetExists($offset);
+ }
+
+ public function offsetGet(mixed $offset): ProcessResult
+ {
+ return $this->results->offsetGet($offset);
+ }
+
+ public function offsetSet(mixed $offset, mixed $value): void
+ {
+ throw new \BadMethodCallException('ProcessPoolResults is immutable and cannot be modified.');
+ }
+
+ public function offsetUnset(mixed $offset): void
+ {
+ throw new \BadMethodCallException('ProcessPoolResults is immutable and cannot be modified.');
+ }
+
+ public function count(): int
+ {
+ return $this->results->count();
+ }
+}
diff --git a/packages/process/src/ProcessResult.php b/packages/process/src/ProcessResult.php
new file mode 100644
index 000000000..e602e2c3f
--- /dev/null
+++ b/packages/process/src/ProcessResult.php
@@ -0,0 +1,42 @@
+exitCode === 0;
+ }
+
+ /**
+ * Determines whether the process has failed.
+ */
+ public function failed(): bool
+ {
+ return ! $this->successful();
+ }
+
+ public static function fromSymfonyProcess(SymfonyProcess $process): self
+ {
+ return new self(
+ exitCode: $process->getExitCode(),
+ output: $process->getOutput(),
+ errorOutput: $process->getErrorOutput(),
+ );
+ }
+}
diff --git a/packages/process/src/Testing/InvokedProcessDescription.php b/packages/process/src/Testing/InvokedProcessDescription.php
new file mode 100644
index 000000000..1f3c2e650
--- /dev/null
+++ b/packages/process/src/Testing/InvokedProcessDescription.php
@@ -0,0 +1,115 @@
+pid = $pid;
+
+ return $this;
+ }
+
+ /**
+ * Describes a line of standard output in the order it should be received.
+ */
+ public function output(string|array $output): self
+ {
+ if (is_string($output)) {
+ $output = [$output];
+ }
+
+ foreach ($output as $item) {
+ $this->output[] = [
+ 'type' => OutputChannel::OUTPUT,
+ 'buffer' => rtrim($item, "\n") . "\n",
+ ];
+ }
+
+ return $this;
+ }
+
+ /**
+ * Describes a line of error output in the order it should be received.
+ */
+ public function errorOutput(string|array $errorOutput): self
+ {
+ if (is_string($errorOutput)) {
+ $errorOutput = [$errorOutput];
+ }
+
+ foreach ($errorOutput as $item) {
+ $this->output[] = [
+ 'type' => OutputChannel::ERROR,
+ 'buffer' => rtrim($item, "\n") . "\n",
+ ];
+ }
+
+ return $this;
+ }
+
+ /**
+ * Defines the exit code of the process.
+ */
+ public function exitCode(int $exitCode): self
+ {
+ $this->exitCode = $exitCode;
+
+ return $this;
+ }
+
+ /**
+ * Specify how many times the "isRunning" method should return "true".
+ */
+ public function iterations(int $iterations): self
+ {
+ $this->runIterations = $iterations;
+
+ return $this;
+ }
+
+ /**
+ * Returns the output of the process, filtered by the type of output.
+ */
+ public function resolveOutput(bool $error = false): string
+ {
+ $expectedType = $error ? OutputChannel::ERROR : OutputChannel::OUTPUT;
+
+ return arr($this->output)
+ ->filter(static fn (array $output) => $output['type'] === $expectedType)
+ ->map(static fn (array $output) => rtrim($output['buffer'], "\n"))
+ ->implode("\n")
+ ->when(fn ($s) => $s->isNotEmpty(), fn ($s) => $s->finish("\n"))
+ ->toString();
+ }
+}
diff --git a/packages/process/src/Testing/InvokedTestingProcess.php b/packages/process/src/Testing/InvokedTestingProcess.php
new file mode 100644
index 000000000..dabafa4dc
--- /dev/null
+++ b/packages/process/src/Testing/InvokedTestingProcess.php
@@ -0,0 +1,233 @@
+invokeOutputHandlerWithNextLineOfOutput();
+
+ return $this->description->pid;
+ }
+ }
+
+ public bool $running {
+ get {
+ $this->invokeOutputHandlerWithNextLineOfOutput();
+
+ if ($this->remainingRunIterations === 0) {
+ // @mago-expect best-practices/no-empty-loop
+ while ($this->invokeOutputHandlerWithNextLineOfOutput()) {
+ }
+
+ return false;
+ }
+
+ $this->remainingRunIterations -= 1;
+
+ return true;
+ }
+ }
+
+ public string $output {
+ get {
+ $this->latestOutput();
+
+ $output = [];
+
+ for ($i = 0; $i < $this->nextOutputIndex; $i++) {
+ if ($this->description->output[$i]['type'] === OutputChannel::OUTPUT) {
+ $output[] = $this->description->output[$i]['buffer'];
+ }
+ }
+
+ return rtrim(implode('', $output), "\n") . "\n";
+ }
+ }
+
+ public string $errorOutput {
+ get {
+ $this->latestErrorOutput();
+
+ $output = [];
+
+ for ($i = 0; $i < $this->nextErrorOutputIndex; $i++) {
+ if ($this->description->output[$i]['type'] === OutputChannel::ERROR) {
+ $output[] = $this->description->output[$i]['buffer'];
+ }
+ }
+
+ return rtrim(implode('', $output), "\n") . "\n";
+ }
+ }
+
+ /**
+ * The general output handler callback.
+ */
+ private ?\Closure $outputHandler = null;
+
+ /**
+ * The number of times the process should indicate that it is "running".
+ */
+ private int $remainingRunIterations {
+ get {
+ if (! isset($this->remainingRunIterations)) {
+ $this->remainingRunIterations = $this->description->runIterations;
+ }
+
+ return $this->remainingRunIterations;
+ }
+ }
+
+ /**
+ * The current output's index.
+ */
+ private int $nextOutputIndex = 0;
+
+ /**
+ * The current error output's index.
+ */
+ private int $nextErrorOutputIndex = 0;
+
+ /**
+ * The signals that have been received.
+ */
+ private array $receivedSignals = [];
+
+ public function __construct(
+ private readonly InvokedProcessDescription $description,
+ ) {}
+
+ public function signal(int $signal): self
+ {
+ $this->invokeOutputHandlerWithNextLineOfOutput();
+
+ $this->receivedSignals[] = $signal;
+
+ return $this;
+ }
+
+ public function stop(float|int|Duration $timeout = 10, ?int $signal = null): self
+ {
+ if ($timeout instanceof Duration) {
+ $timeout = $timeout->getTotalSeconds();
+ }
+
+ $this->process->stop((float) $timeout, $signal);
+
+ return $this;
+ }
+
+ public function wait(?callable $output = null): ProcessResult
+ {
+ $this->outputHandler = $output ?: $this->outputHandler;
+
+ if (! $this->outputHandler) {
+ $this->remainingRunIterations = 0;
+
+ return $this->getProcessResult();
+ }
+
+ // @mago-expect best-practices/no-empty-loop
+ while ($this->invokeOutputHandlerWithNextLineOfOutput()) {
+ }
+
+ $this->remainingRunIterations = 0;
+
+ return $this->getProcessResult();
+ }
+
+ /**
+ * Gets the latest standard output for the process.
+ */
+ private function latestOutput(): string
+ {
+ $outputCount = count($this->description->output);
+
+ for ($i = $this->nextOutputIndex; $i < $outputCount; $i++) {
+ if ($this->description->output[$i]['type'] === OutputChannel::OUTPUT) {
+ $output = $this->description->output[$i]['buffer'];
+ $this->nextOutputIndex = $i + 1;
+
+ break;
+ }
+
+ $this->nextOutputIndex = $i + 1;
+ }
+
+ return $output ?? '';
+ }
+
+ /**
+ * Gets the latest error output for the process.
+ */
+ public function latestErrorOutput(): string
+ {
+ $outputCount = count($this->description->output);
+
+ for ($i = $this->nextErrorOutputIndex; $i < $outputCount; $i++) {
+ if ($this->description->output[$i]['type'] === OutputChannel::ERROR) {
+ $output = $this->description->output[$i]['buffer'];
+ $this->nextErrorOutputIndex = $i + 1;
+
+ break;
+ }
+
+ $this->nextErrorOutputIndex = $i + 1;
+ }
+
+ return $output ?? '';
+ }
+
+ /**
+ * Invokes the asynchronous output handler with the next single line of output if necessary.
+ */
+ private function invokeOutputHandlerWithNextLineOfOutput(): bool
+ {
+ if (! $this->outputHandler) {
+ return false;
+ }
+
+ [$outputCount, $outputStartingPoint] = [
+ count($this->description->output),
+ min($this->nextOutputIndex, $this->nextErrorOutputIndex),
+ ];
+
+ for ($i = $outputStartingPoint; $i < $outputCount; $i++) {
+ $currentOutput = $this->description->output[$i];
+
+ if ($currentOutput['type'] === OutputChannel::OUTPUT && $i >= $this->nextOutputIndex) {
+ call_user_func($this->outputHandler, OutputChannel::OUTPUT, $currentOutput['buffer']);
+
+ $this->nextOutputIndex = $i + 1;
+
+ return $currentOutput;
+ }
+
+ if ($currentOutput['type'] === OutputChannel::ERROR && $i >= $this->nextErrorOutputIndex) {
+ call_user_func($this->outputHandler, OutputChannel::ERROR, $currentOutput['buffer']);
+
+ $this->nextErrorOutputIndex = $i + 1;
+
+ return $currentOutput;
+ }
+ }
+
+ return false;
+ }
+
+ public function getProcessResult(): ProcessResult
+ {
+ return new ProcessResult(
+ exitCode: $this->description->exitCode,
+ output: $this->description->resolveOutput(error: false),
+ errorOutput: $this->description->resolveOutput(error: true),
+ );
+ }
+}
diff --git a/packages/process/src/Testing/ProcessExecutionWasForbidden.php b/packages/process/src/Testing/ProcessExecutionWasForbidden.php
new file mode 100644
index 000000000..356fef34e
--- /dev/null
+++ b/packages/process/src/Testing/ProcessExecutionWasForbidden.php
@@ -0,0 +1,48 @@
+context;
+ }
+
+ public static function forPendingProcess(string|array|PendingProcess $process): self
+ {
+ $command = match (true) {
+ $process instanceof PendingProcess => [$process->command],
+ is_array($process) => $process,
+ default => [$process],
+ };
+
+ return new self(
+ message: sprintf('Process `%s` is being executed without a registered process result.', arr($command)->implode(' ')),
+ context: ['process' => $process],
+ );
+ }
+
+ public static function forPendingPool(iterable $pool): self
+ {
+ return new self(
+ message: 'Process pool is being executed without a matching fake.',
+ context: ['pool' => (array) $pool],
+ );
+ }
+}
diff --git a/packages/process/src/Testing/ProcessTester.php b/packages/process/src/Testing/ProcessTester.php
new file mode 100644
index 000000000..9b1a48066
--- /dev/null
+++ b/packages/process/src/Testing/ProcessTester.php
@@ -0,0 +1,291 @@
+executor ??= new TestingProcessExecutor(
+ executor: new GenericProcessExecutor(),
+ mocks: [],
+ allowRunningActualProcesses: $this->allowRunningActualProcesses,
+ );
+
+ $this->container->singleton(ProcessExecutor::class, $this->executor);
+ }
+
+ /**
+ * Sets up the specified command or pattern to return the specified result. The command accepts `*` as a placeholder.
+ */
+ public function mockProcessResult(string $command = '*', string|ProcessResult $result = ''): self
+ {
+ $this->recordProcessExecutions();
+
+ $this->executor->mocks[$command] = $result;
+
+ return $this;
+ }
+
+ /**
+ * Sets up the specified commands or patterns to return the specified results.
+ *
+ * @var array $results
+ */
+ public function mockProcessResults(array $results): self
+ {
+ $this->recordProcessExecutions();
+
+ foreach ($results as $command => $result) {
+ $this->executor->mocks[$command] = $result;
+ }
+
+ return $this;
+ }
+
+ /**
+ * Allows processes to be executed when they don't have a registered result.
+ */
+ public function allowRunningActualProcesses(): void
+ {
+ $this->allowRunningActualProcesses = true;
+
+ if ($this->executor) {
+ $this->executor->allowRunningActualProcesses = true;
+ } else {
+ $this->recordProcessExecutions();
+ }
+ }
+
+ /**
+ * Prevents processes from actually running when they don't have a registered result.
+ */
+ public function preventRunningActualProcesses(): void
+ {
+ $this->allowRunningActualProcesses = false;
+
+ if ($this->executor) {
+ $this->executor->allowRunningActualProcesses = false;
+ } else {
+ $this->recordProcessExecutions();
+ }
+ }
+
+ /**
+ * Completely disables process execution. This forces developers to register process expectations, while disabling actually running processes in tests. To allow running processes, call `allowRunningActualProcesses()`.
+ */
+ public function disableProcessExecution(): void
+ {
+ $this->container->singleton(ProcessExecutor::class, new RestrictedProcessExecutor());
+ }
+
+ /**
+ * Stops the process and dumps the recorded process executions.
+ */
+ public function debugExecutedProcesses(): void
+ {
+ dd($this->executor->executions); // @mago-expect best-practices/no-debug-symbols
+ }
+
+ /**
+ * Describes how an asynchronous process is expected to behave.
+ */
+ public function describe(): InvokedProcessDescription
+ {
+ return new InvokedProcessDescription();
+ }
+
+ /**
+ * Asserts that the given command has been ran. Alternatively, a callback may be passed.
+ *
+ * @param (\Closure(ProcessResult,PendingProcess=):false|void)|string $command
+ */
+ public function assertCommandRan(string $command, ?\Closure $callback = null): self
+ {
+ $this->ensureTestingSetUp();
+
+ $executions = $this->findExecutionsByPattern($command);
+
+ Assert::assertNotEmpty(
+ actual: $executions,
+ message: sprintf('Expected process with command "%s" to be executed, but it was not.', $command),
+ );
+
+ if ($callback instanceof Closure) {
+ foreach ($executions as [$process, $result]) {
+ $assertion = $callback($result, $process);
+
+ if ($assertion === true) {
+ Assert::assertTrue(true);
+
+ return $this;
+ }
+
+ if ($assertion === false) {
+ throw new ExpectationFailedException(sprintf('Callback for command "%s" returned false.', $process->command));
+ }
+ }
+ }
+
+ return $this;
+ }
+
+ /**
+ * Asserts that the a command has been ran by the given callback.
+ *
+ * @param \Closure(PendingProcess,ProcessResult=):false|void $callback
+ */
+ public function assertRan(\Closure $callback): self
+ {
+ $this->ensureTestingSetUp();
+
+ foreach ($this->executor->executions as $executions) {
+ foreach ($executions as [$process, $result]) {
+ $assertion = $callback($process, $result);
+
+ if ($assertion === true) {
+ Assert::assertTrue(true);
+
+ return $this;
+ }
+
+ if ($assertion === false) {
+ throw new ExpectationFailedException(sprintf('Callback for command "%s" returned false.', $process->command));
+ }
+ }
+ }
+
+ throw new ExpectationFailedException('Could not find a matching command for the provided callback.');
+ }
+
+ /**
+ * Asserts that the given command did not run. Alternatively, a callback may be passed.
+ *
+ * @param (\Closure(PendingProcess,ProcessResult=):false|void)|string $command
+ */
+ public function assertCommandDidNotRun(string|\Closure $command): self
+ {
+ $this->ensureTestingSetUp();
+
+ if ($command instanceof Closure) {
+ foreach ($this->executor->executions as $executions) {
+ foreach ($executions as [$process, $result]) {
+ $assertion = $command($process, $result);
+
+ if ($assertion === true) {
+ throw new ExpectationFailedException(sprintf('Callback for command "%s" returned true.', $process->command));
+ }
+ }
+ }
+
+ Assert::assertTrue(true);
+
+ return $this;
+ }
+
+ $executions = $this->findExecutionsByPattern($command);
+
+ Assert::assertEmpty(
+ actual: $executions,
+ message: sprintf('Expected process with command "%s" to not be ran, but it was.', $command),
+ );
+
+ return $this;
+ }
+
+ /**
+ * Asserts that no processes have been executed.
+ */
+ public function assertNothingRan(): self
+ {
+ $this->ensureTestingSetUp();
+
+ Assert::assertEmpty(
+ actual: $this->executor->executions,
+ message: 'Expected no processes to be executed, but some were.',
+ );
+
+ return $this;
+ }
+
+ /**
+ * Asserts that the given command has ran the specified amount of times.
+ *
+ * @param string|\Closure(PendingProcess,ProcessResult):bool $command
+ */
+ public function assertRanTimes(string|\Closure $command, int $times): self
+ {
+ $this->ensureTestingSetUp();
+
+ if ($command instanceof \Closure) {
+ $count = 0;
+ foreach ($this->executor->executions as $executions) {
+ foreach ($executions as [$process, $result]) {
+ if ($command($process, $result) === true) {
+ $count++;
+ }
+ }
+ }
+ } else {
+ $count = count($this->findExecutionsByPattern($command));
+ }
+
+ Assert::assertSame(
+ expected: $times,
+ actual: $count,
+ message: ($command instanceof Closure)
+ ? sprintf('Expected command matching callback to be executed %d times, but it was executed %d times.', $times, $count)
+ : sprintf('Expected command "%s" to be executed %d times, but it was executed %d times.', $command, $times, $count),
+ );
+
+ return $this;
+ }
+
+ /** @return array */
+ private function findExecutionsByPattern(string $pattern): array
+ {
+ $this->ensureTestingSetUp();
+
+ $executions = [];
+
+ foreach ($this->executor->executions as $command => $commandExecutions) {
+ if ($this->executor->commandMatchesPattern($command, $pattern)) {
+ $executions[] = $commandExecutions;
+ }
+ }
+
+ return Arr\flatten($executions, depth: 1);
+ }
+
+ private function ensureTestingSetUp(): void
+ {
+ if (is_null($this->executor)) {
+ throw new ExpectationFailedException(
+ 'Process testing is not set up. Please call `$this->process->recordProcessExecutions()` or `$this->process->registerProcessResult()` before running assertions, or call `$this->process->allowRunningActualProcesses()` to allow actual processes to run.',
+ );
+ }
+ }
+}
diff --git a/packages/process/src/Testing/RestrictedProcessExecutor.php b/packages/process/src/Testing/RestrictedProcessExecutor.php
new file mode 100644
index 000000000..cff276829
--- /dev/null
+++ b/packages/process/src/Testing/RestrictedProcessExecutor.php
@@ -0,0 +1,33 @@
+> */
+ private(set) array $executions = [];
+
+ /**
+ * @param array $mocks
+ */
+ public function __construct(
+ private readonly GenericProcessExecutor $executor,
+ public array $mocks = [],
+ public bool $allowRunningActualProcesses = false,
+ ) {}
+
+ public function run(string|PendingProcess $command): ProcessResult
+ {
+ if ($result = $this->findMockedProcess($command)) {
+ return $this->recordExecution($command, $result);
+ }
+
+ if (! $this->allowRunningActualProcesses) {
+ throw ProcessExecutionWasForbidden::forPendingProcess($command);
+ }
+
+ return $this->recordExecution($command, $this->start($command)->wait());
+ }
+
+ public function start(string|PendingProcess $command): InvokedProcess
+ {
+ if ($processResult = $this->findInvokedProcessDescription($command)) {
+ $this->recordExecution($command, $process = new InvokedTestingProcess($processResult));
+ } else {
+ if (! $this->allowRunningActualProcesses) {
+ throw ProcessExecutionWasForbidden::forPendingProcess($command);
+ }
+
+ $this->recordExecution($command, $process = $this->executor->start($command));
+ }
+
+ return $process;
+ }
+
+ public function pool(iterable $pool): Pool
+ {
+ return new Pool(
+ pendingProcesses: new ImmutableArray($pool)->map($this->createPendingProcess(...)),
+ processExecutor: $this,
+ );
+ }
+
+ public function concurrently(iterable $pool): ProcessPoolResults
+ {
+ return $this->pool($pool)->start()->wait();
+ }
+
+ private function findMockedProcess(string|PendingProcess $command): ?ProcessResult
+ {
+ $process = $this->createPendingProcess($command);
+
+ foreach ($this->mocks as $command => $result) {
+ if (! Regex\matches($process->command, $this->buildRegExpFromString($command))) {
+ continue;
+ }
+
+ if ($result instanceof ProcessResult) {
+ return $result;
+ }
+
+ return new ProcessResult(
+ exitCode: 0,
+ output: $result,
+ errorOutput: '',
+ );
+ }
+
+ return null;
+ }
+
+ private function findInvokedProcessDescription(string|PendingProcess $command): ?InvokedProcessDescription
+ {
+ $process = $this->createPendingProcess($command);
+
+ foreach ($this->mocks as $command => $result) {
+ if (! $this->commandMatchesPattern($process->command, $command)) {
+ continue;
+ }
+
+ if ($result instanceof InvokedProcessDescription) {
+ return $result;
+ }
+
+ return new InvokedProcessDescription();
+ }
+
+ return null;
+ }
+
+ private function recordExecution(string|PendingProcess $command, InvokedProcess|ProcessResult $result): ProcessResult
+ {
+ $process = $this->createPendingProcess($command);
+ $result = match (true) {
+ $result instanceof ProcessResult => $result,
+ $result instanceof InvokedTestingProcess => $result->getProcessResult(),
+ $result instanceof InvokedSystemProcess => $result->wait(), // TODO: fix
+ default => throw new \RuntimeException('Unexpected result type.'),
+ };
+
+ $this->executions[$process->command] ??= [];
+ $this->executions[$process->command][] = [$process, $result];
+
+ return $result;
+ }
+
+ /**
+ * Checks if the specified command matches the specified pattern.
+ */
+ public function commandMatchesPattern(string $command, string $pattern): bool
+ {
+ return Regex\matches($command, $this->buildRegExpFromString($pattern));
+ }
+
+ /**
+ * Builds a regular expression from a string containing a `*`.
+ */
+ private function buildRegExpFromString(string $string): string
+ {
+ return sprintf('/%s/', str_replace('\\*', '.*', preg_quote($string, delimiter: '/')));
+ }
+
+ private function createPendingProcess(array|string|PendingProcess $processOrCommand): PendingProcess
+ {
+ if ($processOrCommand instanceof PendingProcess) {
+ return $processOrCommand;
+ }
+
+ return new PendingProcess(command: $processOrCommand);
+ }
+}
diff --git a/packages/process/tests/GenericProcessExecutorTest.php b/packages/process/tests/GenericProcessExecutorTest.php
new file mode 100644
index 000000000..cc73cf7b4
--- /dev/null
+++ b/packages/process/tests/GenericProcessExecutorTest.php
@@ -0,0 +1,147 @@
+run('echo hello');
+
+ $this->assertStringEqualsStringIgnoringLineEndings("hello\n", $result->output);
+ $this->assertSame('', $result->errorOutput);
+ $this->assertSame(0, $result->exitCode);
+ }
+
+ public function test_run(): void
+ {
+ $executor = new GenericProcessExecutor();
+ $result = $executor->run(new PendingProcess('echo hello'));
+
+ $this->assertStringEqualsStringIgnoringLineEndings("hello\n", $result->output);
+ $this->assertSame('', $result->errorOutput);
+ $this->assertSame(0, $result->exitCode);
+ }
+
+ public function test_start(): void
+ {
+ $executor = new GenericProcessExecutor();
+ $process = $executor->start('echo hello');
+
+ $this->assertIsInt($process->pid);
+ $this->assertTrue($process->running);
+ $this->assertSame('', $process->output);
+ $this->assertSame('', $process->errorOutput);
+
+ $result = $process->wait();
+
+ $this->assertNull($process->pid);
+ $this->assertFalse($process->running);
+ $this->assertStringEqualsStringIgnoringLineEndings("hello\n", $process->output);
+ $this->assertSame('', $process->errorOutput);
+
+ $this->assertStringEqualsStringIgnoringLineEndings("hello\n", $result->output);
+ $this->assertSame('', $result->errorOutput);
+ $this->assertSame(0, $result->exitCode);
+ }
+
+ public function test_wait_callback(): void
+ {
+ $executor = new GenericProcessExecutor();
+ $process = $executor->start('echo hello');
+
+ $output = [];
+ $process->wait(function (OutputChannel $channel, string $data) use (&$output) {
+ $output[$channel->value] ??= [];
+ $output[$channel->value][] = $data;
+ });
+
+ $this->assertCount(1, $output);
+ $this->assertArrayHasKey(OutputChannel::OUTPUT->value, $output);
+ $this->assertStringEqualsStringIgnoringLineEndings("hello\n", $output[OutputChannel::OUTPUT->value][0]);
+ }
+
+ public function test_run_timeout(): void
+ {
+ $this->skipOnWindows();
+ $this->expectException(ProcessHasTimedOut::class);
+
+ $executor = new GenericProcessExecutor();
+ $executor->run(new PendingProcess('sleep .2', timeout: Duration::milliseconds(100)));
+ }
+
+ public function test_run_idle_timeout(): void
+ {
+ $this->skipOnWindows();
+ $this->expectException(ProcessHasTimedOut::class);
+
+ $executor = new GenericProcessExecutor();
+ $executor->run(new PendingProcess('sleep .2', idleTimeout: Duration::milliseconds(100)));
+ }
+
+ public function test_run_input(): void
+ {
+ $executor = new GenericProcessExecutor();
+ $result = $executor->run(new PendingProcess('cat', input: 'hello'));
+
+ $this->assertSame('hello', $result->output);
+ $this->assertSame('', $result->errorOutput);
+ $this->assertSame(0, $result->exitCode);
+ }
+
+ public function test_run_with_error_output(): void
+ {
+ $this->skipOnWindows();
+
+ $executor = new GenericProcessExecutor();
+ $result = $executor->run('echo hello >&2');
+
+ $this->assertSame('', $result->output);
+ $this->assertStringEqualsStringIgnoringLineEndings("hello\n", $result->errorOutput);
+ $this->assertSame(0, $result->exitCode);
+ }
+
+ public function test_run_with_exit_code(): void
+ {
+ $this->skipOnWindows();
+
+ $executor = new GenericProcessExecutor();
+ $result = $executor->run('exit 42');
+
+ $this->assertSame('', $result->output);
+ $this->assertSame('', $result->errorOutput);
+ $this->assertSame(42, $result->exitCode);
+ }
+
+ public function test_run_with_env(): void
+ {
+ $this->skipOnWindows();
+
+ $executor = new GenericProcessExecutor();
+ $result = $executor->run(new PendingProcess('echo $TEST_ENV', environment: ['TEST_ENV' => 'hello']));
+
+ $this->assertStringEqualsStringIgnoringLineEndings("hello\n", $result->output);
+ $this->assertSame('', $result->errorOutput);
+ $this->assertSame(0, $result->exitCode);
+ }
+
+ private function skipOnWindows(): void
+ {
+ if (PHP_OS_FAMILY === 'Windows') {
+ $this->markTestSkipped('This test is not applicable on Windows.');
+ }
+ }
+}
diff --git a/packages/process/tests/PoolTest.php b/packages/process/tests/PoolTest.php
new file mode 100644
index 000000000..2e50212df
--- /dev/null
+++ b/packages/process/tests/PoolTest.php
@@ -0,0 +1,64 @@
+pool([
+ 'echo hello',
+ 'echo world',
+ ]);
+
+ $this->assertInstanceOf(Pool::class, $pool);
+ $this->assertCount(2, $pool->processes());
+
+ // quick immutability check
+ $pool->processes()->add(new PendingProcess('echo foo'));
+ $this->assertCount(2, $pool->processes());
+
+ $invoked = $pool->start();
+
+ $this->assertInstanceOf(InvokedProcessPool::class, $invoked);
+ $this->assertSame(2, $invoked->count());
+
+ $results = $invoked->wait();
+
+ $this->assertSame(2, $results->count());
+ $this->assertStringEqualsStringIgnoringLineEndings("hello\n", $results[0]->output);
+ $this->assertStringEqualsStringIgnoringLineEndings("world\n", $results[1]->output);
+ }
+
+ public function test_concurrently(): void
+ {
+ $executor = new GenericProcessExecutor();
+ $results = $executor->concurrently([
+ 'echo hello',
+ 'echo world',
+ ]);
+
+ $this->assertSame(2, $results->count());
+ $this->assertStringEqualsStringIgnoringLineEndings("hello\n", $results[0]->output);
+ $this->assertStringEqualsStringIgnoringLineEndings("world\n", $results[1]->output);
+ }
+
+ public function test_concurrently_deconstruct(): void
+ {
+ $executor = new GenericProcessExecutor();
+ [$hello, $world] = $executor->concurrently([
+ 'echo hello',
+ 'echo world',
+ ]);
+
+ $this->assertStringEqualsStringIgnoringLineEndings("hello\n", $hello->output);
+ $this->assertStringEqualsStringIgnoringLineEndings("world\n", $world->output);
+ }
+}
diff --git a/packages/support/src/Arr/ArrayInterface.php b/packages/support/src/Arr/ArrayInterface.php
index e55a09e30..60df06f06 100644
--- a/packages/support/src/Arr/ArrayInterface.php
+++ b/packages/support/src/Arr/ArrayInterface.php
@@ -7,7 +7,6 @@
use ArrayAccess;
use Countable;
use Iterator;
-use Serializable;
/**
* @template TKey of array-key
@@ -18,7 +17,7 @@
*
* @internal This interface is not meant to be used in userland.
*/
-interface ArrayInterface extends Iterator, ArrayAccess, Serializable, Countable
+interface ArrayInterface extends Iterator, ArrayAccess, Countable
{
/**
* Returns the underlying array of the instance.
diff --git a/src/Tempest/Framework/Testing/InstallerTester.php b/src/Tempest/Framework/Testing/InstallerTester.php
index 81c4abffd..de4c57027 100644
--- a/src/Tempest/Framework/Testing/InstallerTester.php
+++ b/src/Tempest/Framework/Testing/InstallerTester.php
@@ -5,40 +5,39 @@
namespace Tempest\Framework\Testing;
use PHPUnit\Framework\Assert;
-use RecursiveDirectoryIterator;
-use RecursiveIteratorIterator;
use Tempest\Container\Container;
use Tempest\Core\Composer;
use Tempest\Core\FrameworkKernel;
-use Tempest\Core\ShellExecutors\NullShellExecutor;
+use Tempest\Process\Testing\ProcessTester;
use Tempest\Support\Arr;
use Tempest\Support\Filesystem;
use Tempest\Support\Namespace\Psr4Namespace;
-use function Tempest\Support\arr;
use function Tempest\Support\Path\to_absolute_path;
final class InstallerTester
{
private string $root;
- private NullShellExecutor $executor;
+ private ProcessTester $process {
+ get => $this->container->get(ProcessTester::class);
+ }
public function __construct(
private readonly Container $container,
- ) {
- $this->executor = new NullShellExecutor();
- }
+ ) {}
public function configure(string $root, Psr4Namespace $namespace): self
{
+ $this->process->mockProcessResult();
+
$this->root = $root;
$this->container->get(FrameworkKernel::class)->root = $root;
$this->container
->get(Composer::class)
->setMainNamespace($namespace)
->setNamespaces($namespace)
- ->setShellExecutor($this->executor);
+ ->setProcessExecutor($this->process->executor);
Filesystem\ensure_directory_exists($this->root);
Filesystem\ensure_directory_exists($namespace->path);
@@ -124,10 +123,7 @@ public function assertFileNotContains(string $path, string $search): self
public function assertCommandExecuted(string $command): self
{
- Assert::assertTrue(
- condition: arr($this->executor->executedCommands)->hasValue($command),
- message: sprintf('The command `%s` was not executed', $command),
- );
+ $this->process->assertCommandRan($command);
return $this;
}
diff --git a/src/Tempest/Framework/Testing/IntegrationTest.php b/src/Tempest/Framework/Testing/IntegrationTest.php
index f71cbe63a..56a51ec9b 100644
--- a/src/Tempest/Framework/Testing/IntegrationTest.php
+++ b/src/Tempest/Framework/Testing/IntegrationTest.php
@@ -18,8 +18,6 @@
use Tempest\Core\ExceptionTester;
use Tempest\Core\FrameworkKernel;
use Tempest\Core\Kernel;
-use Tempest\Core\ShellExecutor;
-use Tempest\Core\ShellExecutors\NullShellExecutor;
use Tempest\Database\Migrations\CreateMigrationsTable;
use Tempest\Database\Migrations\MigrationManager;
use Tempest\DateTime\DateTimeInterface;
@@ -32,6 +30,7 @@
use Tempest\Http\Request;
use Tempest\Mail\Testing\MailTester;
use Tempest\Mail\Testing\TestingMailer;
+use Tempest\Process\Testing\ProcessTester;
use Tempest\Storage\Testing\StorageTester;
use Throwable;
@@ -70,6 +69,8 @@ abstract class IntegrationTest extends TestCase
protected ExceptionTester $exceptions;
+ protected ProcessTester $process;
+
protected function setUp(): void
{
parent::setUp();
@@ -124,7 +125,6 @@ protected function setupConsole(): self
$this->console = new ConsoleTester($this->container);
$this->container->singleton(OutputBuffer::class, fn () => new MemoryOutputBuffer());
$this->container->singleton(StdoutOutputBuffer::class, fn () => new MemoryOutputBuffer());
- $this->container->singleton(ShellExecutor::class, fn () => new NullShellExecutor());
return $this;
}
@@ -140,6 +140,9 @@ protected function setupTesters(): self
eventBus: $this->container->get(EventBus::class),
));
+ $this->process = $this->container->get(ProcessTester::class);
+ $this->process->disableProcessExecution();
+
$this->exceptions = $this->container->get(ExceptionTester::class);
$this->exceptions->preventReporting();
diff --git a/tests/Integration/Console/Scheduler/GenericSchedulerTest.php b/tests/Integration/Console/Scheduler/GenericSchedulerTest.php
index 4a6cdf76c..341f23e52 100644
--- a/tests/Integration/Console/Scheduler/GenericSchedulerTest.php
+++ b/tests/Integration/Console/Scheduler/GenericSchedulerTest.php
@@ -12,7 +12,7 @@
use Tempest\Console\Scheduler\Every;
use Tempest\Console\Scheduler\GenericScheduler;
use Tempest\Console\Scheduler\SchedulerConfig;
-use Tempest\Core\ShellExecutors\NullShellExecutor;
+use Tempest\Process\ProcessExecutor;
use Tempest\Reflection\MethodReflector;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
@@ -21,6 +21,10 @@
*/
final class GenericSchedulerTest extends FrameworkIntegrationTestCase
{
+ private ProcessExecutor $executor {
+ get => $this->container->get(ProcessExecutor::class);
+ }
+
protected function setUp(): void
{
parent::setUp();
@@ -33,6 +37,8 @@ protected function setUp(): void
public function test_scheduler_executes_handlers(): void
{
+ $this->process->mockProcessResult();
+
$config = new SchedulerConfig();
$config->addMethodInvocation(
@@ -40,20 +46,20 @@ public function test_scheduler_executes_handlers(): void
new Schedule(Every::MINUTE),
);
- $executor = new NullShellExecutor();
$argumentBag = new ConsoleArgumentBag(['tempest']);
- $scheduler = new GenericScheduler($config, $argumentBag, $executor);
+ $scheduler = new GenericScheduler($config, $argumentBag, $this->executor);
$scheduler->run();
- $this->assertSame(
+ $this->process->assertCommandRan(
'(' . PHP_BINARY . ' tempest schedule:task Tests\\\Tempest\\\Integration\\\Console\\\Scheduler\\\GenericSchedulerTest::handler) >> /dev/null &',
- $executor->executedCommands[0],
);
}
public function test_scheduler_executes_commands(): void
{
+ $this->process->mockProcessResult();
+
$config = new SchedulerConfig();
$config->addCommandInvocation(
@@ -62,19 +68,18 @@ public function test_scheduler_executes_commands(): void
new Schedule(Every::MINUTE),
);
- $executor = new NullShellExecutor();
$argumentBag = new ConsoleArgumentBag(['tempest']);
- $scheduler = new GenericScheduler($config, $argumentBag, $executor);
+ $scheduler = new GenericScheduler($config, $argumentBag, $this->executor);
$scheduler->run();
- $this->assertSame(
+ $this->process->assertCommandRan(
'(' . PHP_BINARY . ' tempest command) >> /dev/null &',
- $executor->executedCommands[0],
);
}
public function test_scheduler_only_dispatches_the_command_in_desired_times(): void
{
+ $this->process->mockProcessResult();
$at = new DateTime('2024-05-01 00:00:00');
$config = new SchedulerConfig();
@@ -83,14 +88,12 @@ public function test_scheduler_only_dispatches_the_command_in_desired_times(): v
new Schedule(Every::MINUTE),
);
- $executor = new NullShellExecutor();
$argumentBag = new ConsoleArgumentBag(['tempest']);
- $scheduler = new GenericScheduler($config, $argumentBag, $executor);
+ $scheduler = new GenericScheduler($config, $argumentBag, $this->executor);
$scheduler->run($at);
- $this->assertSame(
+ $this->process->assertCommandRan(
'(' . PHP_BINARY . ' tempest schedule:task Tests\\\Tempest\\\Integration\\\Console\\\Scheduler\\\GenericSchedulerTest::handler) >> /dev/null &',
- $executor->executedCommands[0],
);
// command won't run twice in a row
@@ -99,15 +102,17 @@ public function test_scheduler_only_dispatches_the_command_in_desired_times(): v
// nor when it's called before the next minute
$scheduler->run($at->modify('+30 seconds'));
- $executor = new NullShellExecutor();
-
- $scheduler = new GenericScheduler($config, $argumentBag, $executor);
+ $scheduler = new GenericScheduler($config, $argumentBag, $this->executor);
$scheduler->run($at->modify('+1 minute'));
- $this->assertSame(
+ $this->process->assertCommandRan(
'(' . PHP_BINARY . ' tempest schedule:task Tests\\\Tempest\\\Integration\\\Console\\\Scheduler\\\GenericSchedulerTest::handler) >> /dev/null &',
- $executor->executedCommands[0],
+ );
+
+ $this->process->assertRanTimes(
+ command: '(' . PHP_BINARY . ' tempest schedule:task Tests\\\Tempest\\\Integration\\\Console\\\Scheduler\\\GenericSchedulerTest::handler) >> /dev/null &',
+ times: 2,
);
}
diff --git a/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php b/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php
index 01c7b53d5..1a81311ca 100644
--- a/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php
+++ b/tests/Integration/Console/Scheduler/ScheduleRunCommandTest.php
@@ -14,6 +14,8 @@ final class ScheduleRunCommandTest extends FrameworkIntegrationTestCase
{
public function test_invoke(): void
{
+ $this->process->mockProcessResult();
+
@unlink(GenericScheduler::getCachePath());
$this->console
diff --git a/tests/Integration/Core/ComposerTest.php b/tests/Integration/Core/ComposerTest.php
index ac950a97d..c88b24324 100644
--- a/tests/Integration/Core/ComposerTest.php
+++ b/tests/Integration/Core/ComposerTest.php
@@ -7,7 +7,7 @@
use PHPUnit\Framework\Attributes\Test;
use Tempest\Core\Composer;
use Tempest\Core\ComposerJsonCouldNotBeLocated;
-use Tempest\Core\ShellExecutors\NullShellExecutor;
+use Tempest\Process\ProcessExecutor;
use Tests\Tempest\Integration\FrameworkIntegrationTestCase;
/**
@@ -21,7 +21,7 @@ private function initializeComposer(array $composer): Composer
return new Composer(
root: realpath(__DIR__ . '/../../Fixtures/Core/Composer'),
- executor: new NullShellExecutor(),
+ executor: $this->container->get(ProcessExecutor::class),
)->load();
}
@@ -138,6 +138,6 @@ public function errors_without_composer_file(): void
{
$this->expectException(ComposerJsonCouldNotBeLocated::class);
- new Composer(root: __DIR__, executor: new NullShellExecutor())->load();
+ new Composer(root: __DIR__, executor: $this->container->get(ProcessExecutor::class))->load();
}
}
diff --git a/tests/Integration/Process/ProcessExecutorTest.php b/tests/Integration/Process/ProcessExecutorTest.php
new file mode 100644
index 000000000..d25cd36fb
--- /dev/null
+++ b/tests/Integration/Process/ProcessExecutorTest.php
@@ -0,0 +1,102 @@
+ $this->container->get(ProcessExecutor::class);
+ }
+
+ public function test_run(): void
+ {
+ $this->process->mockProcessResult('echo *', "Hello\n");
+
+ $result = $this->executor->run('echo "Hello"');
+
+ $this->assertSame("Hello\n", $result->output);
+ $this->process->assertCommandRan('echo "Hello"');
+ $this->process->assertRanTimes('echo *', times: 1);
+ }
+
+ public function test_describe_and_assert_async_process(): void
+ {
+ $this->process->mockProcessResults([
+ 'echo *' => $this->process
+ ->describe()
+ ->output('hello')
+ ->output('world')
+ ->iterations(2)
+ ->exitCode(0),
+ ]);
+
+ $process = $this->executor->start('echo "Hello"');
+
+ $output = '';
+ while ($process->running) {
+ $output = $process->output;
+ }
+
+ $result = $process->wait();
+
+ $this->assertSame("hello\nworld\n", $output);
+ $this->assertSame("hello\nworld\n", $result->output);
+
+ $this->process->assertRanTimes('echo *', times: 1);
+ $this->process->assertCommandRan('echo "Hello"', function (ProcessResult $result): void {
+ $this->assertSame(0, $result->exitCode);
+ $this->assertSame("hello\nworld\n", $result->output);
+ $this->assertEmpty($result->errorOutput);
+ });
+ }
+
+ public function test_concurrently(): void
+ {
+ $this->process->mockProcessResults([
+ 'echo "hello"' => $this->process->describe()->output('hello'),
+ 'echo "world"' => $this->process->describe()->output('world'),
+ ]);
+
+ [$hello, $world] = $this->executor->concurrently([
+ 'echo "hello"',
+ 'echo "world"',
+ ]);
+
+ $this->assertSame("hello\n", $hello->output);
+ $this->assertSame("world\n", $world->output);
+
+ $this->process->assertRanTimes('echo *', times: 2);
+ $this->process->assertRanTimes('echo "hello"', times: 1);
+ $this->process->assertRanTimes('echo "world"', times: 1);
+ }
+
+ public function test_pool(): void
+ {
+ $this->process->mockProcessResults([
+ 'echo "hello"' => $this->process->describe()->output('hello'),
+ 'echo "world"' => $this->process->describe()->output('world'),
+ ]);
+
+ $pool = $this->executor->pool([
+ 'echo "hello"',
+ 'echo "world"',
+ ]);
+
+ $invocation = $pool->start();
+
+ $output = '';
+ while ($invocation->running->isNotEmpty()) {
+ $output = $invocation
+ ->all
+ ->map(fn (InvokedProcess $process) => $process->output)
+ ->toArray();
+ }
+
+ $this->assertSame(["hello\n", "world\n"], $output);
+ }
+}
diff --git a/tests/Integration/Process/ProcessTesterAssertNotRanTest.php b/tests/Integration/Process/ProcessTesterAssertNotRanTest.php
new file mode 100644
index 000000000..61e992a6a
--- /dev/null
+++ b/tests/Integration/Process/ProcessTesterAssertNotRanTest.php
@@ -0,0 +1,52 @@
+ $this->container->get(ProcessExecutor::class);
+ }
+
+ public function test_succeeds_when_command_is_not_ran(): void
+ {
+ $this->process->recordProcessExecutions();
+ $this->process->assertCommandDidNotRun('echo "hello"');
+ }
+
+ public function test_succeeds_with_callback_when_no_command_ran(): void
+ {
+ $this->process->recordProcessExecutions();
+ $this->process->assertCommandDidNotRun(function (): void {});
+ }
+
+ public function test_succeeds_with_callback_when_other_commands_ran(): void
+ {
+ $this->process->mockProcessResult('echo *', 'hello');
+ $this->executor->run('echo "hello"');
+
+ $this->process->assertCommandDidNotRun(function (PendingProcess $process) {
+ // this returns false, so expectation succeeds
+ return $process->command === 'echo "world"';
+ });
+ }
+
+ public function test_fails_with_callback_when_returning_false(): void
+ {
+ $this->expectException(ExpectationFailedException::class);
+ $this->expectExceptionMessage('Callback for command "echo "hello"" returned true.');
+
+ $this->process->mockProcessResult('echo *', 'hello');
+ $this->executor->run('echo "hello"');
+
+ $this->process->assertCommandDidNotRun(function (PendingProcess $process) {
+ // this returns true, so expectation fails
+ return $process->command === 'echo "hello"';
+ });
+ }
+}
diff --git a/tests/Integration/Process/ProcessTesterAssertRanTest.php b/tests/Integration/Process/ProcessTesterAssertRanTest.php
new file mode 100644
index 000000000..3e0472bec
--- /dev/null
+++ b/tests/Integration/Process/ProcessTesterAssertRanTest.php
@@ -0,0 +1,116 @@
+ $this->container->get(ProcessExecutor::class);
+ }
+
+ #[TestWith(['*'])]
+ #[TestWith(['echo *'])]
+ #[TestWith(['echo "hello"'])]
+ public function test_expectation_succeeds_when_command_is_ran(string $pattern): void
+ {
+ $this->process->mockProcessResult('echo *', "hello\n");
+ $this->executor->run('echo "hello"');
+ $this->process->assertCommandRan($pattern);
+ }
+
+ public function test_expectation_succeeds_when_command_is_ran_and_callback_returns_true(): void
+ {
+ $this->process->mockProcessResult('echo *', "hello\n");
+ $this->executor->run('echo "hello"');
+ $this->process->assertCommandRan('echo *', function (ProcessResult $result) {
+ return $result->output === "hello\n";
+ });
+ }
+
+ public function test_expectation_fails_when_specified_command_is_not_ran(): void
+ {
+ $this->expectException(ExpectationFailedException::class);
+ $this->expectExceptionMessage('Expected process with command "not-ran" to be executed, but it was not.');
+
+ $this->process->mockProcessResult('echo *', "hello\n");
+ $this->executor->run('echo "hello"');
+ $this->process->assertCommandRan('not-ran');
+ }
+
+ public function test_expectation_fails_when_command_is_ran_and_callback_returns_false(): void
+ {
+ $this->expectException(ExpectationFailedException::class);
+ $this->expectExceptionMessage('Callback for command "echo "hello"" returned false.');
+
+ $this->process->mockProcessResult('echo *', "hello\n");
+ $this->executor->run('echo "hello"');
+ $this->process->assertCommandRan('echo *', function (ProcessResult $result) {
+ return $result->output !== "hello\n";
+ });
+ }
+
+ public function test_expectation_succeeds_when_callback_returns_nothing(): void
+ {
+ $this->process->mockProcessResult('echo *', "hello\n");
+ $this->executor->run('echo "hello"');
+ $this->process->assertCommandRan('echo *', function (): void {});
+ }
+
+ public function test_expectation_succeeds_when_callback_returns_true(): void
+ {
+ $this->process->mockProcessResult('echo *', "hello\n");
+ $this->executor->run('echo "hello"');
+
+ $this->process->assertRan(function (PendingProcess $process): bool {
+ return $process->command === 'echo "hello"';
+ });
+ }
+
+ public function test_returning_false_from_callback_fails_expectation(): void
+ {
+ $this->expectException(ExpectationFailedException::class);
+ $this->expectExceptionMessage('Callback for command "echo "hello"" returned false.');
+
+ $this->process->mockProcessResult('echo *', "hello\n");
+ $this->executor->run('echo "hello"');
+
+ $this->process->assertRan(function (PendingProcess $_process): bool {
+ return false;
+ });
+ }
+
+ public function test_returning_true_from_callback_skips_other_iterations(): void
+ {
+ $this->process->mockProcessResult('echo *', "hello\n");
+ $this->executor->run('echo "hello"');
+ $this->executor->run('echo "world"');
+
+ $this->process->assertRan(function (PendingProcess $process): bool {
+ if ($process->command === 'echo "hello"') {
+ return true;
+ }
+
+ throw new ExpectationFailedException('This should not be reached.');
+ });
+ }
+
+ public function test_never_returning_fails_expectation(): void
+ {
+ $this->expectException(ExpectationFailedException::class);
+ $this->expectExceptionMessage('Could not find a matching command for the provided callback.');
+
+ $this->process->mockProcessResult('echo *', "hello\n");
+ $this->executor->run('echo "hello"');
+
+ $this->process->assertRan(function (PendingProcess $_process): void {
+ // This callback never returns.
+ });
+ }
+}
diff --git a/tests/Integration/Process/ProcessTesterTest.php b/tests/Integration/Process/ProcessTesterTest.php
new file mode 100644
index 000000000..4280a9969
--- /dev/null
+++ b/tests/Integration/Process/ProcessTesterTest.php
@@ -0,0 +1,147 @@
+ $this->container->get(ProcessExecutor::class);
+ }
+
+ public function test_prevents_execution_by_default(): void
+ {
+ $this->expectException(ProcessExecutionWasForbidden::class);
+ $this->expectExceptionMessage('Process `echo "Hello"` is being executed without a registered process result.');
+
+ $this->process->preventRunningActualProcesses();
+ $this->executor->run('echo "Hello"');
+ }
+
+ public function test_that_recording_must_be_enabled_to_perform_assertions(): void
+ {
+ try {
+ $this->process->assertCommandRan('echo "hello"');
+ } catch (ExpectationFailedException $expectationFailedException) {
+ $this->assertStringContainsString('Process testing is not set up', $expectationFailedException->getMessage());
+ }
+
+ try {
+ $this->process->assertCommandDidNotRun('echo "hello"');
+ } catch (ExpectationFailedException $expectationFailedException) {
+ $this->assertStringContainsString('Process testing is not set up', $expectationFailedException->getMessage());
+ }
+ }
+
+ public function test_registering_result_allows_assertions(): void
+ {
+ $this->process->mockProcessResult('echo *', 'Hello');
+ $this->process->assertCommandDidNotRun('echo *');
+ }
+
+ public function test_allowing_actual_executions_allows_assertions(): void
+ {
+ $this->process->allowRunningActualProcesses();
+ $this->process->assertCommandDidNotRun('echo *');
+ }
+
+ public function test_preventing_actual_executions_allows_assertions(): void
+ {
+ $this->process->preventRunningActualProcesses();
+ $this->process->assertCommandDidNotRun('echo *');
+ }
+
+ public function test_recording_executions_allows_assertions(): void
+ {
+ $this->process->recordProcessExecutions();
+ $this->process->assertCommandDidNotRun('echo *');
+ }
+
+ public function test_assert_nothing_ran(): void
+ {
+ $this->process->recordProcessExecutions();
+ $this->process->assertNothingRan();
+ }
+
+ public function test_assert_nothing_ran_failure(): void
+ {
+ $this->expectException(ExpectationFailedException::class);
+ $this->expectExceptionMessage('Expected no processes to be executed, but some were.');
+
+ $this->process->mockProcessResult('echo *', 'hello');
+ $this->executor->run('echo "hello"');
+ $this->process->assertNothingRan();
+ }
+
+ public function test_assert_ran_times_with_string(): void
+ {
+ $this->process->mockProcessResult('echo *', 'hello');
+ $this->executor->run('echo "hello"');
+ $this->executor->run('echo "hello"');
+
+ $this->process->assertRanTimes('echo *', times: 2);
+ }
+
+ public function test_assert_ran_times_with_callback(): void
+ {
+ $this->process->mockProcessResult('echo *', 'hello');
+ $this->executor->run('echo "hello"');
+ $this->executor->run('echo "hello"');
+
+ $this->process->assertRanTimes(fn (PendingProcess $process) => $process->command === 'echo "hello"', times: 2);
+ }
+
+ public function test_assert_ran_times_with_string_failure(): void
+ {
+ $this->expectException(ExpectationFailedException::class);
+ $this->expectExceptionMessage('Expected command "echo *" to be executed 1 times, but it was executed 2 times.');
+
+ $this->process->mockProcessResult('echo *', 'hello');
+ $this->executor->run('echo "hello"');
+ $this->executor->run('echo "hello"');
+
+ $this->process->assertRanTimes('echo *', times: 1);
+ }
+
+ public function test_assert_ran_times_with_callback_failure(): void
+ {
+ $this->expectException(ExpectationFailedException::class);
+ $this->expectExceptionMessage('Expected command matching callback to be executed 1 times, but it was executed 2 times.');
+
+ $this->process->mockProcessResult('echo *', 'hello');
+ $this->executor->run('echo "hello"');
+ $this->executor->run('echo "hello"');
+
+ $this->process->assertRanTimes(fn (PendingProcess $process) => $process->command === 'echo "hello"', times: 1);
+ }
+
+ public function test_assert_ran_times_with_unrelated_callback(): void
+ {
+ $this->process->mockProcessResult('echo *', 'hello');
+ $this->executor->run('echo "hello"');
+ $this->executor->run('echo "hello"');
+
+ $this->process->assertRanTimes('echo *', times: 2);
+ $this->process->assertRanTimes(fn (PendingProcess $process) => $process->command === 'echo "world"', times: 0);
+ }
+
+ public function test_register_multiple_process_results(): void
+ {
+ $this->process->mockProcessResults([
+ 'echo "hello"' => 'Hello',
+ 'echo "world"' => new ProcessResult(exitCode: 0, output: 'World', errorOutput: ''),
+ ]);
+
+ $this->executor->run('echo "hello"');
+ $this->executor->run('echo "world"');
+
+ $this->process->assertCommandRan('echo "hello"');
+ $this->process->assertCommandRan('echo "world"');
+ }
+}