diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml
index b5b2872..f5b59af 100644
--- a/.github/actions/setup/action.yml
+++ b/.github/actions/setup/action.yml
@@ -21,6 +21,7 @@ runs:
php-version: ${{ inputs.php-version }}
tools: composer:${{ inputs.composer-version }}
coverage: xdebug
+ ini-values: zend.assertions=1
- name: Validate Composer Config
shell: bash
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 1778e00..8dbbbb3 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -41,6 +41,7 @@ jobs:
vendor/bin/phpunit \
--testsuite 'Unit' \
--log-junit 'var/test-results/${{ inputs.os }}-php${{ matrix.php-version }}.junit.xml' \
+ --coverage-text \
--coverage-cobertura 'var/coverage/${{ inputs.os }}-php${{ matrix.php-version }}/coverage.cobertura.xml'
- if: success() || failure()
diff --git a/.idea/docker-composer.iml b/.idea/docker-composer.iml
index 1f5144e..73307ed 100644
--- a/.idea/docker-composer.iml
+++ b/.idea/docker-composer.iml
@@ -5,12 +5,10 @@
-
-
diff --git a/.idea/php.xml b/.idea/php.xml
index 167a688..621e190 100644
--- a/.idea/php.xml
+++ b/.idea/php.xml
@@ -28,13 +28,11 @@
-
-
diff --git a/.idea/runConfigurations/Unit_Tests.xml b/.idea/runConfigurations/Unit_Tests.xml
index 066d3fc..d066454 100644
--- a/.idea/runConfigurations/Unit_Tests.xml
+++ b/.idea/runConfigurations/Unit_Tests.xml
@@ -1,6 +1,6 @@
-
+
\ No newline at end of file
diff --git a/AGENTS.md b/AGENTS.md
index b258cc9..39c4b8d 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,9 +1,9 @@
## General Instructions
+In all interactions and comments be extremely concise — sacrifice grammar for the sake of conciseness. Conciseness alone does not justify omitting information or intent.
In commit messages use conventional commits and provide justification of the changes in the body.
-In all interactions and plans be extremely concise — sacrifice grammar for the sake of conciseness. Conciseness alone does not justify omitting information or intent.
## Plan Mode
-Make plans extremely concise — sacrifice grammar for the sake of concision. Conciseness alone does not justify omitting information or intent.
+Make plans extremely concise — sacrifice grammar for the sake of conciseness. Conciseness alone does not justify omitting information or intent.
At the end of each plan, give me a list of unresolved questions to answer, if any.
## Tests
@@ -13,25 +13,17 @@ At the end of every task, execute these commands to ensure the quality of the co
- `composer stan`
- `composer test`
-## PHPStan
-Prefer clear, performant code over reshaping code only to satisfy PHPStan.
-Treat PHPStan findings as likely real; fix root causes first.
-If PHPStan cannot model valid runtime behavior, use the narrowest fix:
- 1. add explicit control flow or `assert()` when it improves clarity;
- 2. otherwise add a targeted `@phpstan-ignore ` on the exact line.
-Do not add broad suppressions, baselines, or unclear type workarounds.
-
## Coding Style
-All PHP code must adhere to PER Coding Syle 3.0, which also includes PSR-1: Basic Coding Standard.
+All PHP code must adhere to PER Coding Style, which includes PSR-1: Basic Coding Standard.
Files should _either_ declare symbols _or_ cause side-effects but not both.
## PHPDoc
-Add descriptive PHPDoc comments to all Structural Elements in PHP code under `src/`. Include descriptive `@param` and `@return` tags for all argument and return types, and `@var` tags for all parameters.
+Add descriptive PHPDoc comments to all Structural Elements in PHP code under `src/`. For functions and methods include the return type, and the `@param` and `@return` tags for every argument.
When writing PHPDocs, observe this format:
```php
-class
+class Foo
{
/**
* Constants don't need a `@var` doctag, since their type is implied.
@@ -53,32 +45,39 @@ class
private Baz baz():
/**
- * The first line should be a short description with no Markdown.
+ * The first line should be a short description, no Markdown allowed here.
*
- * Here you can add as many lines and all the Markdown you want.
+ * Here, provide a high-level explanation of what the function does and what
+ * the use cases are. Aim for a line length of 80 characters or fewer.
+ *
+ * You can use as many lines and all the Markdown you want.
* - reference arguments by making the text __bold__
- * - reference scalar types and literals with backticks, so `string` and `"foo"`
+ * - reference scalar types and literals with backticks, so `string` and
+ * `"foo"`
* - reference non-scalar types as {@see Foo}
* - import referenced classes, do not use FQN
*
+ * If appropriate, e.g. if not completely clear from the description
+ * signature alone, provide some examples:
+ *
* // 4 spaces indent Code blocks.
- * foo(1, 2, 'three');
+ * foo($foo, $bar, 'foo'); // returns $foo
+ * foo($foo, $bar, 'baz'); // returns $bar
*
- * Keep a blank line before the first doctag:
+ * Keep a blank line before the first doc tag:
*
* @template TFoo of Foo
* @template TBar of Bar
*
* @param TFoo $one
- * Place the description of a doctag on the next line, indented by 2
+ * Place the description of a doc tag on the next line, indented by 2
* spaces.
*
* @param TBar $two
- * Doctags with a description should be surrounded by a blank line.
+ * Doc tags with a description should be surrounded by a blank line.
*
* @param string $three
- * `@param` doctags should have their type and argument name surrounded
- * by 2 spaces
+ * Only `@param` doc tags have their type and name surrounded by 2 spaces.
*
* @return ($three is "foo" ? TFoo : TBar)
* Returns __one__ if __three__ is `"foo"`, __two__ otherwise.
diff --git a/README.md b/README.md
index 66223ba..7687b9e 100644
--- a/README.md
+++ b/README.md
@@ -92,7 +92,11 @@ Set `DOCKER_COMPOSER_DISABLE=1` to bypass Docker redirection temporarily.
This plugin redirects Composer scripts, including lifecycle scripts such as
`post-install-cmd` and custom scripts run through `composer run-script`.
-It does not transparently replace whole Composer commands such as
-`composer install` with `docker compose exec php composer install`. Composer's
-plugin command events do not provide a clean way to run a child command, skip the
-host command, and return the child exit code without relying on fragile internals.
+It also redirects dependency commands before host execution so platform
+requirements are resolved from inside the configured service:
+
+- `composer install`
+- `composer update`
+- `composer require`
+- `composer remove`
+- `composer reinstall`
diff --git a/composer.json b/composer.json
index 5a20c3d..34b38b9 100644
--- a/composer.json
+++ b/composer.json
@@ -51,11 +51,19 @@
"scripts": {
"style-check": "XDEBUG_MODE=off php-cs-fixer check",
"style-fix": "XDEBUG_MODE=off php-cs-fixer fix",
- "stan": "XDEBUG_MODE=off phpstan --memory-limit=1G",
+ "stan": "XDEBUG_MODE=off phpstan analyse --memory-limit=1G",
"test": "XDEBUG_MODE=coverage phpunit --coverage-text",
"test-integration": "XDEBUG_MODE=off phpunit --testsuite Integration",
"test-unit": "XDEBUG_MODE=coverage phpunit --testsuite Unit --coverage-text"
},
+ "scripts-descriptions": {
+ "style-check": "Check coding style using `php-cs-fixer check [options] [--] [...]`",
+ "style-fix": "Fix coding style using `php-cs-fixer fix [options] [--] [...]`",
+ "stan": "Perform static analysis using `phpstan analyse [options] [--] [...]`",
+ "test": "Run all test suites using `phpunit --coverage-text [options] [ ...]`",
+ "test-integration": "Run Integration test suite using `phpunit --testsuite Integration [options] [ ...]`",
+ "test-unit": "Run Unit test suite using `phpunit --testsuite Unit --coverage-text [options] [ ...]`"
+ },
"extra": {
"class": "empaphy\\docker_composer\\DockerComposerPlugin",
"branch-alias": {
diff --git a/src/DockerComposeCommandBuilder.php b/src/DockerComposeCommandBuilder.php
index 4d42a83..5da1f24 100644
--- a/src/DockerComposeCommandBuilder.php
+++ b/src/DockerComposeCommandBuilder.php
@@ -16,6 +16,7 @@
use Composer\Script\Event as ScriptEvent;
use Composer\Util\ProcessExecutor;
use InvalidArgumentException;
+use Symfony\Component\Console\Input\InputInterface;
/**
* Builds Docker Compose commands for redirected Composer scripts.
@@ -97,6 +98,50 @@ public function buildScriptCommand(DockerComposerConfig $config, ScriptEvent $ev
return array_merge($command, $this->composerRunScriptCommand($event));
}
+ /**
+ * Builds the Docker Compose Composer command execution command.
+ *
+ * @param DockerComposerConfig $config
+ * The Docker Composer configuration that provides service options.
+ *
+ * @param string $commandName
+ * The Composer command name to replay inside Docker Compose.
+ *
+ * @param InputInterface $input
+ * The original console input whose raw arguments are replayed.
+ *
+ * @param bool $interactive
+ * Whether the Docker command should keep TTY interaction enabled.
+ *
+ * @return list
+ * Returns command arguments for `docker compose exec` or `run`.
+ */
+ public function buildComposerCommand(DockerComposerConfig $config, string $commandName, InputInterface $input, bool $interactive): array
+ {
+ $command = $this->composeBase($config);
+ $command[] = $config->getMode();
+
+ if ($config->getMode() === DockerComposerConfig::MODE_RUN) {
+ $command[] = '--rm';
+ }
+
+ if (! $interactive) {
+ $command[] = '-T';
+ }
+
+ if ($config->getWorkdir() !== null) {
+ $command[] = '--workdir';
+ $command[] = $config->getWorkdir();
+ }
+
+ $command[] = '--env';
+ $command[] = 'DOCKER_COMPOSER_INSIDE=1';
+ $command[] = $config->getService();
+ $command[] = 'composer';
+
+ return array_merge($command, $this->getCommandArguments($input, $commandName));
+ }
+
/**
* Builds the common Docker Compose command prefix.
*
@@ -160,6 +205,169 @@ private function composerRunScriptCommand(ScriptEvent $event): array
return $command;
}
+ /**
+ * Gets raw Composer command arguments with host working directory removed.
+ *
+ * @param InputInterface $input
+ * The input that may expose raw console tokens.
+ *
+ * @param string $commandName
+ * The Composer command name to replace the first argument.
+ *
+ * @return list
+ * Returns Composer arguments including the command name.
+ */
+ private function getCommandArguments(InputInterface $input, string $commandName): array
+ {
+ $tokens = $this->getRawInputTokens($input);
+ $firstArgument = $input->getFirstArgument();
+ $commandTokenReplaced = false;
+
+ foreach ($tokens as $index => $token) {
+ if (! $commandTokenReplaced && $token === $firstArgument) {
+ $tokens[$index] = $commandName;
+ $commandTokenReplaced = true;
+ }
+ }
+
+ if (! $commandTokenReplaced) {
+ $tokens[] = $commandName;
+ }
+
+ return $this->stripWorkingDirectoryTokens($tokens);
+ }
+
+ /**
+ * Gets raw input tokens across Symfony Console versions.
+ *
+ * @param InputInterface $input
+ * The console input that carries Composer command arguments.
+ *
+ * @return list
+ * Returns raw tokens without the Composer executable.
+ *
+ * @throws InvalidArgumentException
+ * Thrown when raw tokens cannot be recovered safely.
+ */
+ private function getRawInputTokens(InputInterface $input): array
+ {
+ if (method_exists($input, 'getRawTokens')) {
+ /** @var list $tokens */
+ $tokens = $input->getRawTokens();
+
+ return $tokens;
+ }
+
+ $tokens = $this->getRawInputTokensFromProperty($input);
+ if ($tokens !== null) {
+ return $tokens;
+ }
+
+ $argv = $_SERVER['argv'] ?? null;
+ if (! is_array($argv) || $argv === []) {
+ throw new InvalidArgumentException('Composer command input must expose raw tokens.');
+ }
+
+ $tokens = [];
+ foreach (array_slice($argv, 1) as $token) {
+ if (! is_string($token)) {
+ throw new InvalidArgumentException('Composer command input must expose raw tokens.');
+ }
+
+ $tokens[] = $token;
+ }
+
+ return $tokens;
+ }
+
+ /**
+ * Gets raw input tokens from legacy Symfony Console internals.
+ *
+ * @param InputInterface $input
+ * The console input that may store raw tokens privately.
+ *
+ * @return list|null
+ * Returns raw tokens, or `null` when no compatible property exists.
+ *
+ * @throws InvalidArgumentException
+ * Thrown when the legacy tokens property has an unexpected shape.
+ */
+ private function getRawInputTokensFromProperty(InputInterface $input): ?array
+ {
+ $reflection = new \ReflectionObject($input);
+ if (! $reflection->hasProperty('tokens')) {
+ return null;
+ }
+
+ $property = $reflection->getProperty('tokens');
+ $rawTokens = $property->getValue($input);
+ if (! is_array($rawTokens) || ! array_is_list($rawTokens)) {
+ throw new InvalidArgumentException('Composer command input must expose raw tokens.');
+ }
+
+ $tokens = [];
+ foreach ($rawTokens as $token) {
+ if (! is_string($token)) {
+ throw new InvalidArgumentException('Composer command input must expose raw tokens.');
+ }
+
+ $tokens[] = $token;
+ }
+
+ return $tokens;
+ }
+
+ /**
+ * Removes host-only Composer working directory options from arguments.
+ *
+ * @param list $tokens
+ * The raw command arguments.
+ *
+ * @return list
+ * Returns __tokens__ without `--working-dir` or `-d`.
+ */
+ private function stripWorkingDirectoryTokens(array $tokens): array
+ {
+ $stripped = [];
+ $afterOptions = false;
+ $skipNext = false;
+
+ foreach ($tokens as $token) {
+ if ($skipNext) {
+ $skipNext = false;
+
+ continue;
+ }
+
+ if ($afterOptions) {
+ $stripped[] = $token;
+
+ continue;
+ }
+
+ if ($token === '--') {
+ $afterOptions = true;
+ $stripped[] = $token;
+
+ continue;
+ }
+
+ if ($token === '--working-dir' || $token === '-d') {
+ $skipNext = true;
+
+ continue;
+ }
+
+ if (str_starts_with($token, '--working-dir=') || preg_match('/^-d.+/', $token) === 1) {
+ continue;
+ }
+
+ $stripped[] = $token;
+ }
+
+ return $stripped;
+ }
+
/**
* Converts a Composer script argument to a command string.
*
diff --git a/src/DockerComposerPlugin.php b/src/DockerComposerPlugin.php
index 88de511..f85cb93 100644
--- a/src/DockerComposerPlugin.php
+++ b/src/DockerComposerPlugin.php
@@ -20,6 +20,8 @@
use Composer\EventDispatcher\EventSubscriberInterface;
use Composer\EventDispatcher\ScriptExecutionException;
use Composer\IO\IOInterface;
+use Composer\Plugin\PreCommandRunEvent;
+use Composer\Plugin\PluginEvents;
use Composer\Plugin\PluginInterface;
use Composer\Script\Event as ScriptEvent;
use Composer\Util\ProcessExecutor;
@@ -30,6 +32,19 @@
*/
class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface
{
+ /**
+ * Lists Composer commands redirected before host execution.
+ *
+ * @var list
+ */
+ private const REDIRECTED_COMMANDS = [
+ 'install',
+ 'update',
+ 'require',
+ 'remove',
+ 'reinstall',
+ ];
+
/**
* Stores Composer IO for plugin messages.
*/
@@ -121,6 +136,7 @@ public function activate(Composer $composer, IOInterface $io)
$this->writeUnknownConfigWarning();
$this->writeDuplicateServiceMappingWarnings($io);
+ $this->registerCommandListener($composer);
$this->registerScriptListeners($composer);
}
@@ -229,6 +245,74 @@ public function onScript(ScriptEvent $event): void
$event->stopPropagation();
}
+ /**
+ * Redirects selected Composer commands into Docker Compose.
+ *
+ * @param PreCommandRunEvent $event
+ * The Composer command event to inspect and possibly redirect.
+ *
+ * @return void
+ * Returns nothing.
+ *
+ * @throws ScriptExecutionException
+ * Thrown after Docker execution to stop host Composer command handling.
+ */
+ public function onCommand(PreCommandRunEvent $event): void
+ {
+ $commandName = $event->getCommand();
+ if (! in_array($commandName, self::REDIRECTED_COMMANDS, true)) {
+ return;
+ }
+
+ $io = $this->io;
+ if ($io === null || $this->isDisabledByEnvironment() || $this->containerDetector->isInsideContainer()) {
+ return;
+ }
+
+ $config = $this->config;
+ if ($config === null) {
+ return;
+ }
+
+ $this->writeDuplicateServiceMappingWarnings($io);
+
+ if ($config->isExcluded($commandName)) {
+ return;
+ }
+
+ if (! $config->isConfiguredForScript($commandName)) {
+ $this->writeMissingConfigWarning($io, $commandName, 'command');
+
+ return;
+ }
+
+ $commandConfig = $config->forScript($commandName);
+
+ $this->writeCommandRedirectNotice($io, $commandName, $commandConfig);
+ $this->runComposerCommandInDocker($event, $commandConfig);
+
+ throw new ScriptExecutionException('', 0);
+ }
+
+ /**
+ * Registers the listener for dependency Composer commands.
+ *
+ * @param Composer $composer
+ * The Composer instance whose commands should be watched.
+ *
+ * @return void
+ * Returns nothing.
+ */
+ private function registerCommandListener(Composer $composer): void
+ {
+ if (class_exists(PreCommandRunEvent::class) && defined(PluginEvents::class . '::PRE_COMMAND_RUN')) {
+ $eventName = constant(PluginEvents::class . '::PRE_COMMAND_RUN');
+ assert(is_string($eventName));
+
+ $composer->getEventDispatcher()->addListener($eventName, [$this, 'onCommand'], PHP_INT_MAX);
+ }
+ }
+
/**
* Registers listeners for configured Composer scripts.
*
@@ -329,21 +413,25 @@ private function writeDuplicateServiceMappingWarnings(IOInterface $io): void
* @param IOInterface $io
* The Composer IO that receives the warning.
*
- * @param string $scriptName
- * The Composer script name without a configured service.
+ * @param string $entryName
+ * The Composer script or command name without a configured service.
+ *
+ * @param string $entryType
+ * The Composer entry type being allowed to run on the host.
*
* @return void
* Returns nothing.
*/
- private function writeMissingConfigWarning(IOInterface $io, string $scriptName): void
+ private function writeMissingConfigWarning(IOInterface $io, string $entryName, string $entryType = 'script'): void
{
if ($this->missingConfigWarningWritten) {
return;
}
$io->writeError(sprintf(
- 'docker-composer: no default service and no service-mapping override for "%s"; running Composer script on the host.',
- OutputFormatter::escape($scriptName),
+ 'docker-composer: no default service and no service-mapping override for "%s"; running Composer %s on the host.',
+ OutputFormatter::escape($entryName),
+ $entryType,
));
$this->missingConfigWarningWritten = true;
}
@@ -369,6 +457,30 @@ private function writeRedirectNotice(ScriptEvent $event, DockerComposerConfig $c
));
}
+ /**
+ * Writes the command redirection notice.
+ *
+ * @param IOInterface $io
+ * The Composer IO that receives the notice.
+ *
+ * @param string $commandName
+ * The Composer command being redirected.
+ *
+ * @param DockerComposerConfig $config
+ * The configuration that provides the target service.
+ *
+ * @return void
+ * Returns nothing.
+ */
+ private function writeCommandRedirectNotice(IOInterface $io, string $commandName, DockerComposerConfig $config): void
+ {
+ $io->writeError(sprintf(
+ 'docker-composer: Running composer %s in Docker Compose service %s.',
+ OutputFormatter::escape($commandName),
+ OutputFormatter::escape($config->getService()),
+ ));
+ }
+
/**
* Runs a Composer script inside Docker Compose.
*
@@ -408,6 +520,46 @@ private function runInDocker(ScriptEvent $event, DockerComposerConfig $config):
}
}
+ /**
+ * Runs a Composer command inside Docker Compose.
+ *
+ * @param PreCommandRunEvent $event
+ * The Composer command event being executed.
+ *
+ * @param DockerComposerConfig $config
+ * The Docker Composer configuration used to build commands.
+ *
+ * @return void
+ * Returns nothing.
+ *
+ * @throws ScriptExecutionException
+ * Thrown when Docker Compose startup or command execution fails.
+ */
+ private function runComposerCommandInDocker(PreCommandRunEvent $event, DockerComposerConfig $config): void
+ {
+ $runner = $this->getProcessRunnerForCommand();
+
+ if ($config->getMode() === DockerComposerConfig::MODE_EXEC) {
+ $startupKey = $this->getExecServiceStartupKey($config);
+ if (! isset($this->startedExecServices[$startupKey])) {
+ $this->ensureExecServiceStarted($runner, $config);
+ $this->startedExecServices[$startupKey] = true;
+ }
+ }
+
+ $isInteractive = $event->getInput()->isInteractive() && $runner->supportsTty();
+ $command = $this->commandBuilder->buildComposerCommand(
+ $config,
+ $event->getCommand(),
+ $event->getInput(),
+ $isInteractive,
+ );
+ $exitCode = $runner->run($command, $isInteractive);
+ if ($exitCode !== 0) {
+ $this->throwScriptExecutionException($runner, $exitCode, $config->getMode(), $command);
+ }
+ }
+
/**
* Ensures the configured service can receive `docker compose exec`.
*
@@ -489,6 +641,25 @@ private function getProcessRunner(ScriptEvent $event): ProcessRunner
return $this->processRunner;
}
+ /**
+ * Gets the process runner for Docker commands.
+ *
+ * @return ProcessRunner
+ * Returns the configured or lazily created process runner.
+ */
+ private function getProcessRunnerForCommand(): ProcessRunner
+ {
+ if ($this->processRunner === null) {
+ if ($this->io === null) {
+ throw new ScriptExecutionException('Docker Composer plugin was not activated.', 1);
+ }
+
+ $this->processRunner = new ComposerProcessRunner($this->io);
+ }
+
+ return $this->processRunner;
+ }
+
/**
* Builds a cache key for exec-mode service startup.
*
diff --git a/tests/Integration/DockerComposerIntegrationTest.php b/tests/Integration/DockerComposerIntegrationTest.php
index 3fcee3b..59b5ef1 100644
--- a/tests/Integration/DockerComposerIntegrationTest.php
+++ b/tests/Integration/DockerComposerIntegrationTest.php
@@ -1,6 +1,8 @@
*/
+ /**
+ * @var list
+ */
private array $projectDirectories = [];
protected function tearDown(): void
@@ -46,6 +50,21 @@ public function testExecModeRedirectsCustomAndLifecycleScriptsWithAutoUp(): void
self::assertSame('1', trim((string) file_get_contents($projectDirectory . '/lifecycle.txt')));
}
+ public function testInstallCommandRedirectsWhenPluginIsAlreadyInstalled(): void
+ {
+ $projectDirectory = $this->createProject([
+ 'service' => 'php',
+ 'mode' => 'exec',
+ 'compose-files' => 'docker-compose.yaml',
+ 'workdir' => '/usr/src/app',
+ ]);
+ $this->installProject($projectDirectory);
+
+ $result = $this->runCommand(['composer', 'install', '--no-interaction', '--no-progress', '--prefer-dist'], $projectDirectory);
+
+ self::assertStringContainsString('Running composer install in Docker Compose service php.', $result['stderr']);
+ }
+
public function testServiceMappingOverrideRedirectsToConfiguredService(): void
{
$projectDirectory = $this->createProject([
@@ -106,8 +125,8 @@ public function testRunModeBypassMissingConfigAndInsideContainerBehavior(): void
}
/**
- * @param array $dockerComposerConfig
- * @param list>|null $repositories
+ * @param array $dockerComposerConfig
+ * @param list>|null $repositories
*/
private function createProject(array $dockerComposerConfig, ?array $repositories = null, string $requireVersion = '*'): string
{
@@ -207,13 +226,7 @@ private function getComposerImage(): string
}
/**
- * Gets a Composer require command for the active integration Composer version.
- *
- * @param string $package
- * The package constraint to require.
- *
* @return list
- * Returns a Composer require command compatible with the active version.
*/
protected function getRequireCommand(string $package): array
{
@@ -236,8 +249,10 @@ private function installProject(string $projectDirectory): void
/**
* @param list $command
* @param array $environment
+ *
+ * @return array{stdout: string, stderr: string, exit-code: int}
*/
- private function runCommand(array $command, string $workingDirectory, array $environment = [], bool $failOnError = true): void
+ private function runCommand(array $command, string $workingDirectory, array $environment = [], bool $failOnError = true): array
{
$descriptorSpec = [
1 => ['pipe', 'w'],
@@ -268,6 +283,12 @@ private function runCommand(array $command, string $workingDirectory, array $env
$stderr,
));
}
+
+ return [
+ 'stdout' => $stdout,
+ 'stderr' => $stderr,
+ 'exit-code' => $exitCode,
+ ];
}
private function removeDirectory(string $directory): void
diff --git a/tests/Unit/DockerComposeCommandBuilderTest.php b/tests/Unit/DockerComposeCommandBuilderTest.php
index f45ef81..e2f7ade 100644
--- a/tests/Unit/DockerComposeCommandBuilderTest.php
+++ b/tests/Unit/DockerComposeCommandBuilderTest.php
@@ -14,7 +14,11 @@
use empaphy\docker_composer\DockerComposerConfig;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
+use Symfony\Component\Console\Input\ArrayInput;
use Tests\TestCase;
+use Tests\Unit\Mocks\InvalidLegacyTokenInput;
+use Tests\Unit\Mocks\LegacyTokenInput;
+use Tests\Unit\Mocks\RawTokenInput;
#[CoversClass(DockerComposeCommandBuilder::class)]
#[UsesClass(DockerComposerConfig::class)]
@@ -62,4 +66,205 @@ public function testCommandBuilderRejectsNonScalarArguments(): void
$method->invoke(new DockerComposeCommandBuilder(), []);
}
+
+ public function testCommandBuilderUsesLegacyInputTokensProperty(): void
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $config = DockerComposerConfig::fromComposer($composer);
+ $input = new LegacyTokenInput([
+ '--no-interaction',
+ 'install',
+ '--working-dir=app',
+ '--prefer-dist',
+ ]);
+
+ $command = (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false);
+
+ self::assertSame([
+ 'composer',
+ '--no-interaction',
+ 'install',
+ '--prefer-dist',
+ ], array_slice($command, -4));
+ }
+
+ public function testCommandBuilderUsesRawTokensMethod(): void
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $config = DockerComposerConfig::fromComposer($composer);
+ $input = new RawTokenInput([
+ '--no-interaction',
+ 'i',
+ '--working-dir=/host/app',
+ '--prefer-dist',
+ ], 'i');
+
+ $command = (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false);
+
+ self::assertSame([
+ 'composer',
+ '--no-interaction',
+ 'install',
+ '--prefer-dist',
+ ], array_slice($command, -4));
+ }
+
+ public function testCommandBuilderStripsWorkingDirectoryTokenForms(): void
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $config = DockerComposerConfig::fromComposer($composer);
+ $input = new LegacyTokenInput([
+ '--working-dir',
+ '/host/a',
+ '-d',
+ '/host/b',
+ '-d/host/c',
+ '--working-dir=/host/d',
+ 'install',
+ '--',
+ '-d',
+ 'vendor/package',
+ ], 'install');
+
+ $command = (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false);
+
+ self::assertSame([
+ 'composer',
+ 'install',
+ '--',
+ '-d',
+ 'vendor/package',
+ ], array_slice($command, -5));
+ }
+
+ public function testCommandBuilderBuildsInteractiveRunCommand(): void
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => [
+ 'service' => 'php',
+ 'mode' => 'run',
+ ],
+ ]);
+ $config = DockerComposerConfig::fromComposer($composer);
+ $input = new LegacyTokenInput(['update'], 'update');
+
+ $command = (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'update', $input, true);
+
+ self::assertSame([
+ 'docker',
+ 'compose',
+ 'run',
+ '--rm',
+ '--env',
+ 'DOCKER_COMPOSER_INSIDE=1',
+ 'php',
+ 'composer',
+ 'update',
+ ], $command);
+ }
+
+ public function testCommandBuilderUsesServerArgvFallback(): void
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $config = DockerComposerConfig::fromComposer($composer);
+ $input = new ArrayInput(['install']);
+ $previousServer = $_SERVER;
+
+ try {
+ $_SERVER['argv'] = ['composer', '--no-interaction', '--no-dev'];
+
+ $command = (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false);
+ } finally {
+ $_SERVER = $previousServer;
+ }
+
+ self::assertSame([
+ 'composer',
+ '--no-interaction',
+ '--no-dev',
+ 'install',
+ ], array_slice($command, -4));
+ }
+
+ public function testCommandBuilderRejectsMissingRawTokens(): void
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $config = DockerComposerConfig::fromComposer($composer);
+ $input = new ArrayInput(['install']);
+ $previousServer = $_SERVER;
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Composer command input must expose raw tokens.');
+
+ try {
+ $_SERVER = array_diff_key($_SERVER, ['argv' => true]);
+
+ (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false);
+ } finally {
+ $_SERVER = $previousServer;
+ }
+ }
+
+ public function testCommandBuilderRejectsInvalidServerArgvTokens(): void
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $config = DockerComposerConfig::fromComposer($composer);
+ $input = new ArrayInput(['install']);
+ $previousServer = $_SERVER;
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Composer command input must expose raw tokens.');
+
+ try {
+ $_SERVER['argv'] = ['composer', []];
+
+ (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false);
+ } finally {
+ $_SERVER = $previousServer;
+ }
+ }
+
+ public function testCommandBuilderRejectsInvalidLegacyInputTokensProperty(): void
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $config = DockerComposerConfig::fromComposer($composer);
+ $input = new InvalidLegacyTokenInput(['install', []]);
+
+ self::assertSame(['install', []], $input->getTokensForAssertion());
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Composer command input must expose raw tokens.');
+
+ (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false);
+ }
+
+ public function testCommandBuilderRejectsMalformedLegacyInputTokensProperty(): void
+ {
+ [$composer] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $config = DockerComposerConfig::fromComposer($composer);
+ $input = new InvalidLegacyTokenInput(['command' => 'install']);
+
+ self::assertSame(['command' => 'install'], $input->getTokensForAssertion());
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Composer command input must expose raw tokens.');
+
+ (new DockerComposeCommandBuilder())->buildComposerCommand($config, 'install', $input, false);
+ }
}
diff --git a/tests/Unit/DockerComposerPluginTest.php b/tests/Unit/DockerComposerPluginTest.php
index 65bf5ae..93b1b64 100644
--- a/tests/Unit/DockerComposerPluginTest.php
+++ b/tests/Unit/DockerComposerPluginTest.php
@@ -10,6 +10,8 @@
use Composer\EventDispatcher\Event;
use Composer\EventDispatcher\ScriptExecutionException;
+use Composer\Plugin\PluginEvents;
+use Composer\Plugin\PreCommandRunEvent;
use Composer\Script\Event as ScriptEvent;
use empaphy\docker_composer\ComposerProcessRunner;
use empaphy\docker_composer\DockerComposeCommandBuilder;
@@ -23,6 +25,7 @@
use Tests\Unit\Mocks\MockContainerDetector;
use Tests\Unit\Mocks\MockOutputCapturingProcessRunner;
use Tests\Unit\Mocks\MockProcessRunner;
+use Symfony\Component\Console\Input\ArgvInput;
#[CoversClass(DockerComposerPlugin::class)]
#[CoversClass(ComposerProcessRunner::class)]
@@ -556,6 +559,284 @@ public function testMissingServiceWarnsOnceAndFallsThrough(): void
self::assertFalse($secondEvent->isPropagationStopped());
self::assertSame([], $runner->commands);
self::assertSame(1, substr_count($io->getOutput(), 'no default service and no service-mapping override for "test"'));
+ self::assertStringContainsString('running Composer script on the host', $io->getOutput());
+ }
+
+ public function testRedirectsInstallCommandBeforeHostExecution(): void
+ {
+ [$composer, $io] = $this->createComposer([], [
+ 'docker-composer' => [
+ 'service' => 'php',
+ 'compose-files' => ['docker-compose.yaml'],
+ 'project-directory' => '.',
+ 'workdir' => '/usr/src/app',
+ ],
+ ]);
+ $runner = new MockProcessRunner();
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
+ $input = new ArgvInput(['composer', '--no-interaction', 'install', '--no-dev', '--working-dir', 'app', '--prefer-dist']);
+ $input->setInteractive(false);
+ $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install');
+
+ $plugin->activate($composer, $io);
+ $exception = $this->assertCommandExecutionStops($plugin, $event);
+
+ self::assertSame(0, $exception->getCode());
+ self::assertSame([
+ [
+ 'docker',
+ 'compose',
+ '--file',
+ 'docker-compose.yaml',
+ '--project-directory',
+ '.',
+ 'up',
+ '-d',
+ 'php',
+ ],
+ [
+ 'docker',
+ 'compose',
+ '--file',
+ 'docker-compose.yaml',
+ '--project-directory',
+ '.',
+ 'exec',
+ '-T',
+ '--workdir',
+ '/usr/src/app',
+ '--env',
+ 'DOCKER_COMPOSER_INSIDE=1',
+ 'php',
+ 'composer',
+ '--no-interaction',
+ 'install',
+ '--no-dev',
+ '--prefer-dist',
+ ],
+ ], $runner->commands);
+ self::assertStringContainsString('Running composer install in Docker Compose service php.', $io->getOutput());
+ }
+
+ public function testRedirectsDependencyCommandsBeforeHostExecution(): void
+ {
+ [$composer, $io] = $this->createComposer([], [
+ 'docker-composer' => [
+ 'service' => 'php',
+ 'mode' => 'run',
+ ],
+ ]);
+ $runner = new MockProcessRunner();
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
+
+ $plugin->activate($composer, $io);
+
+ foreach (['update', 'require', 'remove', 'reinstall'] as $commandName) {
+ $input = new ArgvInput(['composer', $commandName, 'vendor/package']);
+ $input->setInteractive(false);
+ $this->assertCommandExecutionStops($plugin, new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, $commandName));
+ }
+
+ self::assertSame([
+ ['run', 'composer', 'update', 'vendor/package'],
+ ['run', 'composer', 'require', 'vendor/package'],
+ ['run', 'composer', 'remove', 'vendor/package'],
+ ['run', 'composer', 'reinstall', 'vendor/package'],
+ ], array_map(
+ static fn(array $command): array => [$command[2], $command[8], $command[9], $command[10]],
+ $runner->commands,
+ ));
+ }
+
+ public function testExcludedCommandFallsThrough(): void
+ {
+ [$composer, $io] = $this->createComposer([], [
+ 'docker-composer' => [
+ 'service' => 'php',
+ 'exclude' => ['install'],
+ ],
+ ]);
+ $runner = new MockProcessRunner();
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
+ $input = new ArgvInput(['composer', 'install']);
+ $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install');
+
+ $plugin->activate($composer, $io);
+ $plugin->onCommand($event);
+
+ self::assertSame([], $runner->commands);
+ }
+
+ public function testUnredirectedCommandFallsThrough(): void
+ {
+ [$composer, $io] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $runner = new MockProcessRunner();
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
+ $input = new ArgvInput(['composer', 'validate']);
+ $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'validate');
+
+ $plugin->activate($composer, $io);
+ $plugin->onCommand($event);
+
+ self::assertSame([], $runner->commands);
+ }
+
+ public function testCommandFallsThroughWithoutActivation(): void
+ {
+ $runner = new MockProcessRunner();
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
+ $input = new ArgvInput(['composer', 'install']);
+ $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install');
+
+ $plugin->onCommand($event);
+
+ self::assertSame([], $runner->commands);
+ }
+
+ public function testContainerCommandFallsThrough(): void
+ {
+ [$composer, $io] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $runner = new MockProcessRunner();
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(true));
+ $input = new ArgvInput(['composer', 'install']);
+ $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install');
+
+ $plugin->activate($composer, $io);
+ $plugin->onCommand($event);
+
+ self::assertSame([], $runner->commands);
+ }
+
+ #[BackupGlobals(true)]
+ public function testDisableEnvironmentVariableMakesCommandFallThrough(): void
+ {
+ [$composer, $io] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $runner = new MockProcessRunner();
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
+ $input = new ArgvInput(['composer', 'install']);
+ $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install');
+
+ putenv('DOCKER_COMPOSER_DISABLE=1');
+ try {
+ $plugin->activate($composer, $io);
+ $plugin->onCommand($event);
+ } finally {
+ putenv('DOCKER_COMPOSER_DISABLE');
+ }
+
+ self::assertSame([], $runner->commands);
+ }
+
+ public function testMissingServiceCommandWarnsAndFallsThrough(): void
+ {
+ [$composer, $io] = $this->createComposer([], [
+ 'docker-composer' => [],
+ ]);
+ $runner = new MockProcessRunner();
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
+ $input = new ArgvInput(['composer', 'install']);
+ $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install');
+
+ $plugin->activate($composer, $io);
+ $plugin->onCommand($event);
+
+ self::assertSame([], $runner->commands);
+ self::assertStringContainsString('no default service and no service-mapping override for "install"', $io->getOutput());
+ self::assertStringContainsString('running Composer command on the host', $io->getOutput());
+ self::assertStringNotContainsString('running Composer script on the host', $io->getOutput());
+ }
+
+ public function testCommandServiceMappingOverrideChangesTargetService(): void
+ {
+ [$composer, $io] = $this->createComposer([], [
+ 'docker-composer' => [
+ 'service' => 'php',
+ 'service-mapping' => [
+ 'php-tools' => 'install',
+ ],
+ ],
+ ]);
+ $runner = new MockProcessRunner();
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
+ $input = new ArgvInput(['composer', 'install']);
+ $input->setInteractive(false);
+ $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install');
+
+ $plugin->activate($composer, $io);
+ $this->assertCommandExecutionStops($plugin, $event);
+
+ self::assertSame('php-tools', $runner->commands[0][4]);
+ self::assertSame('php-tools', $runner->commands[1][6]);
+ self::assertStringContainsString('Running composer install in Docker Compose service php-tools.', $io->getOutput());
+ }
+
+ public function testCommandFallsThroughWhenIoExistsWithoutConfiguration(): void
+ {
+ [, $io] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $runner = new MockProcessRunner();
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
+ $ioProperty = new \ReflectionProperty(DockerComposerPlugin::class, 'io');
+ $input = new ArgvInput(['composer', 'install']);
+ $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install');
+
+ $ioProperty->setValue($plugin, $io);
+ $plugin->onCommand($event);
+
+ self::assertSame([], $runner->commands);
+ }
+
+ public function testCommandProcessRunnerRequiresActivationContext(): void
+ {
+ $plugin = new DockerComposerPlugin(null, new MockContainerDetector(false));
+ $method = new \ReflectionMethod(DockerComposerPlugin::class, 'getProcessRunnerForCommand');
+
+ $this->expectException(ScriptExecutionException::class);
+ $this->expectExceptionMessage('Docker Composer plugin was not activated.');
+
+ $method->invoke($plugin);
+ }
+
+ public function testCommandProcessRunnerCreatesDefaultRunnerFromStoredIo(): void
+ {
+ [, $io] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $plugin = new DockerComposerPlugin(null, new MockContainerDetector(false));
+ $ioProperty = new \ReflectionProperty(DockerComposerPlugin::class, 'io');
+ $method = new \ReflectionMethod(DockerComposerPlugin::class, 'getProcessRunnerForCommand');
+
+ $ioProperty->setValue($plugin, $io);
+
+ self::assertInstanceOf(ComposerProcessRunner::class, $method->invoke($plugin));
+ }
+
+ public function testCommandDockerFailurePreservesExitCode(): void
+ {
+ [$composer, $io] = $this->createComposer([], [
+ 'docker-composer' => ['service' => 'php'],
+ ]);
+ $runner = new MockProcessRunner([0, 7], 'install failed');
+ $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false));
+ $input = new ArgvInput(['composer', 'install']);
+ $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'install');
+
+ $plugin->activate($composer, $io);
+
+ $exception = $this->assertCommandExecutionStops($plugin, $event);
+
+ self::assertSame(7, $exception->getCode());
+ self::assertStringContainsString('Docker Compose exec command failed with exit code 7.', $exception->getMessage());
+ self::assertStringContainsString('composer', $exception->getMessage());
+ self::assertStringContainsString('install', $exception->getMessage());
+ self::assertStringContainsString('Error Output: install failed', $exception->getMessage());
}
public function testEmptyAndInvalidScriptNamesAreIgnoredDuringActivation(): void
@@ -807,4 +1088,15 @@ private function assertScriptExecutionFails(DockerComposerPlugin $plugin, Script
self::fail('Expected Docker script execution to fail.');
}
+
+ private function assertCommandExecutionStops(DockerComposerPlugin $plugin, PreCommandRunEvent $event): ScriptExecutionException
+ {
+ try {
+ $plugin->onCommand($event);
+ } catch (ScriptExecutionException $exception) {
+ return $exception;
+ }
+
+ self::fail('Expected Docker command execution to stop host command.');
+ }
}
diff --git a/tests/Unit/Mocks/InvalidLegacyTokenInput.php b/tests/Unit/Mocks/InvalidLegacyTokenInput.php
new file mode 100644
index 0000000..6d37105
--- /dev/null
+++ b/tests/Unit/Mocks/InvalidLegacyTokenInput.php
@@ -0,0 +1,29 @@
+tokens = $tokens;
+
+ parent::__construct(['install']);
+ }
+
+ public function getTokensForAssertion(): mixed
+ {
+ return $this->tokens;
+ }
+
+ public function getFirstArgument(): string
+ {
+ return 'install';
+ }
+}
diff --git a/tests/Unit/Mocks/LegacyTokenInput.php b/tests/Unit/Mocks/LegacyTokenInput.php
new file mode 100644
index 0000000..ac6e8d6
--- /dev/null
+++ b/tests/Unit/Mocks/LegacyTokenInput.php
@@ -0,0 +1,43 @@
+
+ */
+ private array $tokens;
+
+ private ?string $firstArgument;
+
+ /**
+ * @param list $tokens
+ */
+ public function __construct(array $tokens, ?string $firstArgument = null)
+ {
+ $this->tokens = $tokens;
+ $this->firstArgument = $firstArgument;
+
+ parent::__construct($tokens);
+ }
+
+ public function getFirstArgument(): ?string
+ {
+ if ($this->firstArgument !== null) {
+ return $this->firstArgument;
+ }
+
+ foreach ($this->tokens as $token) {
+ if ($token !== '' && $token[0] !== '-') {
+ return $token;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/tests/Unit/Mocks/MockOutputCapturingProcessRunner.php b/tests/Unit/Mocks/MockOutputCapturingProcessRunner.php
index 554d978..86788b8 100644
--- a/tests/Unit/Mocks/MockOutputCapturingProcessRunner.php
+++ b/tests/Unit/Mocks/MockOutputCapturingProcessRunner.php
@@ -10,7 +10,9 @@
final class MockOutputCapturingProcessRunner extends MockProcessRunner implements OutputCapturingProcessRunner
{
- /** @var list */
+ /**
+ * @var list
+ */
private array $outputs;
/**
diff --git a/tests/Unit/Mocks/MockProcessExecutor.php b/tests/Unit/Mocks/MockProcessExecutor.php
index 2005a67..dd2104a 100644
--- a/tests/Unit/Mocks/MockProcessExecutor.php
+++ b/tests/Unit/Mocks/MockProcessExecutor.php
@@ -8,40 +8,44 @@
final class MockProcessExecutor extends ProcessExecutor
{
- /** @var list */
+ /**
+ * @var list>
+ */
public array $commands = [];
- /** @var list */
+ /**
+ * @var list>
+ */
public array $ttyCommands = [];
/**
* @noinspection PhpMissingParentConstructorInspection
*/
public function __construct(
- private int $executeExitCode,
- private int $ttyExitCode,
- private string $testErrorOutput,
- private string $testOutput = '',
+ private readonly int $executeExitCode,
+ private readonly int $ttyExitCode,
+ private readonly string $testErrorOutput,
+ private readonly string $testOutput = '',
) {}
/**
- * @param mixed $command
- * @param mixed $output
+ * @param string|non-empty-list $command
+ * @param mixed $output
*/
public function execute($command, &$output = null, ?string $cwd = null): int
{
- $this->commands[] = (string) $command;
+ $this->commands[] = $command;
$output = $this->testOutput;
return $this->executeExitCode;
}
/**
- * @param mixed $command
+ * @param string|non-empty-list $command
*/
public function executeTty($command, ?string $cwd = null): int
{
- $this->ttyCommands[] = (string) $command;
+ $this->ttyCommands[] = $command;
return $this->ttyExitCode;
}
diff --git a/tests/Unit/Mocks/MockProcessRunner.php b/tests/Unit/Mocks/MockProcessRunner.php
index 3072785..4e491bf 100644
--- a/tests/Unit/Mocks/MockProcessRunner.php
+++ b/tests/Unit/Mocks/MockProcessRunner.php
@@ -10,13 +10,19 @@
class MockProcessRunner implements ProcessRunner
{
- /** @var list> */
+ /**
+ * @var list>
+ */
public array $commands = [];
- /** @var list */
+ /**
+ * @var list
+ */
public array $tty = [];
- /** @var list */
+ /**
+ * @var list
+ */
private array $exitCodes;
/**
@@ -24,12 +30,15 @@ class MockProcessRunner implements ProcessRunner
*/
public function __construct(
array $exitCodes = [0],
- private string $errorOutput = '',
- private bool $supportsTty = false,
+ private readonly string $errorOutput = '',
+ private readonly bool $supportsTty = false,
) {
$this->exitCodes = $exitCodes;
}
+ /**
+ * @param list $command
+ */
public function run(array $command, bool $tty = false): int
{
$this->commands[] = $command;
diff --git a/tests/Unit/Mocks/RawTokenInput.php b/tests/Unit/Mocks/RawTokenInput.php
new file mode 100644
index 0000000..1065fbf
--- /dev/null
+++ b/tests/Unit/Mocks/RawTokenInput.php
@@ -0,0 +1,59 @@
+
+ */
+ private array $tokens;
+
+ private ?string $firstArgument;
+
+ /**
+ * @param list $tokens
+ */
+ public function __construct(array $tokens, ?string $firstArgument = null)
+ {
+ $this->tokens = $tokens;
+ $this->firstArgument = $firstArgument;
+
+ parent::__construct($tokens);
+ }
+
+ public function getFirstArgument(): ?string
+ {
+ return $this->firstArgument;
+ }
+
+ /**
+ * @return list
+ */
+ public function getRawTokens(bool $strip = false): array
+ {
+ if (! $strip) {
+ return $this->tokens;
+ }
+
+ $tokens = [];
+ $keep = false;
+ foreach ($this->tokens as $token) {
+ if (! $keep && $token === $this->firstArgument) {
+ $keep = true;
+
+ continue;
+ }
+
+ if ($keep) {
+ $tokens[] = $token;
+ }
+ }
+
+ return $tokens;
+ }
+}