From d86e3418312bea3dbc738bd81f86c365f14d5189 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Wed, 13 May 2026 10:30:40 +0200 Subject: [PATCH 01/10] docs: tweaked installation order in `README.md` --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7687b9e..c4d465b 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,8 @@ Composer plugin that ensures scripts are always executed within a Docker Compose ## Installation ```bash -composer require --dev empaphy/docker-composer composer config allow-plugins.empaphy/docker-composer true +composer require --dev empaphy/docker-composer ``` Composer 2.2 and newer require plugins to be allowed explicitly. Composer 1 ignores From 818cc2eb84b6bbb852e2f1768a6b8e51217db008 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Wed, 13 May 2026 17:32:35 +0200 Subject: [PATCH 02/10] feat: add Laravel Docker redirection Introduce shared Docker Compose execution primitives so Composer and Laravel flows use the same command building and service startup behavior. Add Laravel autodiscovery support, publishable config, console entry matching, and integration coverage for Artisan and custom bootstrap redirection. --- .idea/docker-composer.iml | 17 + .idea/php.xml | 17 + AGENTS.md | 24 +- README.md | 43 +- composer.json | 7 + config/docker_composer.php | 14 + src/DockerComposeCommandBuilder.php | 213 +++++-- src/DockerComposeExecutionResult.php | 82 +++ src/DockerComposeOptions.php | 76 +++ src/DockerComposeResolvedOptions.php | 89 +++ src/DockerComposeRunner.php | 150 +++++ src/DockerComposeWorkdirResolution.php | 67 +++ src/DockerComposeWorkdirResolver.php | 330 ++++++++++ src/DockerComposerConfig.php | 6 +- src/DockerComposerPlugin.php | 220 +++---- src/Laravel/Config.php | 563 ++++++++++++++++++ src/Laravel/ConsoleEntry.php | 145 +++++ src/Laravel/Redirector.php | 143 +++++ src/Laravel/ServiceProvider.php | 307 ++++++++++ src/ShellProcessRunner.php | 132 ++++ .../DockerComposerIntegrationTest.php | 326 +++++++++- .../Unit/DockerComposeCommandBuilderTest.php | 106 ++++ tests/Unit/DockerComposeRunnerTest.php | 105 ++++ tests/Unit/DockerComposerPluginTest.php | 46 +- tests/Unit/Laravel/ConfigTest.php | 112 ++++ tests/Unit/Laravel/ConsoleEntryTest.php | 33 + tests/Unit/Laravel/RedirectorTest.php | 193 ++++++ tests/Unit/Mocks/MockCommandBuilder.php | 13 +- 28 files changed, 3417 insertions(+), 162 deletions(-) create mode 100644 config/docker_composer.php create mode 100644 src/DockerComposeExecutionResult.php create mode 100644 src/DockerComposeOptions.php create mode 100644 src/DockerComposeResolvedOptions.php create mode 100644 src/DockerComposeRunner.php create mode 100644 src/DockerComposeWorkdirResolution.php create mode 100644 src/DockerComposeWorkdirResolver.php create mode 100644 src/Laravel/Config.php create mode 100644 src/Laravel/ConsoleEntry.php create mode 100644 src/Laravel/Redirector.php create mode 100644 src/Laravel/ServiceProvider.php create mode 100644 src/ShellProcessRunner.php create mode 100644 tests/Unit/DockerComposeRunnerTest.php create mode 100644 tests/Unit/Laravel/ConfigTest.php create mode 100644 tests/Unit/Laravel/ConsoleEntryTest.php create mode 100644 tests/Unit/Laravel/RedirectorTest.php diff --git a/.idea/docker-composer.iml b/.idea/docker-composer.iml index 73307ed..3a23a7a 100644 --- a/.idea/docker-composer.iml +++ b/.idea/docker-composer.iml @@ -73,6 +73,23 @@ + + + + + + + + + + + + + + + + + diff --git a/.idea/php.xml b/.idea/php.xml index 621e190..4ec6d1d 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -96,6 +96,23 @@ + + + + + + + + + + + + + + + + + diff --git a/AGENTS.md b/AGENTS.md index 39c4b8d..d539edb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,23 @@ ## 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. ## Plan Mode -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. +When asking the user to choose an approach, consider whether implementing multiple approaches that can be chained as fallbacks is the recommended options. ## Tests When writing unit tests, create a TestCase class for each class being tested. At the end of every task, execute these commands to ensure the quality of the code: - `composer style-fix` -- `composer stan` -- `composer test` +- `composer check` + +### Coverage +All unit tests are required to have both a branch and line coverage of 100%. + +## Architecture +DRY: Don't Repeat Yourself — before adding new code, inspect existing abstractions and extend/reuse them. +Framework integrations belong in framework-named subdirectories under `src/`. +Do not duplicate code when a shared abstraction can cover the behavior. ## Coding Style All PHP code must adhere to PER Coding Style, which includes PSR-1: Basic Coding Standard. @@ -42,7 +48,15 @@ class Foo /** * The `@var` doctag is omitted if the type is unambiguous. */ - private Baz baz(): + private Baz $baz; + + /** + * The `@return` doctag is omitted if the return type is `void`. + */ + public function doSomething(): void + { + // Imagine this method does something. + } /** * The first line should be a short description, no Markdown allowed here. diff --git a/README.md b/README.md index c4d465b..32e58d1 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ Supported keys: - `mode`: `exec` or `run`; defaults to `exec`. - `compose-files`: one compose file path or a list of compose file paths. - `project-directory`: optional Docker Compose project directory. -- `workdir`: optional working directory inside the container. +- `workdir`: optional working directory inside the container. When omitted, the plugin attempts to infer it. - `exclude`: exact Composer script/event names that should run on the host. - `service-mapping`: Docker Compose service names mapped to one script or a list of scripts. @@ -85,6 +85,12 @@ then run normally because the plugin detects that Composer is already inside a container. It also treats `/.dockerenv`, `/run/.containerenv`, and common cgroup markers as container signals. +When `workdir` is omitted, the plugin attempts to infer the host project root's +container path from Docker Compose bind volumes. If no mapping is found, it +falls back to configured service `working_dir`, probing `pwd`, then image +`Config.WorkingDir`. Path translation only runs when a host-to-container +mapping is known. + Set `DOCKER_COMPOSER_DISABLE=1` to bypass Docker redirection temporarily. ## Scope @@ -100,3 +106,38 @@ requirements are resolved from inside the configured service: - `composer require` - `composer remove` - `composer reinstall` + +## Laravel + +The package also registers a Laravel service provider through package +autodiscovery. Publish and enable the Laravel config: + +```bash +php artisan vendor:publish --tag=docker-composer-config +``` + +```php +return [ + 'enabled' => env('DOCKER_COMPOSER_LARAVEL', false), + 'service' => 'php', + 'mode' => 'exec', + 'compose_files' => ['docker-compose.yaml'], + 'project_directory' => '.', + 'workdir' => '/usr/src/app', + 'exclude' => ['queue:work'], + 'service_mapping' => [ + 'php-tools' => [ + 'config:cache', + Illuminate\Foundation\Console\ConfigCacheCommand::class, + ':scripts/task.php', + ], + ], +]; +``` + +When enabled, Laravel CLI bootstraps run in Docker Compose unless excluded. +Artisan commands can be mapped by command name or command class. Custom scripts +that bootstrap Laravel can be mapped by project-relative path prefixed with `:`. + +The Laravel integration preserves the original CLI arguments and translates +absolute host project paths to the configured container `workdir`. diff --git a/composer.json b/composer.json index 34b38b9..f11aa8b 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "require-dev": { "composer/composer": ">=1.1", "empaphy/filharmonic": "^1", + "illuminate/support": ">=10", "friendsofphp/php-cs-fixer": "^3", "phpstan/phpstan": "^2", "phpstan/phpstan-phpunit": "^2", @@ -49,6 +50,7 @@ } }, "scripts": { + "check": ["@style-check", "@stan", "@test"], "style-check": "XDEBUG_MODE=off php-cs-fixer check", "style-fix": "XDEBUG_MODE=off php-cs-fixer fix", "stan": "XDEBUG_MODE=off phpstan analyse --memory-limit=1G", @@ -66,6 +68,11 @@ }, "extra": { "class": "empaphy\\docker_composer\\DockerComposerPlugin", + "laravel": { + "providers": [ + "empaphy\\docker_composer\\Laravel\\ServiceProvider" + ] + }, "branch-alias": { "dev-main": "1.x-dev" } diff --git a/config/docker_composer.php b/config/docker_composer.php new file mode 100644 index 0000000..f9aaf50 --- /dev/null +++ b/config/docker_composer.php @@ -0,0 +1,14 @@ + env('DOCKER_COMPOSER_LARAVEL', false), + 'service' => null, + 'mode' => 'exec', + 'compose_files' => [], + 'project_directory' => null, + 'workdir' => null, + 'exclude' => [], + 'service_mapping' => [], +]; diff --git a/src/DockerComposeCommandBuilder.php b/src/DockerComposeCommandBuilder.php index 5da1f24..762666c 100644 --- a/src/DockerComposeCommandBuilder.php +++ b/src/DockerComposeCommandBuilder.php @@ -26,13 +26,13 @@ class DockerComposeCommandBuilder /** * Builds the Docker Compose service startup command. * - * @param DockerComposerConfig $config + * @param DockerComposeOptions $config * The Docker Composer configuration that provides service options. * * @return list * Returns command arguments for `docker compose up -d`. */ - public function buildUpCommand(DockerComposerConfig $config): array + public function buildUpCommand(DockerComposeOptions $config): array { return array_merge($this->composeBase($config), [ 'up', @@ -44,13 +44,13 @@ public function buildUpCommand(DockerComposerConfig $config): array /** * Builds the Docker Compose running services command. * - * @param DockerComposerConfig $config + * @param DockerComposeOptions $config * The Docker Composer configuration that provides service options. * * @return list * Returns command arguments for `docker compose ps`. */ - public function buildRunningServicesCommand(DockerComposerConfig $config): array + public function buildRunningServicesCommand(DockerComposeOptions $config): array { return array_merge( $this->composeBase($config), @@ -61,7 +61,7 @@ public function buildRunningServicesCommand(DockerComposerConfig $config): array /** * Builds the Docker Compose script execution command. * - * @param DockerComposerConfig $config + * @param DockerComposeOptions $config * The Docker Composer configuration that provides service options. * * @param ScriptEvent $event @@ -73,35 +73,15 @@ public function buildRunningServicesCommand(DockerComposerConfig $config): array * @return list * Returns command arguments for `docker compose exec` or `run`. */ - public function buildScriptCommand(DockerComposerConfig $config, ScriptEvent $event, bool $interactive): array + public function buildScriptCommand(DockerComposeOptions $config, ScriptEvent $event, 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(); - - return array_merge($command, $this->composerRunScriptCommand($event)); + return $this->buildProcessCommand($config, $this->composerRunScriptCommand($event), $interactive); } /** * Builds the Docker Compose Composer command execution command. * - * @param DockerComposerConfig $config + * @param DockerComposeOptions $config * The Docker Composer configuration that provides service options. * * @param string $commandName @@ -116,12 +96,32 @@ public function buildScriptCommand(DockerComposerConfig $config, ScriptEvent $ev * @return list * Returns command arguments for `docker compose exec` or `run`. */ - public function buildComposerCommand(DockerComposerConfig $config, string $commandName, InputInterface $input, bool $interactive): array + public function buildComposerCommand(DockerComposeOptions $config, string $commandName, InputInterface $input, bool $interactive): array + { + return $this->buildProcessCommand($config, array_merge(['composer'], $this->getCommandArguments($input, $commandName)), $interactive); + } + + /** + * Builds a Docker Compose process execution command. + * + * @param DockerComposeOptions $config + * The Docker Compose configuration that provides service options. + * + * @param list $processCommand + * The command arguments that should run inside the service. + * + * @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 buildProcessCommand(DockerComposeOptions $config, array $processCommand, bool $interactive): array { $command = $this->composeBase($config); $command[] = $config->getMode(); - if ($config->getMode() === DockerComposerConfig::MODE_RUN) { + if ($config->getMode() === DockerComposeOptions::MODE_RUN) { $command[] = '--rm'; } @@ -137,21 +137,150 @@ public function buildComposerCommand(DockerComposerConfig $config, string $comma $command[] = '--env'; $command[] = 'DOCKER_COMPOSER_INSIDE=1'; $command[] = $config->getService(); - $command[] = 'composer'; - return array_merge($command, $this->getCommandArguments($input, $commandName)); + return array_merge($command, $processCommand); + } + + /** + * Builds the Docker Compose config inspection command. + * + * @param DockerComposeOptions $config + * The configuration that provides compose files and project directory. + * + * @return list + * Returns command arguments for `docker compose config --format json`. + */ + public function buildConfigCommand(DockerComposeOptions $config): array + { + return array_merge($this->composeBase($config), [ + 'config', + '--format', + 'json', + ]); + } + + /** + * Builds a service default workdir probe for exec mode. + * + * @param DockerComposeOptions $config + * The Docker Compose configuration that identifies the service. + * + * @return list + * Returns command arguments for `docker compose exec -T pwd`. + */ + public function buildExecWorkdirCommand(DockerComposeOptions $config): array + { + return array_merge($this->composeBase($config), [ + 'exec', + '-T', + $config->getService(), + 'pwd', + ]); + } + + /** + * Builds a service default workdir probe for run mode. + * + * @param DockerComposeOptions $config + * The Docker Compose configuration that identifies the service. + * + * @return list + * Returns command arguments for `docker compose run --rm -T pwd`. + */ + public function buildRunWorkdirCommand(DockerComposeOptions $config): array + { + return array_merge($this->composeBase($config), [ + 'run', + '--rm', + '-T', + $config->getService(), + 'pwd', + ]); + } + + /** + * Builds a Docker image workdir inspection command. + * + * @param string $image + * The Docker image reference to inspect. + * + * @return list + * Returns command arguments for `docker image inspect`. + */ + public function buildImageWorkdirCommand(string $image): array + { + return [ + 'docker', + 'image', + 'inspect', + '--format', + '{{.Config.WorkingDir}}', + $image, + ]; + } + + /** + * Translates absolute host project paths in command arguments. + * + * @param list $arguments + * The host command arguments. + * + * @param string $hostProjectRoot + * The absolute project root on the host. + * + * @param string|null $containerWorkdir + * The configured container workdir, or `null` to leave paths unchanged. + * + * @return list + * Returns arguments with project-root paths translated into container paths. + */ + public function translateProjectPaths(array $arguments, string $hostProjectRoot, ?string $containerWorkdir): array + { + if ($containerWorkdir === null) { + return $arguments; + } + + $hostProjectRoot = $this->normalizePath($hostProjectRoot); + $containerWorkdir = rtrim($this->normalizePath($containerWorkdir), '/'); + $translated = []; + + foreach ($arguments as $argument) { + $prefix = ''; + $path = $argument; + if (str_contains($argument, '=')) { + [$prefix, $path] = explode('=', $argument, 2); + $prefix .= '='; + } + + $normalizedPath = $this->normalizePath($path); + if ($normalizedPath === $hostProjectRoot) { + $translated[] = $prefix . $containerWorkdir; + + continue; + } + + if (str_starts_with($normalizedPath, $hostProjectRoot . '/')) { + $translated[] = $prefix . $containerWorkdir . substr($normalizedPath, strlen($hostProjectRoot)); + + continue; + } + + $translated[] = $argument; + } + + return $translated; } /** * Builds the common Docker Compose command prefix. * - * @param DockerComposerConfig $config + * @param DockerComposeOptions $config * The configuration that provides compose files and project directory. * * @return list * Returns base arguments beginning with `docker compose`. */ - private function composeBase(DockerComposerConfig $config): array + private function composeBase(DockerComposeOptions $config): array { $command = ['docker', 'compose']; @@ -396,4 +525,20 @@ private function stringifyArgument($argument): string throw new InvalidArgumentException('Composer script arguments must be scalar values.'); } + + /** + * Normalizes path separators and trailing slashes. + * + * @param string $path + * The path to normalize. + * + * @return string + * Returns a slash-separated path without trailing slash. + */ + private function normalizePath(string $path): string + { + $path = str_replace('\\', '/', $path); + + return rtrim($path, '/'); + } } diff --git a/src/DockerComposeExecutionResult.php b/src/DockerComposeExecutionResult.php new file mode 100644 index 0000000..1877086 --- /dev/null +++ b/src/DockerComposeExecutionResult.php @@ -0,0 +1,82 @@ + + * @license MIT + * @package DockerComposer + */ + +declare(strict_types=1); + +namespace empaphy\docker_composer; + +/** + * Describes the completed Docker Compose phase and exit code. + */ +final class DockerComposeExecutionResult +{ + /** + * Creates a Docker Compose execution result. + * + * @param string $phase + * The Docker Compose phase that ran, such as `"up"` or `"exec"`. + * + * @param list $command + * The Docker Compose command arguments that were executed. + * + * @param int $exitCode + * The process exit code returned by Docker Compose. + */ + public function __construct( + private readonly string $phase, + private readonly array $command, + private readonly int $exitCode, + ) {} + + /** + * Checks whether Docker Compose completed successfully. + * + * @return bool + * Returns `true` when the exit code is zero. + */ + public function isSuccessful(): bool + { + return $this->exitCode === 0; + } + + /** + * Gets the Docker Compose phase that ran. + * + * @return string + * Returns the phase name. + */ + public function getPhase(): string + { + return $this->phase; + } + + /** + * Gets the executed Docker Compose command. + * + * @return list + * Returns command arguments. + */ + public function getCommand(): array + { + return $this->command; + } + + /** + * Gets the Docker Compose exit code. + * + * @return int + * Returns the process exit code. + */ + public function getExitCode(): int + { + return $this->exitCode; + } +} diff --git a/src/DockerComposeOptions.php b/src/DockerComposeOptions.php new file mode 100644 index 0000000..99115e6 --- /dev/null +++ b/src/DockerComposeOptions.php @@ -0,0 +1,76 @@ + + * @license MIT + * @package DockerComposer + */ + +declare(strict_types=1); + +namespace empaphy\docker_composer; + +/** + * Exposes service-level options needed to run Docker Compose commands. + */ +interface DockerComposeOptions +{ + /** + * Selects Docker Compose exec mode. + * + * @var string + * Stores the mode that executes commands in an existing service. + */ + public const MODE_EXEC = 'exec'; + + /** + * Selects Docker Compose run mode. + * + * @var string + * Stores the mode that creates a one-off service container. + */ + public const MODE_RUN = 'run'; + + /** + * Gets the configured Docker Compose service name. + * + * @return string + * Returns the non-empty service name. + */ + public function getService(): string; + + /** + * Gets the configured Docker Compose mode. + * + * @return string + * Returns `"exec"` or `"run"`. + */ + public function getMode(): string; + + /** + * Gets configured Docker Compose file paths. + * + * @return list + * Returns paths passed to Docker Compose with `--file`. + */ + public function getComposeFiles(): array; + + /** + * Gets the configured Docker Compose project directory. + * + * @return string|null + * Returns the directory path, or `null` for Docker Compose defaults. + */ + public function getProjectDirectory(): ?string; + + /** + * Gets the configured service working directory. + * + * @return string|null + * Returns the service working directory, or `null` for service default. + */ + public function getWorkdir(): ?string; +} diff --git a/src/DockerComposeResolvedOptions.php b/src/DockerComposeResolvedOptions.php new file mode 100644 index 0000000..6937a98 --- /dev/null +++ b/src/DockerComposeResolvedOptions.php @@ -0,0 +1,89 @@ + + * @license MIT + * @package DockerComposer + */ + +declare(strict_types=1); + +namespace empaphy\docker_composer; + +/** + * Wraps Docker Compose options with an inferred working directory. + */ +final class DockerComposeResolvedOptions implements DockerComposeOptions +{ + /** + * Creates resolved Docker Compose options. + * + * @param DockerComposeOptions $options + * The source options. + * + * @param string|null $workdir + * The resolved working directory, or `null`. + */ + public function __construct( + private readonly DockerComposeOptions $options, + private readonly ?string $workdir, + ) {} + + /** + * Gets the configured Docker Compose service name. + * + * @return string + * Returns the non-empty service name. + */ + public function getService(): string + { + return $this->options->getService(); + } + + /** + * Gets the configured Docker Compose mode. + * + * @return string + * Returns `"exec"` or `"run"`. + */ + public function getMode(): string + { + return $this->options->getMode(); + } + + /** + * Gets configured Docker Compose file paths. + * + * @return list + * Returns paths passed to Docker Compose with `--file`. + */ + public function getComposeFiles(): array + { + return $this->options->getComposeFiles(); + } + + /** + * Gets the configured Docker Compose project directory. + * + * @return string|null + * Returns the directory path, or `null` for Docker Compose defaults. + */ + public function getProjectDirectory(): ?string + { + return $this->options->getProjectDirectory(); + } + + /** + * Gets the resolved service working directory. + * + * @return string|null + * Returns the resolved service workdir, or `null`. + */ + public function getWorkdir(): ?string + { + return $this->workdir; + } +} diff --git a/src/DockerComposeRunner.php b/src/DockerComposeRunner.php new file mode 100644 index 0000000..d5002f3 --- /dev/null +++ b/src/DockerComposeRunner.php @@ -0,0 +1,150 @@ + + * @license MIT + * @package DockerComposer + */ + +declare(strict_types=1); + +namespace empaphy\docker_composer; + +/** + * Runs Docker Compose commands and prepares exec-mode services. + */ +final class DockerComposeRunner +{ + /** + * Tracks services started for Docker Compose exec mode. + * + * @var array + */ + private array $startedExecServices = []; + + /** + * Creates a Docker Compose runner. + * + * @param ProcessRunner $processRunner + * The process runner used for Docker Compose commands. + * + * @param DockerComposeCommandBuilder $commandBuilder + * The command builder used for service startup and status checks. + */ + public function __construct( + private readonly ProcessRunner $processRunner, + private readonly DockerComposeCommandBuilder $commandBuilder, + ) {} + + /** + * Runs a prepared Docker Compose command. + * + * @param DockerComposeOptions $config + * The Docker Compose options for the target service. + * + * @param list $command + * The full Docker Compose command to execute. + * + * @param bool $interactive + * Whether TTY passthrough should be requested. + * + * @return DockerComposeExecutionResult + * Returns the completed execution result. + */ + public function run(DockerComposeOptions $config, array $command, bool $interactive): DockerComposeExecutionResult + { + if ($config->getMode() === DockerComposeOptions::MODE_EXEC) { + $startup = $this->ensureExecServiceStarted($config); + if ($startup !== null && ! $startup->isSuccessful()) { + return $startup; + } + } + + $exitCode = $this->processRunner->run($command, $interactive); + + return new DockerComposeExecutionResult($config->getMode(), $command, $exitCode); + } + + /** + * Ensures the configured service can receive `docker compose exec`. + * + * @param DockerComposeOptions $config + * The Docker Compose options that identify the service. + * + * @return DockerComposeExecutionResult|null + * Returns a failed startup result, or `null` when startup is complete. + */ + public function ensureExecServiceStarted(DockerComposeOptions $config): ?DockerComposeExecutionResult + { + $startupKey = $this->getExecServiceStartupKey($config); + if (isset($this->startedExecServices[$startupKey])) { + return null; + } + + if ($this->isExecServiceRunning($config)) { + $this->startedExecServices[$startupKey] = true; + + return null; + } + + $upCommand = $this->commandBuilder->buildUpCommand($config); + $exitCode = $this->processRunner->run($upCommand); + if ($exitCode === 0) { + $this->startedExecServices[$startupKey] = true; + } + + return new DockerComposeExecutionResult('up', $upCommand, $exitCode); + } + + /** + * Checks whether the configured exec-mode service is running. + * + * @param DockerComposeOptions $config + * The Docker Compose options that identify the service. + * + * @return bool + * Returns `true` when Docker Compose lists the service as running. + */ + private function isExecServiceRunning(DockerComposeOptions $config): bool + { + if (! $this->processRunner instanceof OutputCapturingProcessRunner) { + return false; + } + + $command = $this->commandBuilder->buildRunningServicesCommand($config); + $output = ''; + if ($this->processRunner->runWithOutput($command, $output) !== 0) { + return false; + } + + $services = preg_split('/\R/', trim($output)) ?: []; + foreach ($services as $service) { + if (trim($service) === $config->getService()) { + return true; + } + } + + return false; + } + + /** + * Builds a cache key for exec-mode service startup. + * + * @param DockerComposeOptions $config + * The Docker Compose options that identify the service. + * + * @return string + * Returns a stable serialized key for the service startup command. + */ + private function getExecServiceStartupKey(DockerComposeOptions $config): string + { + return serialize([ + $config->getService(), + $config->getComposeFiles(), + $config->getProjectDirectory(), + ]); + } +} diff --git a/src/DockerComposeWorkdirResolution.php b/src/DockerComposeWorkdirResolution.php new file mode 100644 index 0000000..7205cbf --- /dev/null +++ b/src/DockerComposeWorkdirResolution.php @@ -0,0 +1,67 @@ + + * @license MIT + * @package DockerComposer + */ + +declare(strict_types=1); + +namespace empaphy\docker_composer; + +/** + * Stores inferred container workdir and host project path mapping. + */ +final class DockerComposeWorkdirResolution +{ + /** + * Creates resolved workdir metadata. + * + * @param string|null $workdir + * The container working directory, or `null` when unavailable. + * + * @param string|null $containerProjectRoot + * The container path matching the host project root, or `null`. + */ + public function __construct( + private readonly ?string $workdir, + private readonly ?string $containerProjectRoot, + ) {} + + /** + * Gets the resolved container workdir. + * + * @return string|null + * Returns the container working directory, or `null`. + */ + public function getWorkdir(): ?string + { + return $this->workdir; + } + + /** + * Gets the container project root mapping. + * + * @return string|null + * Returns the container path matching the host project root, or `null`. + */ + public function getContainerProjectRoot(): ?string + { + return $this->containerProjectRoot; + } + + /** + * Checks whether host project paths can be translated. + * + * @return bool + * Returns `true` when the host project root has a container path. + */ + public function hasPathMapping(): bool + { + return $this->containerProjectRoot !== null; + } +} diff --git a/src/DockerComposeWorkdirResolver.php b/src/DockerComposeWorkdirResolver.php new file mode 100644 index 0000000..5950784 --- /dev/null +++ b/src/DockerComposeWorkdirResolver.php @@ -0,0 +1,330 @@ + + * @license MIT + * @package DockerComposer + */ + +declare(strict_types=1); + +namespace empaphy\docker_composer; + +/** + * Resolves container workdir and host project path mapping. + */ +final class DockerComposeWorkdirResolver +{ + /** + * Creates a Docker Compose workdir resolver. + * + * @param DockerComposeCommandBuilder $commandBuilder + * The builder used for discovery commands. + */ + public function __construct( + private readonly DockerComposeCommandBuilder $commandBuilder, + ) {} + + /** + * Resolves workdir and path mapping for a service. + * + * @param DockerComposeOptions $config + * The effective Docker Compose service options. + * + * @param string $hostProjectRoot + * The absolute project root on the host. + * + * @param ProcessRunner|null $processRunner + * The runner used for discovery commands, or `null` to skip them. + * + * @param DockerComposeRunner|null $dockerRunner + * The Docker Compose runner used to prepare exec probes. + * + * @return DockerComposeWorkdirResolution + * Returns inferred workdir and host project path mapping. + */ + public function resolve( + DockerComposeOptions $config, + string $hostProjectRoot, + ?ProcessRunner $processRunner = null, + ?DockerComposeRunner $dockerRunner = null, + ): DockerComposeWorkdirResolution { + $workdir = $config->getWorkdir(); + $containerProjectRoot = null; + $service = $processRunner instanceof OutputCapturingProcessRunner + ? $this->readComposeService($config, $processRunner) + : null; + + if ($service !== null) { + $containerProjectRoot = $this->inferContainerProjectRoot($service, $hostProjectRoot); + if ($containerProjectRoot !== null && $workdir === null) { + $workdir = $containerProjectRoot; + } + + $workdir ??= $this->readServiceWorkingDir($service); + } + + if ($containerProjectRoot === null && $config->getWorkdir() !== null) { + $containerProjectRoot = $config->getWorkdir(); + } + + if ($workdir === null && $processRunner instanceof OutputCapturingProcessRunner) { + $workdir = $this->probeContainerWorkdir($config, $processRunner, $dockerRunner); + } + + if ($workdir === null && $processRunner instanceof OutputCapturingProcessRunner && $service !== null) { + $workdir = $this->inspectImageWorkdir($service, $processRunner); + } + + return new DockerComposeWorkdirResolution($workdir, $containerProjectRoot); + } + + /** + * Reads the target service object from Docker Compose config. + * + * @param DockerComposeOptions $config + * The service options. + * + * @param OutputCapturingProcessRunner $processRunner + * The runner used to read Docker Compose config. + * + * @return array|null + * Returns the service config object, or `null` when unavailable. + */ + private function readComposeService(DockerComposeOptions $config, OutputCapturingProcessRunner $processRunner): ?array + { + $output = ''; + if ($processRunner->runWithOutput($this->commandBuilder->buildConfigCommand($config), $output) !== 0) { + return null; + } + + $decoded = json_decode($output, true); + if (! is_array($decoded)) { + return null; + } + + $services = $decoded['services'] ?? null; + if (! is_array($services)) { + return null; + } + + $service = $services[$config->getService()] ?? null; + + return is_array($service) ? $service : null; + } + + /** + * Infers the container project root from service bind volumes. + * + * @param array $service + * The Docker Compose service config object. + * + * @param string $hostProjectRoot + * The absolute project root on the host. + * + * @return string|null + * Returns the mapped container project root, or `null`. + */ + private function inferContainerProjectRoot(array $service, string $hostProjectRoot): ?string + { + $volumes = $service['volumes'] ?? null; + if (! is_array($volumes) || ! array_is_list($volumes)) { + return null; + } + + $hostProjectRoot = $this->normalizePath($hostProjectRoot); + $bestSource = null; + $bestTarget = null; + + foreach ($volumes as $volume) { + if (! is_array($volume) || ($volume['type'] ?? null) !== 'bind') { + continue; + } + + $source = $volume['source'] ?? null; + $target = $volume['target'] ?? null; + if (! is_string($source) || $source === '' || ! is_string($target) || $target === '') { + continue; + } + + $source = $this->normalizePath($source); + $target = $this->normalizePath($target); + if ($source === $hostProjectRoot) { + return $target; + } + + if ($this->isPathAncestor($source, $hostProjectRoot) && ($bestSource === null || strlen($source) > strlen($bestSource))) { + $bestSource = $source; + $bestTarget = $target; + } + } + + if ($bestSource === null || $bestTarget === null) { + return null; + } + + return $this->appendPath($bestTarget, substr($hostProjectRoot, strlen($bestSource))); + } + + /** + * Reads a service-level Docker Compose working directory. + * + * @param array $service + * The Docker Compose service config object. + * + * @return string|null + * Returns the configured `working_dir`, or `null`. + */ + private function readServiceWorkingDir(array $service): ?string + { + $workingDir = $service['working_dir'] ?? null; + + return is_string($workingDir) && $workingDir !== '' ? $workingDir : null; + } + + /** + * Probes the service process for its default working directory. + * + * @param DockerComposeOptions $config + * The service options. + * + * @param OutputCapturingProcessRunner $processRunner + * The runner used for discovery commands. + * + * @param DockerComposeRunner|null $dockerRunner + * The Docker runner used to prepare exec services. + * + * @return string|null + * Returns the probed working directory, or `null`. + */ + private function probeContainerWorkdir( + DockerComposeOptions $config, + OutputCapturingProcessRunner $processRunner, + ?DockerComposeRunner $dockerRunner, + ): ?string { + if ($config->getMode() === DockerComposeOptions::MODE_EXEC) { + if ($dockerRunner === null) { + return null; + } + + $startup = $dockerRunner->ensureExecServiceStarted($config); + if ($startup !== null && ! $startup->isSuccessful()) { + return null; + } + + return $this->runWorkdirProbe($processRunner, $this->commandBuilder->buildExecWorkdirCommand($config)); + } + + return $this->runWorkdirProbe($processRunner, $this->commandBuilder->buildRunWorkdirCommand($config)); + } + + /** + * Reads image default workdir from Docker image metadata. + * + * @param array $service + * The Docker Compose service config object. + * + * @param OutputCapturingProcessRunner $processRunner + * The runner used for Docker image inspection. + * + * @return string|null + * Returns image `Config.WorkingDir`, or `null`. + */ + private function inspectImageWorkdir(array $service, OutputCapturingProcessRunner $processRunner): ?string + { + $image = $service['image'] ?? null; + if (! is_string($image) || $image === '') { + return null; + } + + return $this->runWorkdirProbe($processRunner, $this->commandBuilder->buildImageWorkdirCommand($image)); + } + + /** + * Runs a command that prints one workdir path. + * + * @param OutputCapturingProcessRunner $processRunner + * The runner used for discovery commands. + * + * @param list $command + * The command to execute. + * + * @return string|null + * Returns trimmed command output, or `null`. + */ + private function runWorkdirProbe(OutputCapturingProcessRunner $processRunner, array $command): ?string + { + $output = ''; + if ($processRunner->runWithOutput($command, $output) !== 0) { + return null; + } + + $output = trim($output); + + return $output !== '' ? $output : null; + } + + /** + * Checks whether one path is an ancestor of another. + * + * @param string $ancestor + * The possible ancestor path. + * + * @param string $path + * The possible descendant path. + * + * @return bool + * Returns `true` when __path__ is below __ancestor__. + */ + private function isPathAncestor(string $ancestor, string $path): bool + { + $prefix = $ancestor === '/' ? '/' : $ancestor . '/'; + + return str_starts_with($path, $prefix); + } + + /** + * Appends a normalized suffix to a container path. + * + * @param string $base + * The base container path. + * + * @param string $suffix + * The host suffix being mapped into the container. + * + * @return string + * Returns a slash-separated container path. + */ + private function appendPath(string $base, string $suffix): string + { + $suffix = ltrim(str_replace('\\', '/', $suffix), '/'); + if ($suffix === '') { + return $base; + } + + if ($base === '/') { + return '/' . $suffix; + } + + return $base . '/' . $suffix; + } + + /** + * Normalizes path separators and trailing slashes. + * + * @param string $path + * The path to normalize. + * + * @return string + * Returns a slash-separated path. + */ + private function normalizePath(string $path): string + { + $path = rtrim(str_replace('\\', '/', $path), '/'); + + return $path === '' ? '/' : $path; + } +} diff --git a/src/DockerComposerConfig.php b/src/DockerComposerConfig.php index 9b42db3..ad5b1c3 100644 --- a/src/DockerComposerConfig.php +++ b/src/DockerComposerConfig.php @@ -20,7 +20,7 @@ /** * Parses and exposes Docker Composer configuration from Composer metadata. */ -final class DockerComposerConfig +final class DockerComposerConfig implements DockerComposeOptions { /** * Names the Composer extra key used by this plugin. @@ -36,7 +36,7 @@ final class DockerComposerConfig * @var string * Stores the mode that executes scripts in an existing service container. */ - public const MODE_EXEC = 'exec'; + public const MODE_EXEC = DockerComposeOptions::MODE_EXEC; /** * Selects Docker Compose run mode. @@ -44,7 +44,7 @@ final class DockerComposerConfig * @var string * Stores the mode that creates a one-off service container for scripts. */ - public const MODE_RUN = 'run'; + public const MODE_RUN = DockerComposeOptions::MODE_RUN; /** * Lists supported configuration keys. diff --git a/src/DockerComposerPlugin.php b/src/DockerComposerPlugin.php index f85cb93..ef89803 100644 --- a/src/DockerComposerPlugin.php +++ b/src/DockerComposerPlugin.php @@ -50,6 +50,11 @@ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface */ private ?IOInterface $io = null; + /** + * Stores the Composer instance passed during activation. + */ + private ?Composer $composer = null; + /** * Stores parsed Docker Composer configuration. */ @@ -70,6 +75,16 @@ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface */ private DockerComposeCommandBuilder $commandBuilder; + /** + * Runs Docker Compose commands for the active process runner. + */ + private ?DockerComposeRunner $dockerRunner = null; + + /** + * Resolves container workdir and project path mapping. + */ + private DockerComposeWorkdirResolver $workdirResolver; + /** * Tracks whether the missing configuration warning was written. */ @@ -85,15 +100,6 @@ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface */ private bool $duplicateServiceMappingWarningsWritten = false; - /** - * Tracks services started for Docker Compose exec mode. - * - * Stores startup keys for services already started during this process. - * - * @var array - */ - private array $startedExecServices = []; - /** * Creates a Composer plugin with optional collaborators. * @@ -105,15 +111,20 @@ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface * * @param DockerComposeCommandBuilder|null $commandBuilder * The command builder, or `null` for the default builder. + * + * @param DockerComposeWorkdirResolver|null $workdirResolver + * The workdir resolver, or `null` for the default resolver. */ public function __construct( ?ProcessRunner $processRunner = null, ?ContainerDetector $containerDetector = null, ?DockerComposeCommandBuilder $commandBuilder = null, + ?DockerComposeWorkdirResolver $workdirResolver = null, ) { $this->processRunner = $processRunner; $this->containerDetector = $containerDetector ?? new EnvironmentContainerDetector(); $this->commandBuilder = $commandBuilder ?? new DockerComposeCommandBuilder(); + $this->workdirResolver = $workdirResolver ?? new DockerComposeWorkdirResolver($this->commandBuilder); } /** @@ -131,6 +142,7 @@ public function __construct( public function activate(Composer $composer, IOInterface $io) { $this->io = $io; + $this->composer = $composer; $this->config = DockerComposerConfig::fromComposer($composer); $this->processRunner ??= new ComposerProcessRunner($io); @@ -499,25 +511,14 @@ private function writeCommandRedirectNotice(IOInterface $io, string $commandName private function runInDocker(ScriptEvent $event, DockerComposerConfig $config): void { $runner = $this->getProcessRunner($event); - - if ($config->getMode() === DockerComposerConfig::MODE_EXEC) { - $startupKey = $this->getExecServiceStartupKey($config); - if (! isset($this->startedExecServices[$startupKey])) { - $this->ensureExecServiceStarted($runner, $config); - $this->startedExecServices[$startupKey] = true; - } - } - + $config = $this->resolveDockerOptions($config, $this->getComposerProjectRoot($event->getComposer()), $runner); $isInteractive = $event->getIO()->isInteractive() && $runner->supportsTty(); $scriptCommand = $this->commandBuilder->buildScriptCommand( $config, $event, $isInteractive, ); - $exitCode = $runner->run($scriptCommand, $isInteractive); - if ($exitCode !== 0) { - $this->throwScriptExecutionException($runner, $exitCode, $config->getMode(), $scriptCommand); - } + $this->runDockerCommand($runner, $config, $scriptCommand, $isInteractive); } /** @@ -538,15 +539,7 @@ private function runInDocker(ScriptEvent $event, DockerComposerConfig $config): 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; - } - } - + $config = $this->resolveDockerOptions($config, $this->getComposerProjectRoot($this->composer), $runner); $isInteractive = $event->getInput()->isInteractive() && $runner->supportsTty(); $command = $this->commandBuilder->buildComposerCommand( $config, @@ -554,73 +547,7 @@ private function runComposerCommandInDocker(PreCommandRunEvent $event, DockerCom $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`. - * - * @param ProcessRunner $runner - * The runner used for Docker Compose commands. - * - * @param DockerComposerConfig $config - * The Docker Composer configuration that identifies the service. - * - * @return void - * Returns nothing. - * - * @throws ScriptExecutionException - * Thrown when Docker Compose startup fails. - */ - private function ensureExecServiceStarted(ProcessRunner $runner, DockerComposerConfig $config): void - { - if ($this->isExecServiceRunning($runner, $config)) { - return; - } - - $upCommand = $this->commandBuilder->buildUpCommand($config); - $exitCode = $runner->run($upCommand); - if ($exitCode !== 0) { - $this->throwScriptExecutionException($runner, $exitCode, 'up', $upCommand); - } - } - - /** - * Checks whether the configured exec-mode service is running. - * - * @param ProcessRunner $runner - * The runner used for Docker Compose commands. - * - * @param DockerComposerConfig $config - * The Docker Composer configuration that identifies the service. - * - * @return bool - * Returns `true` when Docker Compose lists the service as running. - */ - private function isExecServiceRunning(ProcessRunner $runner, DockerComposerConfig $config): bool - { - if (! $runner instanceof OutputCapturingProcessRunner) { - return false; - } - - $command = $this->commandBuilder->buildRunningServicesCommand($config); - $output = ''; - if ($runner->runWithOutput($command, $output) !== 0) { - return false; - } - - $services = preg_split('/\R/', trim($output)) ?: []; - - foreach ($services as $service) { - if (trim($service) === $config->getService()) { - return true; - } - } - - return false; + $this->runDockerCommand($runner, $config, $command, $isInteractive); } /** @@ -661,21 +588,53 @@ private function getProcessRunnerForCommand(): ProcessRunner } /** - * Builds a cache key for exec-mode service startup. + * Resolves Docker Compose workdir metadata for execution. * * @param DockerComposerConfig $config - * The Docker Composer configuration that identifies the service. + * The parsed Docker Composer configuration. + * + * @param string $projectRoot + * The host project root. + * + * @param ProcessRunner $runner + * The runner used for Docker commands. + * + * @return DockerComposeOptions + * Returns options with resolved workdir applied. + */ + private function resolveDockerOptions(DockerComposerConfig $config, string $projectRoot, ProcessRunner $runner): DockerComposeOptions + { + $resolution = $this->workdirResolver->resolve($config, $projectRoot, $runner, $this->getDockerRunner($runner)); + + return new DockerComposeResolvedOptions($config, $resolution->getWorkdir()); + } + + /** + * Gets the active Composer project root. + * + * @param Composer|null $composer + * The active Composer instance, or `null`. * * @return string - * Returns a stable serialized key for the service startup command. + * Returns the host project root, falling back to current directory. */ - private function getExecServiceStartupKey(DockerComposerConfig $config): string + private function getComposerProjectRoot(?Composer $composer): string { - return serialize([ - $config->getService(), - $config->getComposeFiles(), - $config->getProjectDirectory(), - ]); + if ($composer !== null) { + $config = $composer->getConfig(); + $reflection = new \ReflectionObject($config); + if ($reflection->hasProperty('baseDir')) { + $property = $reflection->getProperty('baseDir'); + $baseDir = $property->getValue($config); + if (is_string($baseDir) && $baseDir !== '') { + return $baseDir; + } + } + } + + $cwd = getcwd(); + + return $cwd !== false ? $cwd : '.'; } /** @@ -716,6 +675,53 @@ private function throwScriptExecutionException(ProcessRunner $runner, int $exitC throw new ScriptExecutionException($message, $exitCode); } + /** + * Runs a Docker Compose command and throws when it fails. + * + * @param ProcessRunner $runner + * The runner used for Docker Compose commands. + * + * @param DockerComposeOptions $config + * The Docker Compose options that identify the target service. + * + * @param list $command + * The full Docker Compose command to execute. + * + * @param bool $interactive + * Whether TTY passthrough should be requested. + * + * @return void + * Returns nothing. + * + * @throws ScriptExecutionException + * Thrown when Docker Compose startup or execution fails. + */ + private function runDockerCommand(ProcessRunner $runner, DockerComposeOptions $config, array $command, bool $interactive): void + { + $result = $this->getDockerRunner($runner)->run($config, $command, $interactive); + if (! $result->isSuccessful()) { + $this->throwScriptExecutionException($runner, $result->getExitCode(), $result->getPhase(), $result->getCommand()); + } + } + + /** + * Gets the Docker Compose runner for the active process runner. + * + * @param ProcessRunner $runner + * The process runner used for Docker Compose commands. + * + * @return DockerComposeRunner + * Returns the shared Docker Compose runner. + */ + private function getDockerRunner(ProcessRunner $runner): DockerComposeRunner + { + if ($this->dockerRunner === null) { + $this->dockerRunner = new DockerComposeRunner($runner, $this->commandBuilder); + } + + return $this->dockerRunner; + } + /** * Formats command arguments for shell output. * diff --git a/src/Laravel/Config.php b/src/Laravel/Config.php new file mode 100644 index 0000000..8a677c7 --- /dev/null +++ b/src/Laravel/Config.php @@ -0,0 +1,563 @@ + + * @license MIT + * @package DockerComposer + */ + +declare(strict_types=1); + +namespace empaphy\docker_composer\Laravel; + +use empaphy\docker_composer\DockerComposeOptions; +use InvalidArgumentException; +use LogicException; + +/** + * Parses and exposes Laravel console Docker redirection configuration. + */ +final class Config implements DockerComposeOptions +{ + /** + * Lists supported configuration keys. + * + * @var list + */ + private const KNOWN_KEYS = [ + 'enabled', + 'service', + 'mode', + 'compose_files', + 'project_directory', + 'workdir', + 'exclude', + 'service_mapping', + ]; + + /** + * Creates immutable Laravel Docker configuration. + * + * @param bool $enabled + * Whether Laravel console redirection is enabled. + * + * @param string|null $service + * The default Docker Compose service, or `null` when missing. + * + * @param array $servicesByEntry + * Docker Compose services keyed by Laravel entry identifier. + * + * @param string $mode + * The Docker Compose mode, either `"exec"` or `"run"`. + * + * @param list $composeFiles + * The Docker Compose files passed with `--file`. + * + * @param string|null $projectDirectory + * The Docker Compose project directory, or `null` for default. + * + * @param string|null $workdir + * The service working directory, or `null` for service default. + * + * @param list $exclude + * The Laravel entries that should run on the host. + */ + private function __construct( + private readonly bool $enabled, + private readonly ?string $service, + private readonly array $servicesByEntry, + private readonly string $mode, + private readonly array $composeFiles, + private readonly ?string $projectDirectory, + private readonly ?string $workdir, + private readonly array $exclude, + ) {} + + /** + * Creates configuration from a Laravel config array. + * + * @param array $raw + * The raw `docker_composer` config array. + * + * @return self + * Returns parsed Laravel Docker configuration. + * + * @throws InvalidArgumentException + * Thrown when the config array has an invalid shape or value. + */ + public static function fromArray(array $raw): self + { + $raw = self::object($raw); + $unknownKeys = array_values(array_diff(array_keys($raw), self::KNOWN_KEYS)); + if ($unknownKeys !== []) { + throw new InvalidArgumentException(sprintf('docker_composer contains unknown key "%s".', $unknownKeys[0])); + } + + return new self( + self::enabled($raw), + self::optionalString($raw, 'service'), + self::serviceMapping($raw), + self::mode($raw), + self::composeFiles($raw), + self::optionalString($raw, 'project_directory'), + self::optionalString($raw, 'workdir'), + self::expandClassEntries(self::stringList($raw, 'exclude')), + ); + } + + /** + * Checks whether Laravel console redirection is enabled. + * + * @return bool + * Returns `true` when redirection should be considered. + */ + public function isEnabled(): bool + { + return $this->enabled; + } + + /** + * Gets the configured Docker Compose service name. + * + * @return string + * Returns the non-empty service name. + * + * @throws LogicException + * Thrown when the service is requested before configuration exists. + */ + public function getService(): string + { + if ($this->service === null) { + throw new LogicException('Docker Compose service is not configured.'); + } + + return $this->service; + } + + /** + * Gets the configured Docker Compose mode. + * + * @return string + * Returns `"exec"` or `"run"`. + */ + public function getMode(): string + { + return $this->mode; + } + + /** + * Gets configured Docker Compose file paths. + * + * @return list + * Returns paths passed to Docker Compose with `--file`. + */ + public function getComposeFiles(): array + { + return $this->composeFiles; + } + + /** + * Gets the configured Docker Compose project directory. + * + * @return string|null + * Returns the directory path, or `null` for Docker Compose defaults. + */ + public function getProjectDirectory(): ?string + { + return $this->projectDirectory; + } + + /** + * Gets the configured service working directory. + * + * @return string|null + * Returns the service working directory, or `null` for service default. + */ + public function getWorkdir(): ?string + { + return $this->workdir; + } + + /** + * Checks whether any console entry name is excluded. + * + * @param ConsoleEntry $entry + * The Laravel console entry to inspect. + * + * @return bool + * Returns `true` when one entry name is excluded. + */ + public function excludes(ConsoleEntry $entry): bool + { + foreach ($entry->getNames() as $name) { + if (in_array($name, $this->exclude, true)) { + return true; + } + } + + return false; + } + + /** + * Creates a copy configured for the matched console entry service. + * + * @param ConsoleEntry $entry + * The Laravel console entry to match. + * + * @return self|null + * Returns service-specific config, or `null` when no service applies. + */ + public function forEntry(ConsoleEntry $entry): ?self + { + foreach ($entry->getNames() as $name) { + if (array_key_exists($name, $this->servicesByEntry)) { + return $this->withService($this->servicesByEntry[$name]); + } + } + + if ($this->service === null) { + return null; + } + + return $this; + } + + /** + * Creates a copy using a specific Docker Compose service. + * + * @param string $service + * The Docker Compose service to use. + * + * @return self + * Returns a copy with __service__ set as effective service. + */ + private function withService(string $service): self + { + return new self($this->enabled, $service, $this->servicesByEntry, $this->mode, $this->composeFiles, $this->projectDirectory, $this->workdir, $this->exclude); + } + + /** + * Normalizes a decoded Laravel config object. + * + * @param array $raw + * The raw config value. + * + * @return array + * Returns __raw__ with verified `string` keys. + * + * @throws InvalidArgumentException + * Thrown when __raw__ contains non-`string` keys. + */ + private static function object(array $raw): array + { + $normalized = []; + foreach ($raw as $key => $value) { + if (! is_string($key)) { + throw new InvalidArgumentException('docker_composer must be an array with string keys.'); + } + + $normalized[$key] = $value; + } + + return $normalized; + } + + /** + * Reads the enabled flag. + * + * @param array $raw + * The normalized configuration array. + * + * @return bool + * Returns `false` when omitted, otherwise the configured boolean. + * + * @throws InvalidArgumentException + * Thrown when `enabled` is not boolean-like. + */ + private static function enabled(array $raw): bool + { + if (! array_key_exists('enabled', $raw)) { + return false; + } + + if (is_bool($raw['enabled'])) { + return $raw['enabled']; + } + + if (is_int($raw['enabled'])) { + if (! in_array($raw['enabled'], [0, 1], true)) { + throw new InvalidArgumentException('docker_composer.enabled must be a boolean.'); + } + + return $raw['enabled'] === 1; + } + + if (is_string($raw['enabled'])) { + $value = filter_var($raw['enabled'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($value !== null) { + return $value; + } + } + + throw new InvalidArgumentException('docker_composer.enabled must be a boolean.'); + } + + /** + * Reads an optional non-empty `string` value. + * + * @param array $raw + * The normalized configuration array. + * + * @param string $key + * The configuration key to read. + * + * @return string|null + * Returns the configured `string`, or `null` when omitted. + * + * @throws InvalidArgumentException + * Thrown when __key__ exists but is not a non-empty `string`. + */ + private static function optionalString(array $raw, string $key): ?string + { + if (! array_key_exists($key, $raw) || $raw[$key] === null) { + return null; + } + + if (! is_string($raw[$key]) || $raw[$key] === '') { + throw new InvalidArgumentException(sprintf('docker_composer.%s must be a non-empty string.', $key)); + } + + return $raw[$key]; + } + + /** + * Reads the configured Docker Compose mode. + * + * @param array $raw + * The normalized configuration array. + * + * @return string + * Returns `"exec"` when omitted, otherwise `"exec"` or `"run"`. + * + * @throws InvalidArgumentException + * Thrown when `mode` is not an accepted `string` value. + */ + private static function mode(array $raw): string + { + if (! array_key_exists('mode', $raw)) { + return DockerComposeOptions::MODE_EXEC; + } + + if (! is_string($raw['mode']) || ! in_array($raw['mode'], [DockerComposeOptions::MODE_EXEC, DockerComposeOptions::MODE_RUN], true)) { + throw new InvalidArgumentException('docker_composer.mode must be "exec" or "run".'); + } + + return $raw['mode']; + } + + /** + * Reads Docker Compose file settings. + * + * @param array $raw + * The normalized configuration array. + * + * @return list + * Returns file paths configured by `compose_files`. + * + * @throws InvalidArgumentException + * Thrown when `compose_files` is not a non-empty `string` or list. + */ + private static function composeFiles(array $raw): array + { + if (! array_key_exists('compose_files', $raw) || $raw['compose_files'] === null) { + return []; + } + + if (is_string($raw['compose_files'])) { + if ($raw['compose_files'] === '') { + throw new InvalidArgumentException('docker_composer.compose_files must contain non-empty strings.'); + } + + return [$raw['compose_files']]; + } + + return self::stringList($raw, 'compose_files'); + } + + /** + * Reads a list of non-empty `string` values. + * + * @param array $raw + * The normalized configuration array. + * + * @param string $key + * The configuration key to read. + * + * @return list + * Returns the configured list, or an empty list when omitted. + * + * @throws InvalidArgumentException + * Thrown when __key__ is not a list of non-empty `string` values. + */ + private static function stringList(array $raw, string $key): array + { + if (! array_key_exists($key, $raw) || $raw[$key] === null) { + return []; + } + + if (! is_array($raw[$key]) || ! array_is_list($raw[$key])) { + throw new InvalidArgumentException(sprintf('docker_composer.%s must be a list of strings.', $key)); + } + + $values = []; + foreach ($raw[$key] as $value) { + if (! is_string($value) || $value === '') { + throw new InvalidArgumentException(sprintf('docker_composer.%s must contain only non-empty strings.', $key)); + } + + $values[] = $value; + } + + return $values; + } + + /** + * Reads service mapping settings as Laravel entry service overrides. + * + * @param array $raw + * The normalized configuration array. + * + * @return array + * Returns Docker Compose services keyed by Laravel entry identifier. + * + * @throws InvalidArgumentException + * Thrown when `service_mapping` has an invalid shape. + */ + private static function serviceMapping(array $raw): array + { + $key = 'service_mapping'; + if (! array_key_exists($key, $raw) || $raw[$key] === null) { + return []; + } + + if ($raw[$key] === []) { + return []; + } + + if (! is_array($raw[$key]) || array_is_list($raw[$key])) { + throw new InvalidArgumentException(sprintf('docker_composer.%s must be an object of strings or lists of strings.', $key)); + } + + $values = []; + foreach ($raw[$key] as $service => $entries) { + if (! is_string($service) || $service === '') { + throw new InvalidArgumentException(sprintf('docker_composer.%s must use non-empty string keys.', $key)); + } + + if (is_string($entries)) { + $entries = [$entries]; + } + + if (! is_array($entries) || ! array_is_list($entries) || $entries === []) { + throw new InvalidArgumentException(sprintf('docker_composer.%s must contain only non-empty strings or lists of non-empty strings.', $key)); + } + + foreach ($entries as $entry) { + if (! is_string($entry) || $entry === '') { + throw new InvalidArgumentException(sprintf('docker_composer.%s must contain only non-empty strings or lists of non-empty strings.', $key)); + } + + self::addServiceMapping($values, $entry, $service, $key); + foreach (self::commandNamesForClass($entry) as $commandName) { + self::addServiceMapping($values, $commandName, $service, $key); + } + } + } + + return $values; + } + + /** + * Adds a service mapping entry. + * + * @param array $values + * The service mappings accumulated so far. + * + * @param string $entry + * The entry identifier to map. + * + * @param string $service + * The Docker Compose service to use. + * + * @param string $key + * The source config key used in validation messages. + * + * @return void + * Returns nothing. + * + * @throws InvalidArgumentException + * Thrown when __entry__ already maps to a different service. + */ + private static function addServiceMapping(array &$values, string $entry, string $service, string $key): void + { + if (array_key_exists($entry, $values) && $values[$entry] !== $service) { + throw new InvalidArgumentException(sprintf('docker_composer.%s must not assign an entry to multiple services.', $key)); + } + + $values[$entry] = $service; + } + + /** + * Reads command names declared by a Laravel command class. + * + * @param string $class + * The possible command class name. + * + * @return list + * Returns command names declared through `$signature` or `$name`. + */ + private static function commandNamesForClass(string $class): array + { + if (! class_exists($class)) { + return []; + } + + $defaults = (new \ReflectionClass($class))->getDefaultProperties(); + $names = []; + $signature = $defaults['signature'] ?? null; + if (is_string($signature) && trim($signature) !== '') { + $names[] = strtok(trim($signature), " \t\r\n") ?: ''; + } + + $name = $defaults['name'] ?? null; + if (is_string($name) && $name !== '') { + $names[] = $name; + } + + return array_values(array_filter(array_unique($names), static fn(string $name): bool => $name !== '')); + } + + /** + * Adds command names declared by class-string entries. + * + * @param list $entries + * The configured entry identifiers. + * + * @return list + * Returns entries plus command names derived from command classes. + */ + private static function expandClassEntries(array $entries): array + { + $expanded = $entries; + foreach ($entries as $entry) { + foreach (self::commandNamesForClass($entry) as $commandName) { + $expanded[] = $commandName; + } + } + + return array_values(array_unique($expanded)); + } +} diff --git a/src/Laravel/ConsoleEntry.php b/src/Laravel/ConsoleEntry.php new file mode 100644 index 0000000..41f0599 --- /dev/null +++ b/src/Laravel/ConsoleEntry.php @@ -0,0 +1,145 @@ + + * @license MIT + * @package DockerComposer + */ + +declare(strict_types=1); + +namespace empaphy\docker_composer\Laravel; + +/** + * Carries Laravel console entry identifiers and replay arguments. + */ +final class ConsoleEntry +{ + /** + * Stores entry identifiers used for service mapping and exclusion. + * + * @var list + */ + private array $names; + + /** + * Stores raw CLI arguments to replay in Docker. + * + * @var list + */ + private array $arguments; + + /** + * Creates a Laravel console entry. + * + * @param list $names + * The entry identifiers that can match configuration entries. + * + * @param list $arguments + * The raw CLI arguments to replay inside Docker. + */ + private function __construct(array $names, array $arguments) + { + $this->names = array_values(array_unique($names)); + $this->arguments = $arguments; + } + + /** + * Creates context for an Artisan command. + * + * @param string|null $commandName + * The Artisan command name, or `null` when unavailable. + * + * @param class-string|null $commandClass + * The Artisan command class, or `null` when unavailable. + * + * @param list $arguments + * The raw CLI arguments to replay inside Docker. + * + * @return self + * Returns context for Artisan command matching. + */ + public static function artisan(?string $commandName, ?string $commandClass, array $arguments): self + { + $names = []; + if ($commandName !== null && $commandName !== '') { + $names[] = $commandName; + } + + if ($commandClass !== null && $commandClass !== '') { + $names[] = $commandClass; + } + + return new self($names, $arguments); + } + + /** + * Creates context for a custom Laravel bootstrap script. + * + * @param string $scriptName + * The script identifier, such as `":scripts/task.php"`. + * + * @param list $arguments + * The raw CLI arguments to replay inside Docker. + * + * @return self + * Returns context for script matching. + */ + public static function script(string $scriptName, array $arguments): self + { + return new self([$scriptName], $arguments); + } + + /** + * Creates a script identifier for a CLI entrypoint. + * + * @param string $entrypoint + * The first CLI argument. + * + * @param string $projectRoot + * The absolute Laravel project root. + * + * @return string + * Returns the script identifier prefixed with `:`. + */ + public static function scriptName(string $entrypoint, string $projectRoot): string + { + $entrypoint = str_replace('\\', '/', $entrypoint); + $projectRoot = rtrim(str_replace('\\', '/', $projectRoot), '/'); + + if ($entrypoint === $projectRoot) { + return ':'; + } + + if (str_starts_with($entrypoint, $projectRoot . '/')) { + return ':' . ltrim(substr($entrypoint, strlen($projectRoot)), '/'); + } + + return ':' . ltrim($entrypoint, '/'); + } + + /** + * Gets entry identifiers used for matching. + * + * @return list + * Returns command names, command classes, or script identifiers. + */ + public function getNames(): array + { + return $this->names; + } + + /** + * Gets raw CLI arguments. + * + * @return list + * Returns arguments to replay inside Docker. + */ + public function getArguments(): array + { + return $this->arguments; + } +} diff --git a/src/Laravel/Redirector.php b/src/Laravel/Redirector.php new file mode 100644 index 0000000..ab810da --- /dev/null +++ b/src/Laravel/Redirector.php @@ -0,0 +1,143 @@ + + * @license MIT + * @package DockerComposer + */ + +declare(strict_types=1); + +namespace empaphy\docker_composer\Laravel; + +use empaphy\docker_composer\ContainerDetector; +use empaphy\docker_composer\DockerComposeCommandBuilder; +use empaphy\docker_composer\DockerComposeResolvedOptions; +use empaphy\docker_composer\DockerComposeRunner; +use empaphy\docker_composer\DockerComposeWorkdirResolver; +use empaphy\docker_composer\ProcessRunner; + +/** + * Redirects Laravel console entries into Docker Compose. + */ +final class Redirector +{ + /** + * Resolves container workdir and project path mapping. + */ + private DockerComposeWorkdirResolver $workdirResolver; + + /** + * Creates a Laravel console redirector. + * + * @param DockerComposeRunner $dockerRunner + * The shared Docker Compose runner. + * + * @param DockerComposeCommandBuilder $commandBuilder + * The shared Docker Compose command builder. + * + * @param ContainerDetector $containerDetector + * The detector for existing container execution. + * + * @param ProcessRunner|null $processRunner + * The process runner used for workdir discovery, or `null`. + * + * @param DockerComposeWorkdirResolver|null $workdirResolver + * The workdir resolver, or `null` for the default resolver. + */ + public function __construct( + private readonly DockerComposeRunner $dockerRunner, + private readonly DockerComposeCommandBuilder $commandBuilder, + private readonly ContainerDetector $containerDetector, + private readonly ?ProcessRunner $processRunner = null, + ?DockerComposeWorkdirResolver $workdirResolver = null, + ) { + $this->workdirResolver = $workdirResolver ?? new DockerComposeWorkdirResolver($this->commandBuilder); + } + + /** + * Redirects a Laravel console entry into Docker Compose when configured. + * + * @param Config $config + * The Laravel Docker configuration. + * + * @param ConsoleEntry $entry + * The Laravel console entry being redirected. + * + * @param string $projectRoot + * The absolute Laravel project root on the host. + * + * @param bool $interactive + * Whether interactive Docker execution is allowed. + * + * @return int|null + * Returns Docker exit code when redirected, or `null` for host execution. + */ + public function redirect(Config $config, ConsoleEntry $entry, string $projectRoot, bool $interactive): ?int + { + if (! $config->isEnabled() || $this->isDisabledByEnvironment() || $this->containerDetector->isInsideContainer() || $config->excludes($entry)) { + return null; + } + + $effectiveConfig = $config->forEntry($entry); + if ($effectiveConfig === null) { + return null; + } + + $resolution = $this->workdirResolver->resolve($effectiveConfig, $projectRoot, $this->processRunner, $this->dockerRunner); + $effectiveOptions = new DockerComposeResolvedOptions($effectiveConfig, $resolution->getWorkdir()); + $arguments = $entry->getArguments(); + if ($resolution->hasPathMapping() && $resolution->getContainerProjectRoot() !== null) { + $arguments = $this->absolutizeEntrypoint($arguments, $projectRoot); + $arguments = $this->commandBuilder->translateProjectPaths($arguments, $projectRoot, $resolution->getContainerProjectRoot()); + } + + $command = $this->commandBuilder->buildProcessCommand($effectiveOptions, $arguments, $interactive); + $result = $this->dockerRunner->run($effectiveOptions, $command, $interactive); + + return $result->getExitCode(); + } + + /** + * Converts a project-relative PHP entrypoint to an absolute host path. + * + * @param list $arguments + * The raw CLI arguments. + * + * @param string $projectRoot + * The absolute Laravel project root on the host. + * + * @return list + * Returns arguments with an absolute first entrypoint when possible. + */ + private function absolutizeEntrypoint(array $arguments, string $projectRoot): array + { + $entrypoint = $arguments[0] ?? null; + if ($entrypoint === null || str_starts_with($entrypoint, '/') || preg_match('/^[A-Za-z]:[\/\\\\]/', $entrypoint) === 1) { + return $arguments; + } + + $candidate = rtrim($projectRoot, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $entrypoint; + if (is_file($candidate)) { + $arguments[0] = $candidate; + } + + return $arguments; + } + + /** + * Checks whether redirection is disabled by environment variable. + * + * @return bool + * Returns `true` when `DOCKER_COMPOSER_DISABLE` is truthy. + */ + private function isDisabledByEnvironment(): bool + { + $value = getenv('DOCKER_COMPOSER_DISABLE'); + + return $value !== false && $value !== '' && $value !== '0'; + } +} diff --git a/src/Laravel/ServiceProvider.php b/src/Laravel/ServiceProvider.php new file mode 100644 index 0000000..4a4e5a3 --- /dev/null +++ b/src/Laravel/ServiceProvider.php @@ -0,0 +1,307 @@ + + * @license MIT + * @package DockerComposer + */ + +declare(strict_types=1); + +namespace empaphy\docker_composer\Laravel; + +use empaphy\docker_composer\DockerComposeCommandBuilder; +use empaphy\docker_composer\DockerComposeRunner; +use empaphy\docker_composer\EnvironmentContainerDetector; +use empaphy\docker_composer\ShellProcessRunner; +use Illuminate\Contracts\Console\Kernel; +use Illuminate\Support\ServiceProvider as IlluminateServiceProvider; +use Throwable; + +/** + * Registers Laravel console Docker redirection through package autodiscovery. + */ +final class ServiceProvider extends IlluminateServiceProvider +{ + /** + * Registers package configuration defaults. + * + * @return void + * Returns nothing. + */ + public function register(): void + { + $this->mergeConfigFrom(dirname(__DIR__, 2) . '/config/docker_composer.php', 'docker_composer'); + } + + /** + * Boots Laravel Docker redirection for console execution. + * + * @return void + * Returns nothing. + */ + public function boot(): void + { + $this->publishes([ + dirname(__DIR__, 2) . '/config/docker_composer.php' => $this->app->configPath('docker_composer.php'), + ], 'docker-composer-config'); + + if (! $this->app->runningInConsole()) { + return; + } + + $arguments = $this->getServerArguments(); + if ($arguments === []) { + return; + } + + $projectRoot = $this->app->basePath(); + $config = Config::fromArray($this->getConfig()); + $redirector = $this->createRedirector(); + if ($this->isArtisan($arguments[0])) { + $this->listenForArtisanCommands($config, $redirector, $arguments, $projectRoot); + + return; + } + + $this->exitIfRedirected($redirector->redirect( + $config, + ConsoleEntry::script(ConsoleEntry::scriptName($arguments[0], $projectRoot), $arguments), + $projectRoot, + $this->isInteractive(), + )); + } + + /** + * Gets package configuration from Laravel. + * + * @return array + * Returns the `docker_composer` config array. + */ + private function getConfig(): array + { + $config = $this->app->make('config')->get('docker_composer', []); + + return is_array($config) ? $config : []; + } + + /** + * Creates the Laravel console redirector. + * + * @return Redirector + * Returns the configured redirector. + */ + private function createRedirector(): Redirector + { + $processRunner = new ShellProcessRunner(); + $commandBuilder = new DockerComposeCommandBuilder(); + + return new Redirector( + new DockerComposeRunner($processRunner, $commandBuilder), + $commandBuilder, + new EnvironmentContainerDetector(), + $processRunner, + ); + } + + /** + * Registers the command-starting listener for Artisan commands. + * + * @param Config $config + * The Laravel Docker configuration. + * + * @param Redirector $redirector + * The redirector used to execute Docker commands. + * + * @param list $arguments + * The raw CLI arguments to replay in Docker. + * + * @param string $projectRoot + * The absolute Laravel project root on the host. + * + * @return void + * Returns nothing. + */ + private function listenForArtisanCommands(Config $config, Redirector $redirector, array $arguments, string $projectRoot): void + { + $artisanClass = 'Illuminate\Console\Application'; + if (is_callable([$artisanClass, 'starting'])) { + call_user_func([$artisanClass, 'starting'], function (object $artisan) use ($config, $redirector, $arguments, $projectRoot): void { + $commandName = $this->getCommandNameFromArguments($arguments); + $this->exitIfRedirected($redirector->redirect( + $config, + ConsoleEntry::artisan($commandName, null, $arguments), + $projectRoot, + $this->isInteractive(), + )); + }); + } + + $events = $this->app->make('events'); + if (! is_object($events) || ! method_exists($events, 'listen')) { + return; + } + + $events->listen('Illuminate\Console\Events\CommandStarting', function (object $event) use ($config, $redirector, $arguments, $projectRoot): void { + $commandName = $this->getEventCommandName($event); + $this->exitIfRedirected($redirector->redirect( + $config, + ConsoleEntry::artisan($commandName, $this->resolveArtisanCommandClass($commandName), $arguments), + $projectRoot, + $this->isInteractive(), + )); + }); + } + + /** + * Gets the Artisan command name from raw CLI arguments. + * + * @param list $arguments + * The raw CLI arguments. + * + * @return string|null + * Returns the command name, or `null` when unavailable. + */ + private function getCommandNameFromArguments(array $arguments): ?string + { + foreach (array_slice($arguments, 1) as $argument) { + if ($argument === '--') { + return null; + } + + if ($argument !== '' && ! str_starts_with($argument, '-')) { + return $argument; + } + } + + return null; + } + + /** + * Gets the Artisan command name from a Laravel command event. + * + * @param object $event + * The `Illuminate\Console\Events\CommandStarting` event. + * + * @return string|null + * Returns the command name, or `null` when unavailable. + */ + private function getEventCommandName(object $event): ?string + { + $command = $event->command ?? null; + + return is_string($command) && $command !== '' ? $command : null; + } + + /** + * Resolves an Artisan command name to its command class. + * + * @param string|null $commandName + * The Artisan command name, or `null` when unavailable. + * + * @return class-string|null + * Returns the command class, or `null` when resolution fails. + */ + private function resolveArtisanCommandClass(?string $commandName): ?string + { + if ($commandName === null) { + return null; + } + + try { + $kernel = $this->app->make(Kernel::class); + if (! is_object($kernel)) { + return null; + } + + foreach ($kernel->all() as $name => $command) { + if ($name === $commandName && is_object($command)) { + return $command::class; + } + } + + if (! method_exists($kernel, 'getArtisan')) { + return null; + } + + $artisan = $kernel->getArtisan(); + if (! is_object($artisan) || ! method_exists($artisan, 'find')) { + return null; + } + + $command = $artisan->find($commandName); + + return is_object($command) ? $command::class : null; + } catch (Throwable) { + return null; + } + } + + /** + * Gets raw CLI arguments from `$_SERVER`. + * + * @return list + * Returns `argv` as a list of strings, or an empty list when unavailable. + */ + private function getServerArguments(): array + { + if (! isset($_SERVER['argv']) || ! is_array($_SERVER['argv']) || ! array_is_list($_SERVER['argv'])) { + return []; + } + + $arguments = []; + foreach ($_SERVER['argv'] as $argument) { + if (! is_string($argument)) { + return []; + } + + $arguments[] = $argument; + } + + return $arguments; + } + + /** + * Checks whether the entrypoint is Laravel's Artisan file. + * + * @param string $entrypoint + * The first CLI argument. + * + * @return bool + * Returns `true` when __entrypoint__ names `artisan`. + */ + private function isArtisan(string $entrypoint): bool + { + return basename(str_replace('\\', '/', $entrypoint)) === 'artisan'; + } + + /** + * Checks whether the current process can run interactively. + * + * @return bool + * Returns `true` when STDIN is a terminal. + */ + private function isInteractive(): bool + { + return defined('STDIN') && function_exists('stream_isatty') && stream_isatty(STDIN); + } + + /** + * Exits the host process if a Docker redirect occurred. + * + * @param int|null $exitCode + * The Docker exit code, or `null` when no redirection happened. + * + * @return void + * Returns nothing. + */ + private function exitIfRedirected(?int $exitCode): void + { + if ($exitCode !== null) { + exit($exitCode); + } + } +} diff --git a/src/ShellProcessRunner.php b/src/ShellProcessRunner.php new file mode 100644 index 0000000..683b2f3 --- /dev/null +++ b/src/ShellProcessRunner.php @@ -0,0 +1,132 @@ + + * @license MIT + * @package DockerComposer + */ + +declare(strict_types=1); + +namespace empaphy\docker_composer; + +/** + * Runs commands through PHP process primitives without Composer IO. + */ +final class ShellProcessRunner implements OutputCapturingProcessRunner +{ + /** + * Stores stderr captured from the last command. + */ + private string $errorOutput = ''; + + /** + * Runs a command and returns its process status. + * + * @param list $command + * The command arguments to execute. + * + * @param bool $tty + * Whether to request TTY passthrough. + * + * @return int + * Returns the command exit code. + */ + public function run(array $command, bool $tty = false): int + { + $output = ''; + + return $this->runProcess($command, false, $output); + } + + /** + * Runs a command while capturing standard output. + * + * @param list $command + * The command arguments to execute. + * + * @param string $output + * The captured standard output. + * + * @return int + * Returns the process exit code. + */ + public function runWithOutput(array $command, string &$output): int + { + return $this->runProcess($command, true, $output); + } + + /** + * Gets stderr captured from the last command. + * + * @return string + * Returns the last process error output. + */ + public function getErrorOutput(): string + { + return $this->errorOutput; + } + + /** + * Checks whether TTY passthrough is available. + * + * @return bool + * Returns `false`; shell execution uses inherited streams. + */ + public function supportsTty(): bool + { + return false; + } + + /** + * Runs a command with optional stdout capture. + * + * @param list $command + * The command arguments to execute. + * + * @param bool $captureOutput + * Whether standard output should be captured instead of inherited. + * + * @param string $output + * The captured standard output. + * + * @return int + * Returns the process exit code. + */ + private function runProcess(array $command, bool $captureOutput, string &$output): int + { + $this->errorOutput = ''; + $output = ''; + $descriptors = [ + 0 => defined('STDIN') ? STDIN : ['file', 'php://stdin', 'r'], + 1 => $captureOutput ? ['pipe', 'w'] : (defined('STDOUT') ? STDOUT : ['file', 'php://stdout', 'w']), + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptors, $pipes); + if (! is_resource($process)) { + $this->errorOutput = 'Unable to start process.'; + + return 1; + } + + if ($captureOutput && isset($pipes[1]) && is_resource($pipes[1])) { + $output = stream_get_contents($pipes[1]) ?: ''; + fclose($pipes[1]); + } + + if (isset($pipes[2]) && is_resource($pipes[2])) { + $this->errorOutput = stream_get_contents($pipes[2]) ?: ''; + if ($this->errorOutput !== '' && defined('STDERR')) { + fwrite(STDERR, $this->errorOutput); + } + + fclose($pipes[2]); + } + + return proc_close($process); + } +} diff --git a/tests/Integration/DockerComposerIntegrationTest.php b/tests/Integration/DockerComposerIntegrationTest.php index 59b5ef1..701d48f 100644 --- a/tests/Integration/DockerComposerIntegrationTest.php +++ b/tests/Integration/DockerComposerIntegrationTest.php @@ -37,7 +37,6 @@ public function testExecModeRedirectsCustomAndLifecycleScriptsWithAutoUp(): void 'service' => 'php', 'mode' => 'exec', 'compose-files' => 'docker-compose.yaml', - 'workdir' => '/usr/src/app', ]); $this->installProject($projectDirectory); @@ -124,6 +123,37 @@ public function testRunModeBypassMissingConfigAndInsideContainerBehavior(): void self::assertSame('host', trim((string) file_get_contents($missingConfigProjectDirectory . '/result.txt'))); } + public function testLaravelAutodiscoveryAndConsoleRedirection(): void + { + $projectDirectory = $this->createLaravelProject(); + $this->installProject($projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => '0']); + + $packages = require $projectDirectory . '/bootstrap/cache/packages.php'; + self::assertContains('empaphy\\docker_composer\\Laravel\\ServiceProvider', $packages['empaphy/docker-composer']['providers'] ?? []); + + $this->runCommand(['php', 'artisan', 'vendor:publish', '--tag=docker-composer-config', '--force'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => '0']); + self::assertFileExists($projectDirectory . '/config/docker_composer.php'); + $this->writeLaravelDockerComposerConfig($projectDirectory); + + $this->runCommand(['docker', 'compose', 'down', '--volumes', '--remove-orphans'], $projectDirectory); + + $result = $this->runCommand(['php', 'artisan', 'mark'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => 'true']); + self::assertSame('1', trim((string) file_get_contents($projectDirectory . '/result.txt')), $result['stdout'] . $result['stderr']); + + $this->runCommand(['php', 'artisan', 'class-map'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => 'true']); + self::assertSame('mapped', trim((string) file_get_contents($projectDirectory . '/class.txt'))); + + $this->runCommand(['php', 'scripts/bootstrap.php'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => 'true']); + self::assertSame('mapped', trim((string) file_get_contents($projectDirectory . '/script.txt'))); + + $this->runCommand(['php', 'artisan', 'host-only'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => 'true']); + self::assertSame('host', trim((string) file_get_contents($projectDirectory . '/host.txt'))); + + @unlink($projectDirectory . '/result.txt'); + $this->runCommand(['php', 'artisan', 'mark'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => '0']); + self::assertSame('host', trim((string) file_get_contents($projectDirectory . '/result.txt'))); + } + /** * @param array $dockerComposerConfig * @param list>|null $repositories @@ -241,9 +271,299 @@ protected function getRequireCommand(string $package): array return $command; } - private function installProject(string $projectDirectory): void + /** + * @param array $environment + */ + private function installProject(string $projectDirectory, array $environment = []): void + { + $this->runCommand(['composer', 'install', '--no-interaction', '--no-progress', '--prefer-dist'], $projectDirectory, $environment); + } + + private function createLaravelProject(): string + { + $projectDirectory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . 'docker-composer-laravel-integration-' + . bin2hex(random_bytes(8)); + if (! mkdir($projectDirectory, 0777, true) && ! is_dir($projectDirectory)) { + throw new \RuntimeException(sprintf('Unable to create integration project directory "%s".', $projectDirectory)); + } + + $this->projectDirectories[] = $projectDirectory; + foreach ([ + 'app/Console/Commands', + 'app/Exceptions', + 'app/Console', + 'bootstrap/cache', + 'config', + 'scripts', + ] as $directory) { + $path = $projectDirectory . '/' . $directory; + if (! is_dir($path) && ! mkdir($path, 0777, true) && ! is_dir($path)) { + throw new \RuntimeException(sprintf('Unable to create directory "%s".', $path)); + } + } + + $this->writeJson($projectDirectory . '/composer.json', [ + 'name' => 'empaphy/docker-composer-laravel-integration', + 'description' => 'Temporary docker-composer Laravel integration fixture.', + 'minimum-stability' => 'dev', + 'prefer-stable' => true, + 'repositories' => [[ + 'type' => 'path', + 'url' => dirname(__DIR__, 2), + 'options' => ['symlink' => false], + ]], + 'require' => [ + 'laravel/framework' => '^12.0', + 'empaphy/docker-composer' => '*', + ], + 'autoload' => [ + 'psr-4' => [ + 'App\\' => 'app/', + ], + ], + 'config' => [ + 'allow-plugins' => [ + 'empaphy/docker-composer' => true, + ], + ], + 'scripts' => [ + 'post-autoload-dump' => [ + 'Illuminate\\Foundation\\ComposerScripts::postAutoloadDump', + '@php artisan package:discover --ansi', + ], + ], + ]); + + file_put_contents($projectDirectory . '/artisan', <<<'PHP' +#!/usr/bin/env php +make(Kernel::class); +$input = new ArgvInput(); +$status = $kernel->handle($input, new ConsoleOutput()); +$kernel->terminate($input, $status); + +exit($status); +PHP); + chmod($projectDirectory . '/artisan', 0755); + + file_put_contents($projectDirectory . '/bootstrap/app.php', <<<'PHP' +singleton(KernelContract::class, Kernel::class); +$app->singleton(ExceptionHandler::class, Handler::class); + +return $app; +PHP); + + file_put_contents($projectDirectory . '/config/app.php', <<<'PHP' + 'Docker Composer Test', + 'env' => 'testing', + 'debug' => true, + 'url' => 'http://localhost', + 'timezone' => 'UTC', + 'locale' => 'en', + 'fallback_locale' => 'en', + 'faker_locale' => 'en_US', + 'key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + 'cipher' => 'AES-256-CBC', + 'providers' => ServiceProvider::defaultProviders()->toArray(), +]; +PHP); + + file_put_contents($projectDirectory . '/app/Console/Kernel.php', <<<'PHP' + + */ + protected $commands = [ + MarkCommand::class, + ClassMappedCommand::class, + HostOnlyCommand::class, + ]; +} +PHP); + + file_put_contents($projectDirectory . '/app/Exceptions/Handler.php', <<<'PHP' +make(Kernel::class)->bootstrap(); + +file_put_contents(__DIR__ . '/../script.txt', getenv('DOCKER_COMPOSER_TEST_MARK') ?: (getenv('DOCKER_COMPOSER_INSIDE') ?: 'host')); +PHP); + chmod($projectDirectory . '/scripts/bootstrap.php', 0755); + + file_put_contents($projectDirectory . '/docker-compose.yaml', sprintf(<<<'YAML' +services: + php: + image: %s + command: ['sleep', 'infinity'] + working_dir: /usr/src/app + volumes: + - { type: bind, source: '.', target: '/usr/src/app' } + php_tools: + image: %s + command: ['sleep', 'infinity'] + environment: + DOCKER_COMPOSER_TEST_MARK: mapped + working_dir: /usr/src/app + volumes: + - { type: bind, source: '.', target: '/usr/src/app' } +YAML, $this->getComposerImage(), $this->getComposerImage())); + + return $projectDirectory; + } + + private function writeLaravelDockerComposerConfig(string $projectDirectory): void { - $this->runCommand(['composer', 'install', '--no-interaction', '--no-progress', '--prefer-dist'], $projectDirectory); + file_put_contents($projectDirectory . '/config/docker_composer.php', <<<'PHP' + env('DOCKER_COMPOSER_LARAVEL', false), + 'service' => 'php', + 'mode' => 'exec', + 'compose_files' => 'docker-compose.yaml', + 'workdir' => '/usr/src/app', + 'exclude' => ['host-only'], + 'service_mapping' => [ + 'php_tools' => [ + App\Console\Commands\ClassMappedCommand::class, + ':scripts/bootstrap.php', + ], + ], +]; +PHP); + } + + /** + * @param array $data + */ + private function writeJson(string $path, array $data): void + { + $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + throw new \RuntimeException(sprintf('Unable to encode "%s".', $path)); + } + + file_put_contents($path, $encoded . PHP_EOL); } /** diff --git a/tests/Unit/DockerComposeCommandBuilderTest.php b/tests/Unit/DockerComposeCommandBuilderTest.php index e2f7ade..c180b8d 100644 --- a/tests/Unit/DockerComposeCommandBuilderTest.php +++ b/tests/Unit/DockerComposeCommandBuilderTest.php @@ -12,6 +12,7 @@ use Composer\Util\ProcessExecutor; use empaphy\docker_composer\DockerComposeCommandBuilder; use empaphy\docker_composer\DockerComposerConfig; +use empaphy\docker_composer\DockerComposeOptions; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\UsesClass; use Symfony\Component\Console\Input\ArrayInput; @@ -169,6 +170,111 @@ public function testCommandBuilderBuildsInteractiveRunCommand(): void ], $command); } + public function testCommandBuilderBuildsGenericProcessCommand(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => [ + 'service' => 'php', + 'mode' => DockerComposeOptions::MODE_RUN, + 'workdir' => '/usr/src/app', + ], + ]); + $config = DockerComposerConfig::fromComposer($composer); + + $command = (new DockerComposeCommandBuilder())->buildProcessCommand($config, ['php', 'artisan', 'migrate'], false); + + self::assertSame([ + 'docker', + 'compose', + 'run', + '--rm', + '-T', + '--workdir', + '/usr/src/app', + '--env', + 'DOCKER_COMPOSER_INSIDE=1', + 'php', + 'php', + 'artisan', + 'migrate', + ], $command); + } + + public function testCommandBuilderBuildsWorkdirDiscoveryCommands(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => [ + 'service' => 'php', + 'compose-files' => 'compose.yaml', + 'project-directory' => '.', + ], + ]); + $config = DockerComposerConfig::fromComposer($composer); + $builder = new DockerComposeCommandBuilder(); + + self::assertSame([ + 'docker', + 'compose', + '--file', + 'compose.yaml', + '--project-directory', + '.', + 'config', + '--format', + 'json', + ], $builder->buildConfigCommand($config)); + self::assertSame([ + 'docker', + 'compose', + '--file', + 'compose.yaml', + '--project-directory', + '.', + 'exec', + '-T', + 'php', + 'pwd', + ], $builder->buildExecWorkdirCommand($config)); + self::assertSame([ + 'docker', + 'compose', + '--file', + 'compose.yaml', + '--project-directory', + '.', + 'run', + '--rm', + '-T', + 'php', + 'pwd', + ], $builder->buildRunWorkdirCommand($config)); + self::assertSame([ + 'docker', + 'image', + 'inspect', + '--format', + '{{.Config.WorkingDir}}', + 'php:cli', + ], $builder->buildImageWorkdirCommand('php:cli')); + } + + public function testCommandBuilderTranslatesProjectPaths(): void + { + $arguments = (new DockerComposeCommandBuilder())->translateProjectPaths([ + '/host/app/artisan', + '--path=/host/app/database/migrations', + '/host/app', + '/elsewhere/file.php', + ], '/host/app', '/usr/src/app'); + + self::assertSame([ + '/usr/src/app/artisan', + '--path=/usr/src/app/database/migrations', + '/usr/src/app', + '/elsewhere/file.php', + ], $arguments); + } + public function testCommandBuilderUsesServerArgvFallback(): void { [$composer] = $this->createComposer([], [ diff --git a/tests/Unit/DockerComposeRunnerTest.php b/tests/Unit/DockerComposeRunnerTest.php new file mode 100644 index 0000000..d511328 --- /dev/null +++ b/tests/Unit/DockerComposeRunnerTest.php @@ -0,0 +1,105 @@ +createConfig(['service' => 'php']); + $processRunner = new MockProcessRunner(); + $runner = new DockerComposeRunner($processRunner, new DockerComposeCommandBuilder()); + + $first = $runner->run($config, ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'], false); + $second = $runner->run($config, ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'], false); + + self::assertTrue($first->isSuccessful()); + self::assertTrue($second->isSuccessful()); + self::assertSame([ + ['docker', 'compose', 'up', '-d', 'php'], + ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'], + ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'], + ], $processRunner->commands); + } + + public function testExecModeSkipsStartupWhenServiceIsRunning(): void + { + $config = $this->createConfig(['service' => 'php']); + $processRunner = new MockOutputCapturingProcessRunner(outputs: ['php']); + $runner = new DockerComposeRunner($processRunner, new DockerComposeCommandBuilder()); + + $result = $runner->run($config, ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'], false); + + self::assertTrue($result->isSuccessful()); + self::assertSame([ + ['docker', 'compose', 'ps', '--status', 'running', '--services', 'php'], + ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'], + ], $processRunner->commands); + } + + public function testStartupFailureReturnsFailedResult(): void + { + $config = $this->createConfig(['service' => 'php']); + $processRunner = new MockProcessRunner([7]); + $runner = new DockerComposeRunner($processRunner, new DockerComposeCommandBuilder()); + + $result = $runner->run($config, ['docker', 'compose', 'exec', '-T', 'php', 'php', '-v'], false); + + self::assertFalse($result->isSuccessful()); + self::assertSame('up', $result->getPhase()); + self::assertSame(7, $result->getExitCode()); + self::assertSame(['docker', 'compose', 'up', '-d', 'php'], $result->getCommand()); + self::assertSame([ + ['docker', 'compose', 'up', '-d', 'php'], + ], $processRunner->commands); + } + + public function testRunModeDoesNotStartService(): void + { + $config = $this->createConfig([ + 'service' => 'php', + 'mode' => DockerComposeOptions::MODE_RUN, + ]); + $processRunner = new MockProcessRunner(); + $runner = new DockerComposeRunner($processRunner, new DockerComposeCommandBuilder()); + + $result = $runner->run($config, ['docker', 'compose', 'run', '--rm', '-T', 'php', 'php', '-v'], false); + + self::assertSame('run', $result->getPhase()); + self::assertSame([ + ['docker', 'compose', 'run', '--rm', '-T', 'php', 'php', '-v'], + ], $processRunner->commands); + } + + /** + * @param array $options + */ + private function createConfig(array $options): DockerComposerConfig + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => $options, + ]); + + return DockerComposerConfig::fromComposer($composer); + } +} diff --git a/tests/Unit/DockerComposerPluginTest.php b/tests/Unit/DockerComposerPluginTest.php index 93b1b64..1c37e7c 100644 --- a/tests/Unit/DockerComposerPluginTest.php +++ b/tests/Unit/DockerComposerPluginTest.php @@ -15,6 +15,11 @@ use Composer\Script\Event as ScriptEvent; use empaphy\docker_composer\ComposerProcessRunner; use empaphy\docker_composer\DockerComposeCommandBuilder; +use empaphy\docker_composer\DockerComposeExecutionResult; +use empaphy\docker_composer\DockerComposeResolvedOptions; +use empaphy\docker_composer\DockerComposeRunner; +use empaphy\docker_composer\DockerComposeWorkdirResolution; +use empaphy\docker_composer\DockerComposeWorkdirResolver; use empaphy\docker_composer\DockerComposerConfig; use empaphy\docker_composer\DockerComposerPlugin; use InvalidArgumentException; @@ -31,6 +36,11 @@ #[CoversClass(ComposerProcessRunner::class)] #[CoversClass(DockerComposerConfig::class)] #[CoversClass(DockerComposeCommandBuilder::class)] +#[CoversClass(DockerComposeRunner::class)] +#[CoversClass(DockerComposeExecutionResult::class)] +#[CoversClass(DockerComposeWorkdirResolver::class)] +#[CoversClass(DockerComposeWorkdirResolution::class)] +#[CoversClass(DockerComposeResolvedOptions::class)] class DockerComposerPluginTest extends TestCase { public function testPluginLifecycleMethodsAreSafe(): void @@ -332,13 +342,24 @@ public function testExecModeSkipsAutoUpWhenServiceIsAlreadyRunning(): void ], ], ); - $runner = new MockOutputCapturingProcessRunner([0, 0], outputs: ['php' . PHP_EOL]); + $runner = new MockOutputCapturingProcessRunner([0, 0, 0], outputs: [$this->composeConfigWithWorkingDir(), 'php' . PHP_EOL]); $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $plugin->activate($composer, $io); $plugin->onScript(new ScriptEvent('test', $composer, $io)); self::assertSame([ + [ + 'docker', + 'compose', + '--file', + 'docker-compose.yaml', + '--project-directory', + '.', + 'config', + '--format', + 'json', + ], [ 'docker', 'compose', @@ -361,6 +382,8 @@ public function testExecModeSkipsAutoUpWhenServiceIsAlreadyRunning(): void '.', 'exec', '-T', + '--workdir', + '/usr/src/app', '--env', 'DOCKER_COMPOSER_INSIDE=1', 'php', @@ -380,16 +403,17 @@ public function testExecModeRunsAutoUpWhenServiceIsNotRunning(): void ['test' => ['host-command']], ['docker-composer' => ['service' => 'php']], ); - $runner = new MockOutputCapturingProcessRunner([0, 0, 0], outputs: ['']); + $runner = new MockOutputCapturingProcessRunner([0, 0, 0, 0], outputs: [$this->composeConfigWithWorkingDir(), '']); $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $plugin->activate($composer, $io); $plugin->onScript(new ScriptEvent('test', $composer, $io)); - self::assertSame(['ps', 'up', 'exec'], [ + self::assertSame(['config', 'ps', 'up', 'exec'], [ $runner->commands[0][2], $runner->commands[1][2], $runner->commands[2][2], + $runner->commands[3][2], ]); } @@ -399,16 +423,17 @@ public function testExecModeRunsAutoUpWhenRunningServiceCheckFails(): void ['test' => ['host-command']], ['docker-composer' => ['service' => 'php']], ); - $runner = new MockOutputCapturingProcessRunner([7, 0, 0], outputs: ['']); + $runner = new MockOutputCapturingProcessRunner([0, 7, 0, 0], outputs: [$this->composeConfigWithWorkingDir(), '']); $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $plugin->activate($composer, $io); $plugin->onScript(new ScriptEvent('test', $composer, $io)); - self::assertSame(['ps', 'up', 'exec'], [ + self::assertSame(['config', 'ps', 'up', 'exec'], [ $runner->commands[0][2], $runner->commands[1][2], $runner->commands[2][2], + $runner->commands[3][2], ]); } @@ -1078,6 +1103,17 @@ public function testDisableEnvironmentVariableFallsThrough(): void self::assertSame([], $runner->commands); } + private function composeConfigWithWorkingDir(): string + { + return json_encode([ + 'services' => [ + 'php' => [ + 'working_dir' => '/usr/src/app', + ], + ], + ], JSON_THROW_ON_ERROR); + } + private function assertScriptExecutionFails(DockerComposerPlugin $plugin, ScriptEvent $event): ScriptExecutionException { try { diff --git a/tests/Unit/Laravel/ConfigTest.php b/tests/Unit/Laravel/ConfigTest.php new file mode 100644 index 0000000..4e7c95d --- /dev/null +++ b/tests/Unit/Laravel/ConfigTest.php @@ -0,0 +1,112 @@ + 'true', + 'service' => 'php', + 'mode' => 'run', + 'compose_files' => 'docker-compose.yaml', + 'project_directory' => '.', + 'workdir' => '/usr/src/app', + 'exclude' => ['queue:work', ExcludedSignatureCommand::class], + 'service_mapping' => [ + 'php-tools' => [ + 'config:cache', + ExampleCommand::class, + SignatureCommand::class, + ':scripts/task.php', + ], + ], + ]); + + self::assertTrue($config->isEnabled()); + self::assertSame('php', $config->getService()); + self::assertSame('run', $config->getMode()); + self::assertSame(['docker-compose.yaml'], $config->getComposeFiles()); + self::assertSame('.', $config->getProjectDirectory()); + self::assertSame('/usr/src/app', $config->getWorkdir()); + self::assertTrue($config->excludes(ConsoleEntry::artisan('queue:work', null, ['artisan', 'queue:work']))); + self::assertTrue($config->excludes(ConsoleEntry::artisan('excluded:run', null, ['artisan', 'excluded:run']))); + self::assertSame('php-tools', $config->forEntry(ConsoleEntry::artisan('config:cache', null, ['artisan', 'config:cache']))?->getService()); + self::assertSame('php-tools', $config->forEntry(ConsoleEntry::artisan(null, ExampleCommand::class, ['artisan', 'example']))?->getService()); + self::assertSame('php-tools', $config->forEntry(ConsoleEntry::artisan('signature:run', null, ['artisan', 'signature:run']))?->getService()); + self::assertSame('php-tools', $config->forEntry(ConsoleEntry::script(':scripts/task.php', ['scripts/task.php']))?->getService()); + } + + public function testDefaultServiceAppliesWhenNoMappingMatches(): void + { + $config = Config::fromArray([ + 'enabled' => true, + 'service' => 'php', + ]); + + self::assertSame('php', $config->forEntry(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']))?->getService()); + } + + public function testMissingServiceLeavesEntryUnconfigured(): void + { + $config = Config::fromArray([ + 'enabled' => true, + ]); + + self::assertNull($config->forEntry(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']))); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Docker Compose service is not configured.'); + + $config->getService(); + } + + public function testRejectsUnknownKeys(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('docker_composer contains unknown key "unknown".'); + + Config::fromArray(['unknown' => true]); + } + + public function testRejectsInvalidMappingShape(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('docker_composer.service_mapping must not assign an entry to multiple services.'); + + Config::fromArray([ + 'service_mapping' => [ + 'php' => 'migrate', + 'worker' => 'migrate', + ], + ]); + } +} + +final class ExampleCommand {} + +final class SignatureCommand +{ + protected string $signature = 'signature:run {argument?}'; +} + +final class ExcludedSignatureCommand +{ + protected string $signature = 'excluded:run'; +} diff --git a/tests/Unit/Laravel/ConsoleEntryTest.php b/tests/Unit/Laravel/ConsoleEntryTest.php new file mode 100644 index 0000000..e1f1e97 --- /dev/null +++ b/tests/Unit/Laravel/ConsoleEntryTest.php @@ -0,0 +1,33 @@ +getNames()); + self::assertSame(['artisan', 'config:cache'], $entry->getArguments()); + } + + public function testCreatesRelativeScriptName(): void + { + self::assertSame(':scripts/task.php', ConsoleEntry::scriptName('/host/app/scripts/task.php', '/host/app')); + self::assertSame(':scripts/task.php', ConsoleEntry::scriptName('scripts/task.php', '/host/app')); + } +} + +final class ExampleConsoleEntryCommand {} diff --git a/tests/Unit/Laravel/RedirectorTest.php b/tests/Unit/Laravel/RedirectorTest.php new file mode 100644 index 0000000..8bc93df --- /dev/null +++ b/tests/Unit/Laravel/RedirectorTest.php @@ -0,0 +1,193 @@ + true, + 'service' => 'php', + 'workdir' => '/usr/src/app', + 'service_mapping' => [ + 'php-tools' => 'config:cache', + ], + ]); + $runner = new MockProcessRunner(); + $builder = new DockerComposeCommandBuilder(); + $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false)); + + $exitCode = $redirector->redirect($config, ConsoleEntry::artisan('config:cache', null, ['/host/app/artisan', 'config:cache']), '/host/app', false); + + self::assertSame(0, $exitCode); + self::assertSame([ + ['docker', 'compose', 'up', '-d', 'php-tools'], + [ + 'docker', + 'compose', + 'exec', + '-T', + '--workdir', + '/usr/src/app', + '--env', + 'DOCKER_COMPOSER_INSIDE=1', + 'php-tools', + '/usr/src/app/artisan', + 'config:cache', + ], + ], $runner->commands); + } + + public function testReturnsNullWhenDisabledInsideContainerExcludedOrUnconfigured(): void + { + $entry = ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']); + $builder = new DockerComposeCommandBuilder(); + + $disabledRunner = new MockProcessRunner(); + $disabled = Config::fromArray(['enabled' => false, 'service' => 'php']); + self::assertNull((new Redirector(new DockerComposeRunner($disabledRunner, $builder), $builder, new MockContainerDetector(false)))->redirect($disabled, $entry, '/host/app', false)); + self::assertSame([], $disabledRunner->commands); + + $insideRunner = new MockProcessRunner(); + $enabled = Config::fromArray(['enabled' => true, 'service' => 'php']); + self::assertNull((new Redirector(new DockerComposeRunner($insideRunner, $builder), $builder, new MockContainerDetector(true)))->redirect($enabled, $entry, '/host/app', false)); + self::assertSame([], $insideRunner->commands); + + $excludedRunner = new MockProcessRunner(); + $excluded = Config::fromArray(['enabled' => true, 'service' => 'php', 'exclude' => ['migrate']]); + self::assertNull((new Redirector(new DockerComposeRunner($excludedRunner, $builder), $builder, new MockContainerDetector(false)))->redirect($excluded, $entry, '/host/app', false)); + self::assertSame([], $excludedRunner->commands); + + $unconfiguredRunner = new MockProcessRunner(); + $unconfigured = Config::fromArray(['enabled' => true]); + self::assertNull((new Redirector(new DockerComposeRunner($unconfiguredRunner, $builder), $builder, new MockContainerDetector(false)))->redirect($unconfigured, $entry, '/host/app', false)); + self::assertSame([], $unconfiguredRunner->commands); + } + + public function testRedirectSkipsEntrypointAbsolutizingWithoutPathMapping(): void + { + $projectRoot = $this->createProjectRootWithArtisan(); + $config = Config::fromArray([ + 'enabled' => true, + 'service' => 'php', + ]); + $runner = new MockProcessRunner(); + $builder = new DockerComposeCommandBuilder(); + $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false)); + + try { + $exitCode = $redirector->redirect($config, ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']), $projectRoot, false); + } finally { + $this->removeProjectRootWithArtisan($projectRoot); + } + + self::assertSame(0, $exitCode); + self::assertSame('artisan', $runner->commands[1][7]); + } + + public function testRedirectAbsolutizesAndTranslatesEntrypointWithPathMapping(): void + { + $projectRoot = $this->createProjectRootWithArtisan(); + $config = Config::fromArray([ + 'enabled' => true, + 'service' => 'php', + ]); + $configOutput = json_encode([ + 'services' => [ + 'php' => [ + 'volumes' => [ + ['type' => 'bind', 'source' => $projectRoot, 'target' => '/usr/src/app'], + ], + ], + ], + ], JSON_THROW_ON_ERROR); + $runner = new MockOutputCapturingProcessRunner([0, 0, 0], outputs: [$configOutput, "php\n"]); + $builder = new DockerComposeCommandBuilder(); + $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), $runner); + + try { + $exitCode = $redirector->redirect($config, ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']), $projectRoot, false); + } finally { + $this->removeProjectRootWithArtisan($projectRoot); + } + + self::assertSame(0, $exitCode); + self::assertSame('/usr/src/app/artisan', $runner->commands[2][9]); + } + + #[BackupGlobals(true)] + public function testEnvironmentDisableReturnsNull(): void + { + putenv('DOCKER_COMPOSER_DISABLE=1'); + + try { + $config = Config::fromArray([ + 'enabled' => true, + 'service' => 'php', + ]); + $runner = new MockProcessRunner(); + $builder = new DockerComposeCommandBuilder(); + $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false)); + + self::assertNull($redirector->redirect($config, ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']), '/host/app', false)); + self::assertSame([], $runner->commands); + } finally { + putenv('DOCKER_COMPOSER_DISABLE'); + } + } + + private function createProjectRootWithArtisan(): string + { + $projectRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR + . 'docker-composer-laravel-' + . bin2hex(random_bytes(8)); + if (! mkdir($projectRoot, 0777, true) && ! is_dir($projectRoot)) { + throw new \RuntimeException(sprintf('Unable to create test directory "%s".', $projectRoot)); + } + + file_put_contents($projectRoot . DIRECTORY_SEPARATOR . 'artisan', ''); + + return $projectRoot; + } + + private function removeProjectRootWithArtisan(string $projectRoot): void + { + @unlink($projectRoot . DIRECTORY_SEPARATOR . 'artisan'); + @rmdir($projectRoot); + } +} diff --git a/tests/Unit/Mocks/MockCommandBuilder.php b/tests/Unit/Mocks/MockCommandBuilder.php index 8ed4de4..f5607bc 100644 --- a/tests/Unit/Mocks/MockCommandBuilder.php +++ b/tests/Unit/Mocks/MockCommandBuilder.php @@ -6,21 +6,26 @@ use Composer\Script\Event as ScriptEvent; use empaphy\docker_composer\DockerComposeCommandBuilder; -use empaphy\docker_composer\DockerComposerConfig; +use empaphy\docker_composer\DockerComposeOptions; final class MockCommandBuilder extends DockerComposeCommandBuilder { - public function buildRunningServicesCommand(DockerComposerConfig $config): array + public function buildRunningServicesCommand(DockerComposeOptions $config): array { return ['php', '-r', 'exit(1);']; } - public function buildUpCommand(DockerComposerConfig $config): array + public function buildUpCommand(DockerComposeOptions $config): array { return ['php', '-r', 'exit(0);']; } - public function buildScriptCommand(DockerComposerConfig $config, ScriptEvent $event, bool $interactive): array + public function buildConfigCommand(DockerComposeOptions $config): array + { + return ['php', '-r', 'echo \'{"services":{"php":{"working_dir":"/usr/src/app"}}}\';']; + } + + public function buildScriptCommand(DockerComposeOptions $config, ScriptEvent $event, bool $interactive): array { return ['php', '-r', 'exit(0);']; } From e75a3ca1d2908ff134364eebb02ec61683278c30 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Thu, 14 May 2026 22:18:43 +0200 Subject: [PATCH 03/10] fix: map composer workdir from active cwd Composer applies -d before plugin execution, so workdir inference should use the active process CWD instead of Composer's private config baseDir. This maps bind volumes and translated Composer arguments from the directory where the redirected command actually runs. Keep Laravel redirection project-root based because its redirector receives an explicit Laravel project root for entrypoint absolutization and path translation. Also documents the active-CWD inference behavior and adds regression coverage for exact and ancestor bind mappings plus Composer command path translation limits. --- README.md | 8 +- src/DockerComposeCommandBuilder.php | 94 ++++++-- src/DockerComposeWorkdirResolution.php | 33 ++- src/DockerComposeWorkdirResolver.php | 42 ++-- src/DockerComposerPlugin.php | 57 ++--- src/Laravel/Redirector.php | 6 +- .../Unit/DockerComposeWorkdirResolverTest.php | 216 ++++++++++++++++++ tests/Unit/DockerComposerPluginTest.php | 65 ++++++ tests/Unit/Mocks/MockCommandBuilder.php | 9 +- 9 files changed, 433 insertions(+), 97 deletions(-) create mode 100644 tests/Unit/DockerComposeWorkdirResolverTest.php diff --git a/README.md b/README.md index 32e58d1..6d974b2 100644 --- a/README.md +++ b/README.md @@ -85,10 +85,10 @@ then run normally because the plugin detects that Composer is already inside a container. It also treats `/.dockerenv`, `/run/.containerenv`, and common cgroup markers as container signals. -When `workdir` is omitted, the plugin attempts to infer the host project root's -container path from Docker Compose bind volumes. If no mapping is found, it -falls back to configured service `working_dir`, probing `pwd`, then image -`Config.WorkingDir`. Path translation only runs when a host-to-container +When `workdir` is omitted, the plugin attempts to infer the active host working +directory's container path from Docker Compose bind volumes. If no mapping is +found, it falls back to configured service `working_dir`, probing `pwd`, then +image `Config.WorkingDir`. Path translation only runs when a host-to-container mapping is known. Set `DOCKER_COMPOSER_DISABLE=1` to bypass Docker redirection temporarily. diff --git a/src/DockerComposeCommandBuilder.php b/src/DockerComposeCommandBuilder.php index 762666c..4be8f1e 100644 --- a/src/DockerComposeCommandBuilder.php +++ b/src/DockerComposeCommandBuilder.php @@ -70,12 +70,27 @@ public function buildRunningServicesCommand(DockerComposeOptions $config): array * @param bool $interactive * Whether the Docker command should keep TTY interaction enabled. * + * @param string|null $hostPathRoot + * The host directory whose descendants can be translated. + * + * @param string|null $containerPathRoot + * The matching container directory, or `null` to leave paths unchanged. + * * @return list * Returns command arguments for `docker compose exec` or `run`. */ - public function buildScriptCommand(DockerComposeOptions $config, ScriptEvent $event, bool $interactive): array - { - return $this->buildProcessCommand($config, $this->composerRunScriptCommand($event), $interactive); + public function buildScriptCommand( + DockerComposeOptions $config, + ScriptEvent $event, + bool $interactive, + ?string $hostPathRoot = null, + ?string $containerPathRoot = null, + ): array { + return $this->buildProcessCommand( + $config, + $this->composerRunScriptCommand($event, $hostPathRoot, $containerPathRoot), + $interactive, + ); } /** @@ -93,12 +108,29 @@ public function buildScriptCommand(DockerComposeOptions $config, ScriptEvent $ev * @param bool $interactive * Whether the Docker command should keep TTY interaction enabled. * + * @param string|null $hostPathRoot + * The host directory whose descendants can be translated. + * + * @param string|null $containerPathRoot + * The matching container directory, or `null` to leave paths unchanged. + * * @return list * Returns command arguments for `docker compose exec` or `run`. */ - public function buildComposerCommand(DockerComposeOptions $config, string $commandName, InputInterface $input, bool $interactive): array - { - return $this->buildProcessCommand($config, array_merge(['composer'], $this->getCommandArguments($input, $commandName)), $interactive); + public function buildComposerCommand( + DockerComposeOptions $config, + string $commandName, + InputInterface $input, + bool $interactive, + ?string $hostPathRoot = null, + ?string $containerPathRoot = null, + ): array { + $arguments = $this->getCommandArguments($input, $commandName); + if ($hostPathRoot !== null) { + $arguments = $this->translateProjectPaths($arguments, $hostPathRoot, $containerPathRoot); + } + + return $this->buildProcessCommand($config, array_merge(['composer'], $arguments), $interactive); } /** @@ -220,28 +252,28 @@ public function buildImageWorkdirCommand(string $image): array } /** - * Translates absolute host project paths in command arguments. + * Translates absolute host paths in command arguments. * * @param list $arguments * The host command arguments. * - * @param string $hostProjectRoot - * The absolute project root on the host. + * @param string $hostPathRoot + * The host directory whose descendants can be translated. * - * @param string|null $containerWorkdir - * The configured container workdir, or `null` to leave paths unchanged. + * @param string|null $containerPathRoot + * The matching container directory, or `null` to leave paths unchanged. * * @return list - * Returns arguments with project-root paths translated into container paths. + * Returns arguments with host paths translated into container paths. */ - public function translateProjectPaths(array $arguments, string $hostProjectRoot, ?string $containerWorkdir): array + public function translateProjectPaths(array $arguments, string $hostPathRoot, ?string $containerPathRoot): array { - if ($containerWorkdir === null) { + if ($containerPathRoot === null) { return $arguments; } - $hostProjectRoot = $this->normalizePath($hostProjectRoot); - $containerWorkdir = rtrim($this->normalizePath($containerWorkdir), '/'); + $hostPathRoot = $this->normalizePath($hostPathRoot); + $containerPathRoot = rtrim($this->normalizePath($containerPathRoot), '/'); $translated = []; foreach ($arguments as $argument) { @@ -253,14 +285,14 @@ public function translateProjectPaths(array $arguments, string $hostProjectRoot, } $normalizedPath = $this->normalizePath($path); - if ($normalizedPath === $hostProjectRoot) { - $translated[] = $prefix . $containerWorkdir; + if ($normalizedPath === $hostPathRoot) { + $translated[] = $prefix . $containerPathRoot; continue; } - if (str_starts_with($normalizedPath, $hostProjectRoot . '/')) { - $translated[] = $prefix . $containerWorkdir . substr($normalizedPath, strlen($hostProjectRoot)); + if (str_starts_with($normalizedPath, $hostPathRoot . '/')) { + $translated[] = $prefix . $containerPathRoot . substr($normalizedPath, strlen($hostPathRoot)); continue; } @@ -303,11 +335,20 @@ private function composeBase(DockerComposeOptions $config): array * @param ScriptEvent $event * The script event whose name, flags, and arguments are replayed. * + * @param string|null $hostPathRoot + * The host directory whose descendants can be translated. + * + * @param string|null $containerPathRoot + * The matching container directory, or `null` to leave paths unchanged. + * * @return list * Returns command arguments beginning with `composer run-script`. */ - private function composerRunScriptCommand(ScriptEvent $event): array - { + private function composerRunScriptCommand( + ScriptEvent $event, + ?string $hostPathRoot = null, + ?string $containerPathRoot = null, + ): array { $command = [ 'composer', 'run-script', @@ -327,11 +368,16 @@ private function composerRunScriptCommand(ScriptEvent $event): array } $command[] = '--'; + $scriptArguments = []; foreach ($arguments as $argument) { - $command[] = $this->stringifyArgument($argument); + $scriptArguments[] = $this->stringifyArgument($argument); } - return $command; + if ($hostPathRoot !== null) { + $scriptArguments = $this->translateProjectPaths($scriptArguments, $hostPathRoot, $containerPathRoot); + } + + return array_merge($command, $scriptArguments); } /** diff --git a/src/DockerComposeWorkdirResolution.php b/src/DockerComposeWorkdirResolution.php index 7205cbf..45fc5cc 100644 --- a/src/DockerComposeWorkdirResolution.php +++ b/src/DockerComposeWorkdirResolution.php @@ -14,7 +14,7 @@ namespace empaphy\docker_composer; /** - * Stores inferred container workdir and host project path mapping. + * Stores inferred container workdir and host directory mapping. */ final class DockerComposeWorkdirResolution { @@ -24,12 +24,12 @@ final class DockerComposeWorkdirResolution * @param string|null $workdir * The container working directory, or `null` when unavailable. * - * @param string|null $containerProjectRoot - * The container path matching the host project root, or `null`. + * @param string|null $containerWorkingDirectory + * The container path matching the host working directory, or `null`. */ public function __construct( private readonly ?string $workdir, - private readonly ?string $containerProjectRoot, + private readonly ?string $containerWorkingDirectory, ) {} /** @@ -44,24 +44,37 @@ public function getWorkdir(): ?string } /** - * Gets the container project root mapping. + * Gets the container working directory mapping. * * @return string|null - * Returns the container path matching the host project root, or `null`. + * Returns the container path matching the host working directory, or `null`. + */ + public function getContainerWorkingDirectory(): ?string + { + return $this->containerWorkingDirectory; + } + + /** + * Gets the legacy container project root mapping. + * + * @return string|null + * Returns the container path matching the host directory, or `null`. + * + * @deprecated Use {@see getContainerWorkingDirectory()} instead. */ public function getContainerProjectRoot(): ?string { - return $this->containerProjectRoot; + return $this->containerWorkingDirectory; } /** - * Checks whether host project paths can be translated. + * Checks whether host paths can be translated. * * @return bool - * Returns `true` when the host project root has a container path. + * Returns `true` when the host directory has a container path. */ public function hasPathMapping(): bool { - return $this->containerProjectRoot !== null; + return $this->containerWorkingDirectory !== null; } } diff --git a/src/DockerComposeWorkdirResolver.php b/src/DockerComposeWorkdirResolver.php index 5950784..c328724 100644 --- a/src/DockerComposeWorkdirResolver.php +++ b/src/DockerComposeWorkdirResolver.php @@ -14,7 +14,7 @@ namespace empaphy\docker_composer; /** - * Resolves container workdir and host project path mapping. + * Resolves container workdir and host directory mapping. */ final class DockerComposeWorkdirResolver { @@ -34,8 +34,8 @@ public function __construct( * @param DockerComposeOptions $config * The effective Docker Compose service options. * - * @param string $hostProjectRoot - * The absolute project root on the host. + * @param string $hostWorkingDirectory + * The active working directory on the host. * * @param ProcessRunner|null $processRunner * The runner used for discovery commands, or `null` to skip them. @@ -44,31 +44,31 @@ public function __construct( * The Docker Compose runner used to prepare exec probes. * * @return DockerComposeWorkdirResolution - * Returns inferred workdir and host project path mapping. + * Returns inferred workdir and host directory mapping. */ public function resolve( DockerComposeOptions $config, - string $hostProjectRoot, + string $hostWorkingDirectory, ?ProcessRunner $processRunner = null, ?DockerComposeRunner $dockerRunner = null, ): DockerComposeWorkdirResolution { $workdir = $config->getWorkdir(); - $containerProjectRoot = null; + $containerWorkingDirectory = null; $service = $processRunner instanceof OutputCapturingProcessRunner ? $this->readComposeService($config, $processRunner) : null; if ($service !== null) { - $containerProjectRoot = $this->inferContainerProjectRoot($service, $hostProjectRoot); - if ($containerProjectRoot !== null && $workdir === null) { - $workdir = $containerProjectRoot; + $containerWorkingDirectory = $this->inferContainerWorkingDirectory($service, $hostWorkingDirectory); + if ($containerWorkingDirectory !== null && $workdir === null) { + $workdir = $containerWorkingDirectory; } $workdir ??= $this->readServiceWorkingDir($service); } - if ($containerProjectRoot === null && $config->getWorkdir() !== null) { - $containerProjectRoot = $config->getWorkdir(); + if ($containerWorkingDirectory === null && $config->getWorkdir() !== null) { + $containerWorkingDirectory = $config->getWorkdir(); } if ($workdir === null && $processRunner instanceof OutputCapturingProcessRunner) { @@ -79,7 +79,7 @@ public function resolve( $workdir = $this->inspectImageWorkdir($service, $processRunner); } - return new DockerComposeWorkdirResolution($workdir, $containerProjectRoot); + return new DockerComposeWorkdirResolution($workdir, $containerWorkingDirectory); } /** @@ -117,25 +117,25 @@ private function readComposeService(DockerComposeOptions $config, OutputCapturin } /** - * Infers the container project root from service bind volumes. + * Infers the container working directory from service bind volumes. * * @param array $service * The Docker Compose service config object. * - * @param string $hostProjectRoot - * The absolute project root on the host. + * @param string $hostWorkingDirectory + * The active working directory on the host. * * @return string|null - * Returns the mapped container project root, or `null`. + * Returns the mapped container working directory, or `null`. */ - private function inferContainerProjectRoot(array $service, string $hostProjectRoot): ?string + private function inferContainerWorkingDirectory(array $service, string $hostWorkingDirectory): ?string { $volumes = $service['volumes'] ?? null; if (! is_array($volumes) || ! array_is_list($volumes)) { return null; } - $hostProjectRoot = $this->normalizePath($hostProjectRoot); + $hostWorkingDirectory = $this->normalizePath($hostWorkingDirectory); $bestSource = null; $bestTarget = null; @@ -152,11 +152,11 @@ private function inferContainerProjectRoot(array $service, string $hostProjectRo $source = $this->normalizePath($source); $target = $this->normalizePath($target); - if ($source === $hostProjectRoot) { + if ($source === $hostWorkingDirectory) { return $target; } - if ($this->isPathAncestor($source, $hostProjectRoot) && ($bestSource === null || strlen($source) > strlen($bestSource))) { + if ($this->isPathAncestor($source, $hostWorkingDirectory) && ($bestSource === null || strlen($source) > strlen($bestSource))) { $bestSource = $source; $bestTarget = $target; } @@ -166,7 +166,7 @@ private function inferContainerProjectRoot(array $service, string $hostProjectRo return null; } - return $this->appendPath($bestTarget, substr($hostProjectRoot, strlen($bestSource))); + return $this->appendPath($bestTarget, substr($hostWorkingDirectory, strlen($bestSource))); } /** diff --git a/src/DockerComposerPlugin.php b/src/DockerComposerPlugin.php index ef89803..82248a0 100644 --- a/src/DockerComposerPlugin.php +++ b/src/DockerComposerPlugin.php @@ -24,6 +24,7 @@ use Composer\Plugin\PluginEvents; use Composer\Plugin\PluginInterface; use Composer\Script\Event as ScriptEvent; +use Composer\Util\Platform; use Composer\Util\ProcessExecutor; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -50,11 +51,6 @@ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface */ private ?IOInterface $io = null; - /** - * Stores the Composer instance passed during activation. - */ - private ?Composer $composer = null; - /** * Stores parsed Docker Composer configuration. */ @@ -81,7 +77,7 @@ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface private ?DockerComposeRunner $dockerRunner = null; /** - * Resolves container workdir and project path mapping. + * Resolves container workdir and host directory mapping. */ private DockerComposeWorkdirResolver $workdirResolver; @@ -142,7 +138,6 @@ public function __construct( public function activate(Composer $composer, IOInterface $io) { $this->io = $io; - $this->composer = $composer; $this->config = DockerComposerConfig::fromComposer($composer); $this->processRunner ??= new ComposerProcessRunner($io); @@ -511,12 +506,16 @@ private function writeCommandRedirectNotice(IOInterface $io, string $commandName private function runInDocker(ScriptEvent $event, DockerComposerConfig $config): void { $runner = $this->getProcessRunner($event); - $config = $this->resolveDockerOptions($config, $this->getComposerProjectRoot($event->getComposer()), $runner); + $hostWorkingDirectory = $this->getHostWorkingDirectory(); + $resolution = $this->resolveDockerWorkdir($config, $hostWorkingDirectory, $runner); + $config = new DockerComposeResolvedOptions($config, $resolution->getWorkdir()); $isInteractive = $event->getIO()->isInteractive() && $runner->supportsTty(); $scriptCommand = $this->commandBuilder->buildScriptCommand( $config, $event, $isInteractive, + $hostWorkingDirectory, + $resolution->getContainerWorkingDirectory(), ); $this->runDockerCommand($runner, $config, $scriptCommand, $isInteractive); } @@ -539,13 +538,17 @@ private function runInDocker(ScriptEvent $event, DockerComposerConfig $config): private function runComposerCommandInDocker(PreCommandRunEvent $event, DockerComposerConfig $config): void { $runner = $this->getProcessRunnerForCommand(); - $config = $this->resolveDockerOptions($config, $this->getComposerProjectRoot($this->composer), $runner); + $hostWorkingDirectory = $this->getHostWorkingDirectory(); + $resolution = $this->resolveDockerWorkdir($config, $hostWorkingDirectory, $runner); + $config = new DockerComposeResolvedOptions($config, $resolution->getWorkdir()); $isInteractive = $event->getInput()->isInteractive() && $runner->supportsTty(); $command = $this->commandBuilder->buildComposerCommand( $config, $event->getCommand(), $event->getInput(), $isInteractive, + $hostWorkingDirectory, + $resolution->getContainerWorkingDirectory(), ); $this->runDockerCommand($runner, $config, $command, $isInteractive); } @@ -593,43 +596,31 @@ private function getProcessRunnerForCommand(): ProcessRunner * @param DockerComposerConfig $config * The parsed Docker Composer configuration. * - * @param string $projectRoot - * The host project root. + * @param string $hostWorkingDirectory + * The active host working directory. * * @param ProcessRunner $runner * The runner used for Docker commands. * - * @return DockerComposeOptions - * Returns options with resolved workdir applied. + * @return DockerComposeWorkdirResolution + * Returns inferred workdir and host directory mapping. */ - private function resolveDockerOptions(DockerComposerConfig $config, string $projectRoot, ProcessRunner $runner): DockerComposeOptions + private function resolveDockerWorkdir(DockerComposerConfig $config, string $hostWorkingDirectory, ProcessRunner $runner): DockerComposeWorkdirResolution { - $resolution = $this->workdirResolver->resolve($config, $projectRoot, $runner, $this->getDockerRunner($runner)); - - return new DockerComposeResolvedOptions($config, $resolution->getWorkdir()); + return $this->workdirResolver->resolve($config, $hostWorkingDirectory, $runner, $this->getDockerRunner($runner)); } /** - * Gets the active Composer project root. - * - * @param Composer|null $composer - * The active Composer instance, or `null`. + * Gets the active host working directory. * * @return string - * Returns the host project root, falling back to current directory. + * Returns Composer's current directory, falling back to process CWD. */ - private function getComposerProjectRoot(?Composer $composer): string + private function getHostWorkingDirectory(): string { - if ($composer !== null) { - $config = $composer->getConfig(); - $reflection = new \ReflectionObject($config); - if ($reflection->hasProperty('baseDir')) { - $property = $reflection->getProperty('baseDir'); - $baseDir = $property->getValue($config); - if (is_string($baseDir) && $baseDir !== '') { - return $baseDir; - } - } + $cwd = Platform::getCwd(true); + if ($cwd !== '') { + return $cwd; } $cwd = getcwd(); diff --git a/src/Laravel/Redirector.php b/src/Laravel/Redirector.php index ab810da..6bc1f43 100644 --- a/src/Laravel/Redirector.php +++ b/src/Laravel/Redirector.php @@ -26,7 +26,7 @@ final class Redirector { /** - * Resolves container workdir and project path mapping. + * Resolves container workdir and host directory mapping. */ private DockerComposeWorkdirResolver $workdirResolver; @@ -90,9 +90,9 @@ public function redirect(Config $config, ConsoleEntry $entry, string $projectRoo $resolution = $this->workdirResolver->resolve($effectiveConfig, $projectRoot, $this->processRunner, $this->dockerRunner); $effectiveOptions = new DockerComposeResolvedOptions($effectiveConfig, $resolution->getWorkdir()); $arguments = $entry->getArguments(); - if ($resolution->hasPathMapping() && $resolution->getContainerProjectRoot() !== null) { + if ($resolution->hasPathMapping() && $resolution->getContainerWorkingDirectory() !== null) { $arguments = $this->absolutizeEntrypoint($arguments, $projectRoot); - $arguments = $this->commandBuilder->translateProjectPaths($arguments, $projectRoot, $resolution->getContainerProjectRoot()); + $arguments = $this->commandBuilder->translateProjectPaths($arguments, $projectRoot, $resolution->getContainerWorkingDirectory()); } $command = $this->commandBuilder->buildProcessCommand($effectiveOptions, $arguments, $interactive); diff --git a/tests/Unit/DockerComposeWorkdirResolverTest.php b/tests/Unit/DockerComposeWorkdirResolverTest.php new file mode 100644 index 0000000..1847419 --- /dev/null +++ b/tests/Unit/DockerComposeWorkdirResolverTest.php @@ -0,0 +1,216 @@ +composeOutput([ + 'volumes' => [ + ['type' => 'bind', 'source' => '/host', 'target' => '/container'], + ['type' => 'bind', 'source' => '/host/app', 'target' => '/usr/src/app'], + ], + ])]); + $config = $this->createConfig(['service' => 'php']); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner)); + + self::assertSame('/usr/src/app', $resolution->getWorkdir()); + self::assertSame('/usr/src/app', $resolution->getContainerWorkingDirectory()); + self::assertSame('/usr/src/app', $resolution->getContainerProjectRoot()); + self::assertTrue($resolution->hasPathMapping()); + self::assertSame([['docker', 'compose', 'config', '--format', 'json']], $runner->commands); + } + + public function testVolumeMappingUsesLongestAncestorSource(): void + { + $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([ + 'volumes' => [ + ['type' => 'bind', 'source' => '/host', 'target' => '/container'], + ['type' => 'bind', 'source' => '/host/other', 'target' => '/other'], + ], + ])]); + $config = $this->createConfig(['service' => 'php']); + + $resolution = $this->createResolver()->resolve($config, '/host/app/package', $runner, $this->createRunner($runner)); + + self::assertSame('/container/app/package', $resolution->getWorkdir()); + self::assertSame('/container/app/package', $resolution->getContainerWorkingDirectory()); + } + + public function testExplicitWorkdirIsAuthoritativeAndFallbackMapping(): void + { + $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([ + 'volumes' => [ + ['type' => 'bind', 'source' => '/host/app', 'target' => '/mounted'], + ], + ])]); + $config = $this->createConfig([ + 'service' => 'php', + 'workdir' => '/configured', + ]); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner)); + + self::assertSame('/configured', $resolution->getWorkdir()); + self::assertSame('/mounted', $resolution->getContainerWorkingDirectory()); + + $fallback = $this->createResolver()->resolve($config, '/host/app', new MockProcessRunner()); + + self::assertSame('/configured', $fallback->getWorkdir()); + self::assertSame('/configured', $fallback->getContainerWorkingDirectory()); + } + + public function testComposeWorkingDirSetsWorkdirWithoutPathMapping(): void + { + $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([ + 'working_dir' => '/srv/app', + ])]); + $config = $this->createConfig(['service' => 'php']); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner)); + + self::assertSame('/srv/app', $resolution->getWorkdir()); + self::assertNull($resolution->getContainerWorkingDirectory()); + self::assertFalse($resolution->hasPathMapping()); + } + + public function testExecModeProbesServiceWorkdir(): void + { + $runner = new MockOutputCapturingProcessRunner( + [0, 0, 0, 0], + outputs: [$this->composeOutput([]), '', "/pwd\n"], + ); + $config = $this->createConfig(['service' => 'php']); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner)); + + self::assertSame('/pwd', $resolution->getWorkdir()); + self::assertNull($resolution->getContainerWorkingDirectory()); + self::assertSame(['config', 'ps', 'up', 'exec'], [ + $runner->commands[0][2], + $runner->commands[1][2], + $runner->commands[2][2], + $runner->commands[3][2], + ]); + self::assertSame(['docker', 'compose', 'exec', '-T', 'php', 'pwd'], $runner->commands[3]); + } + + public function testRunModeProbesOneOffServiceWorkdir(): void + { + $runner = new MockOutputCapturingProcessRunner( + [0, 0], + outputs: [$this->composeOutput([]), "/run-pwd\n"], + ); + $config = $this->createConfig([ + 'service' => 'php', + 'mode' => 'run', + ]); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner)); + + self::assertSame('/run-pwd', $resolution->getWorkdir()); + self::assertSame(['docker', 'compose', 'run', '--rm', '-T', 'php', 'pwd'], $runner->commands[1]); + } + + public function testImageWorkdirFallbackIgnoresEmptyImageWorkdir(): void + { + $config = $this->createConfig([ + 'service' => 'php', + 'mode' => 'run', + ]); + $resolvedRunner = new MockOutputCapturingProcessRunner( + [0, 1, 0], + outputs: [$this->composeOutput(['image' => 'php:cli']), '', "/image\n"], + ); + + $resolved = $this->createResolver()->resolve($config, '/host/app', $resolvedRunner, $this->createRunner($resolvedRunner)); + + self::assertSame('/image', $resolved->getWorkdir()); + self::assertSame(['docker', 'image', 'inspect', '--format', '{{.Config.WorkingDir}}', 'php:cli'], $resolvedRunner->commands[2]); + + $emptyRunner = new MockOutputCapturingProcessRunner( + [0, 1, 0], + outputs: [$this->composeOutput(['image' => 'php:cli']), '', ''], + ); + + $empty = $this->createResolver()->resolve($config, '/host/app', $emptyRunner, $this->createRunner($emptyRunner)); + + self::assertNull($empty->getWorkdir()); + self::assertNull($empty->getContainerWorkingDirectory()); + } + + public function testResolvedOptionsDelegateExceptWorkdir(): void + { + $config = $this->createConfig([ + 'service' => 'php', + 'mode' => 'run', + 'compose-files' => 'compose.yaml', + 'project-directory' => '.', + ]); + $options = new DockerComposeResolvedOptions($config, '/resolved'); + + self::assertSame('php', $options->getService()); + self::assertSame('run', $options->getMode()); + self::assertSame(['compose.yaml'], $options->getComposeFiles()); + self::assertSame('.', $options->getProjectDirectory()); + self::assertSame('/resolved', $options->getWorkdir()); + } + + /** + * @param array $service + */ + private function composeOutput(array $service): string + { + return json_encode(['services' => ['php' => $service]], JSON_THROW_ON_ERROR); + } + + /** + * @param array $options + */ + private function createConfig(array $options): DockerComposerConfig + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => $options, + ]); + + return DockerComposerConfig::fromComposer($composer); + } + + private function createResolver(): DockerComposeWorkdirResolver + { + return new DockerComposeWorkdirResolver(new DockerComposeCommandBuilder()); + } + + private function createRunner(MockOutputCapturingProcessRunner $runner): DockerComposeRunner + { + return new DockerComposeRunner($runner, new DockerComposeCommandBuilder()); + } +} diff --git a/tests/Unit/DockerComposerPluginTest.php b/tests/Unit/DockerComposerPluginTest.php index 1c37e7c..0726bce 100644 --- a/tests/Unit/DockerComposerPluginTest.php +++ b/tests/Unit/DockerComposerPluginTest.php @@ -643,6 +643,57 @@ public function testRedirectsInstallCommandBeforeHostExecution(): void self::assertStringContainsString('Running composer install in Docker Compose service php.', $io->getOutput()); } + public function testRedirectedCommandUsesActiveHostWorkingDirectoryMapping(): void + { + $previousCwd = getcwd(); + self::assertIsString($previousCwd); + + $projectRoot = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'docker-composer-' . bin2hex(random_bytes(8)); + $packageDirectory = $projectRoot . DIRECTORY_SEPARATOR . 'packages' . DIRECTORY_SEPARATOR . 'demo'; + self::assertTrue(mkdir($packageDirectory, 0777, true)); + $hostMountedDirectory = realpath($projectRoot); + $hostPackageDirectory = realpath($packageDirectory); + self::assertIsString($hostMountedDirectory); + self::assertIsString($hostPackageDirectory); + + try { + self::assertTrue(chdir($hostMountedDirectory)); + [$composer, $io] = $this->createComposer([], [ + 'docker-composer' => [ + 'service' => 'php', + ], + ]); + $runner = new MockOutputCapturingProcessRunner( + [0, 0, 0], + outputs: [ + $this->composeConfigWithVolumes([ + ['type' => 'bind', 'source' => $hostMountedDirectory, 'target' => '/workspace'], + ]), + 'php' . PHP_EOL, + ], + ); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); + $insidePath = $hostPackageDirectory . DIRECTORY_SEPARATOR . 'local-package'; + $siblingPath = $hostMountedDirectory . DIRECTORY_SEPARATOR . 'packages' . DIRECTORY_SEPARATOR . 'sibling-package'; + $input = new ArgvInput(['composer', 'require', $insidePath, $siblingPath]); + $input->setInteractive(false); + $event = new PreCommandRunEvent(PluginEvents::PRE_COMMAND_RUN, $input, 'require'); + + self::assertTrue(chdir($hostPackageDirectory)); + $plugin->activate($composer, $io); + $this->assertCommandExecutionStops($plugin, $event); + + self::assertSame('/workspace/packages/demo', $runner->commands[2][5]); + self::assertSame('/workspace/packages/demo/local-package', $runner->commands[2][11]); + self::assertSame($siblingPath, $runner->commands[2][12]); + } finally { + chdir($previousCwd); + rmdir($packageDirectory); + rmdir(dirname($packageDirectory)); + rmdir($projectRoot); + } + } + public function testRedirectsDependencyCommandsBeforeHostExecution(): void { [$composer, $io] = $this->createComposer([], [ @@ -1114,6 +1165,20 @@ private function composeConfigWithWorkingDir(): string ], JSON_THROW_ON_ERROR); } + /** + * @param list> $volumes + */ + private function composeConfigWithVolumes(array $volumes): string + { + return json_encode([ + 'services' => [ + 'php' => [ + 'volumes' => $volumes, + ], + ], + ], JSON_THROW_ON_ERROR); + } + private function assertScriptExecutionFails(DockerComposerPlugin $plugin, ScriptEvent $event): ScriptExecutionException { try { diff --git a/tests/Unit/Mocks/MockCommandBuilder.php b/tests/Unit/Mocks/MockCommandBuilder.php index f5607bc..e9908a1 100644 --- a/tests/Unit/Mocks/MockCommandBuilder.php +++ b/tests/Unit/Mocks/MockCommandBuilder.php @@ -25,8 +25,13 @@ public function buildConfigCommand(DockerComposeOptions $config): array return ['php', '-r', 'echo \'{"services":{"php":{"working_dir":"/usr/src/app"}}}\';']; } - public function buildScriptCommand(DockerComposeOptions $config, ScriptEvent $event, bool $interactive): array - { + public function buildScriptCommand( + DockerComposeOptions $config, + ScriptEvent $event, + bool $interactive, + ?string $hostPathRoot = null, + ?string $containerPathRoot = null, + ): array { return ['php', '-r', 'exit(0);']; } } From c495736720abc5cd0bfd3c8d2254d7ac03bec720 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Thu, 14 May 2026 23:30:46 +0200 Subject: [PATCH 04/10] cleanup: remove unused stubs --- stubs/PHPStan/Testing/functions.stub.php | 46 ------------------------ stubs/PHPStan/debugScope.stub.php | 22 ------------ stubs/PHPStan/dumpType.stub.php | 25 ------------- 3 files changed, 93 deletions(-) delete mode 100644 stubs/PHPStan/Testing/functions.stub.php delete mode 100644 stubs/PHPStan/debugScope.stub.php delete mode 100644 stubs/PHPStan/dumpType.stub.php diff --git a/stubs/PHPStan/Testing/functions.stub.php b/stubs/PHPStan/Testing/functions.stub.php deleted file mode 100644 index 730c1e5..0000000 --- a/stubs/PHPStan/Testing/functions.stub.php +++ /dev/null @@ -1,46 +0,0 @@ - Date: Thu, 14 May 2026 23:31:38 +0200 Subject: [PATCH 05/10] chore: add .aiignore, update .gitignore --- .aiignore | 7 +++++++ .gitignore | 1 + 2 files changed, 8 insertions(+) create mode 100644 .aiignore diff --git a/.aiignore b/.aiignore new file mode 100644 index 0000000..150cff5 --- /dev/null +++ b/.aiignore @@ -0,0 +1,7 @@ +/.op +/var + +.DS_Store +.envrc +*.log +*.tmp diff --git a/.gitignore b/.gitignore index c014941..49a9154 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ /phpunit.xml .DS_Store +.envrc .git .op Thumbs.db From f45fcd5802f805f9a16f88d9ca7e7f427e8be692 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Thu, 14 May 2026 23:33:05 +0200 Subject: [PATCH 06/10] tests: add strict PHPStan rules --- .idea/docker-composer.iml | 11 +++++++++++ .idea/php.xml | 11 +++++++++++ composer.json | 6 ++++++ phpstan.dist.neon | 24 ++++++------------------ src/Laravel/ServiceProvider.php | 9 +++++---- tests/Unit/Mocks/MockProcessExecutor.php | 7 +++---- 6 files changed, 42 insertions(+), 26 deletions(-) diff --git a/.idea/docker-composer.iml b/.idea/docker-composer.iml index 3a23a7a..8dd820b 100644 --- a/.idea/docker-composer.iml +++ b/.idea/docker-composer.iml @@ -90,6 +90,17 @@ + + + + + + + + + + + diff --git a/.idea/php.xml b/.idea/php.xml index 4ec6d1d..729643c 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -113,6 +113,17 @@ + + + + + + + + + + + diff --git a/composer.json b/composer.json index f11aa8b..3a20531 100644 --- a/composer.json +++ b/composer.json @@ -25,10 +25,13 @@ "require-dev": { "composer/composer": ">=1.1", "empaphy/filharmonic": "^1", + "illuminate/console": ">=10", "illuminate/support": ">=10", "friendsofphp/php-cs-fixer": "^3", + "phpstan/extension-installer": "^1", "phpstan/phpstan": "^2", "phpstan/phpstan-phpunit": "^2", + "phpstan/phpstan-strict-rules": "^2", "phpunit/phpunit": ">=10" }, "autoload": { @@ -47,6 +50,9 @@ "PKSA-z3gr-8qht-p93v" : "'PHPUnit Vulnerable to Unsafe Deserialization in PHPT Code Coverage Handling' – We don't use PHPT" } + }, + "allow-plugins": { + "phpstan/extension-installer": true } }, "scripts": { diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 7e35e4f..caeb74c 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -1,27 +1,15 @@ -includes: - - vendor/phpstan/phpstan-phpunit/extension.neon - parameters: - checkFunctionNameCase: true - checkInternalClassCaseSensitivity: true - checkUninitializedProperties: true - + checkUninitializedProperties: true editorUrl: 'phpstorm://open?file=%%file%%&line=%%line%%' - -# ignoreErrors: -# - identifier: missingType.generics -# - identifier: missingType.iterableValue - level: 8 - reportWrongPhpDocTypeInVarTag: true - - resultCachePath: var/cache/phpstan/resultCache.php - tipsOfTheDay: false - treatPhpDocTypesAsCertain: false - paths: - src/ - tests/ + resultCachePath: var/cache/phpstan/resultCache.php + strictRules: + disallowedShortTernary: false + tipsOfTheDay: false + treatPhpDocTypesAsCertain: false services: cacheStorage: diff --git a/src/Laravel/ServiceProvider.php b/src/Laravel/ServiceProvider.php index 4a4e5a3..9199c52 100644 --- a/src/Laravel/ServiceProvider.php +++ b/src/Laravel/ServiceProvider.php @@ -17,6 +17,7 @@ use empaphy\docker_composer\DockerComposeRunner; use empaphy\docker_composer\EnvironmentContainerDetector; use empaphy\docker_composer\ShellProcessRunner; +use Illuminate\Console\Events\CommandStarting; use Illuminate\Contracts\Console\Kernel; use Illuminate\Support\ServiceProvider as IlluminateServiceProvider; use Throwable; @@ -145,7 +146,7 @@ private function listenForArtisanCommands(Config $config, Redirector $redirector return; } - $events->listen('Illuminate\Console\Events\CommandStarting', function (object $event) use ($config, $redirector, $arguments, $projectRoot): void { + $events->listen(CommandStarting::class, function (CommandStarting $event) use ($config, $redirector, $arguments, $projectRoot): void { $commandName = $this->getEventCommandName($event); $this->exitIfRedirected($redirector->redirect( $config, @@ -183,13 +184,13 @@ private function getCommandNameFromArguments(array $arguments): ?string /** * Gets the Artisan command name from a Laravel command event. * - * @param object $event - * The `Illuminate\Console\Events\CommandStarting` event. + * @param CommandStarting $event + * The event. * * @return string|null * Returns the command name, or `null` when unavailable. */ - private function getEventCommandName(object $event): ?string + private function getEventCommandName(CommandStarting $event): ?string { $command = $event->command ?? null; diff --git a/tests/Unit/Mocks/MockProcessExecutor.php b/tests/Unit/Mocks/MockProcessExecutor.php index dd2104a..353b631 100644 --- a/tests/Unit/Mocks/MockProcessExecutor.php +++ b/tests/Unit/Mocks/MockProcessExecutor.php @@ -18,15 +18,14 @@ final class MockProcessExecutor extends ProcessExecutor */ public array $ttyCommands = []; - /** - * @noinspection PhpMissingParentConstructorInspection - */ public function __construct( private readonly int $executeExitCode, private readonly int $ttyExitCode, private readonly string $testErrorOutput, private readonly string $testOutput = '', - ) {} + ) { + parent::__construct(); + } /** * @param string|non-empty-list $command From 02d4a9ee578ea3b7d505df3d36c1139b971af45c Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Thu, 14 May 2026 23:38:08 +0200 Subject: [PATCH 07/10] tests: add behat --- .idea/docker-composer.iml | 8 +++++++ .idea/php.xml | 7 ++++++ AGENTS.md | 18 +++++++++++++-- composer.json | 33 ++++++++++++++++----------- features/bootstrap/FeatureContext.php | 22 ++++++++++++++++++ phpstan.dist.neon | 1 + phpunit.dist.xml | 1 + 7 files changed, 75 insertions(+), 15 deletions(-) create mode 100644 features/bootstrap/FeatureContext.php diff --git a/.idea/docker-composer.iml b/.idea/docker-composer.iml index 8dd820b..41b59e0 100644 --- a/.idea/docker-composer.iml +++ b/.idea/docker-composer.iml @@ -4,6 +4,7 @@ + @@ -90,6 +91,8 @@ + + @@ -101,6 +104,11 @@ + + + + + diff --git a/.idea/php.xml b/.idea/php.xml index 729643c..8c67ba6 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -124,6 +124,13 @@ + + + + + + + diff --git a/AGENTS.md b/AGENTS.md index d539edb..7ab789a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,16 +1,30 @@ +## Project Structure +- `config/`: Configuration files for Laravel support. +- `features/`: Behat feature test suite, written in Gerkin. + - `features/bootstrap/`: Behat Context classes. +- `src/`: Source files organized by domain. Follows PSR-4. + - `src/Laravel/`: Source code related to Laravel support. +- `tests/`: Test suites, test-specific traits and seeders. + - `tests/Unit/`: Unit test suite for PHPUnit. + - `tests/Integration/`: Integration test suite for PHPUnit. +- `vendor/`: Vendor packages, installed with Composer. + ## General Instructions In commit messages use conventional commits and provide justification of the changes in the body. ## Plan Mode At the end of each plan, give me a list of unresolved questions to answer, if any. -When asking the user to choose an approach, consider whether implementing multiple approaches that can be chained as fallbacks is the recommended options. +When asking the user to choose an approach, consider whether chaining multiple approaches is also a valid or even the recommended option. ## Tests When writing unit tests, create a TestCase class for each class being tested. At the end of every task, execute these commands to ensure the quality of the code: -- `composer style-fix` +- `composer cs-fix` - `composer check` +### Feature Tests +When adding new behavior, write a Behat feature spec that covers it. When changing behavior, update the corresponding Behat feature spec. + ### Coverage All unit tests are required to have both a branch and line coverage of 100%. diff --git a/composer.json b/composer.json index 3a20531..3a3ce3e 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ "composer-plugin-api": ">=1.1" }, "require-dev": { + "behat/behat": "^3", "composer/composer": ">=1.1", "empaphy/filharmonic": "^1", "illuminate/console": ">=10", @@ -56,21 +57,27 @@ } }, "scripts": { - "check": ["@style-check", "@stan", "@test"], - "style-check": "XDEBUG_MODE=off php-cs-fixer check", - "style-fix": "XDEBUG_MODE=off php-cs-fixer fix", - "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" + "behat": "XDEBUG_MODE=off behat", + "check": ["@cs-check", "@stan", "@test"], + "cs-check": "XDEBUG_MODE=off php-cs-fixer check", + "cs-fix": "XDEBUG_MODE=off php-cs-fixer fix", + "phpstan": "XDEBUG_MODE=off phpstan analyse --memory-limit=1G", + "test": [ + "XDEBUG_MODE=coverage phpunit --coverage-text", + "@feat" + ], + "phpunit-integration": "XDEBUG_MODE=off phpunit --testsuite Integration", + "phpunit": "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] [ ...]`" + "behat": "Run Behat feature tests using `behat [options] [--] [...]`.", + "check": "Perform all automated checks.", + "cs-check": "Check coding style using `php-cs-fixer check [options] [--] [...]`", + "cs-fix": "Fix coding style using `php-cs-fixer fix [options] [--] [...]`", + "phptan": "Perform static analysis using `phpstan analyse [options] [--] [...]`", + "test": "Run all test suites.", + "phpunit-integration": "Run Integration test suite using `phpunit --testsuite Integration [options] [ ...]`", + "phpunit": "Run Unit test suite using `phpunit --testsuite Unit --coverage-text [options] [ ...]`" }, "extra": { "class": "empaphy\\docker_composer\\DockerComposerPlugin", diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php new file mode 100644 index 0000000..fcde4c1 --- /dev/null +++ b/features/bootstrap/FeatureContext.php @@ -0,0 +1,22 @@ + Date: Fri, 15 May 2026 00:38:24 +0200 Subject: [PATCH 08/10] tests(Behat): migrate Integration test suite to Behat features --- .aiignore | 1 - .gitattributes | 41 +- .github/workflows/ci.yml | 10 +- .github/workflows/feature-tests.yml | 40 ++ .github/workflows/integration-tests.yml | 55 -- .idea/codeStyles/Project.xml | 3 + .idea/docker-composer.iml | 1 + .php-cs-fixer.dist.php | 1 + AGENTS.md | 39 +- README.md | 9 +- behat.dist.yaml | 25 + composer.json | 20 +- features/bootstrap/FeatureContext.php | 128 +++- .../InteractsWithTemporaryProjects.php | 310 +++++++++ features/bootstrap/LaravelContext.php | 330 +++++++++ features/composer_plugin.feature | 39 ++ features/laravel.feature | 21 + phpunit.dist.xml | 6 - src/DockerComposeCommandBuilder.php | 8 +- src/DockerComposerConfig.php | 8 +- src/DockerComposerPlugin.php | 20 +- src/Laravel/Config.php | 2 +- src/ProcessRunner.php | 2 +- .../DockerComposerIntegrationTest.php | 635 ------------------ tests/Unit/DockerComposerPluginTest.php | 2 +- 25 files changed, 972 insertions(+), 784 deletions(-) create mode 100644 .github/workflows/feature-tests.yml delete mode 100644 .github/workflows/integration-tests.yml create mode 100644 behat.dist.yaml create mode 100644 features/bootstrap/InteractsWithTemporaryProjects.php create mode 100644 features/bootstrap/LaravelContext.php create mode 100644 features/composer_plugin.feature create mode 100644 features/laravel.feature delete mode 100644 tests/Integration/DockerComposerIntegrationTest.php diff --git a/.aiignore b/.aiignore index 150cff5..56f5232 100644 --- a/.aiignore +++ b/.aiignore @@ -1,5 +1,4 @@ /.op -/var .DS_Store .envrc diff --git a/.gitattributes b/.gitattributes index e1f3001..2097888 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,18 +1,31 @@ -/.github export-ignore -/.idea export-ignore -/.run export-ignore -/tests export-ignore +/.github export-ignore +/.idea export-ignore +/.op export-ignore +/.run export-ignore +/features export-ignore +/tests export-ignore +/var export-ignore +/vendor export-ignore -/.editorconfig export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore -/.php-cs-fixer.dist.php export-ignore -/docker-compose.yaml export-ignore -/phpdoc.dist.xml export-ignore -/phpstan.dist.neon export-ignore -/phpstan.neon.example export-ignore -/phpunit.dist.xml export-ignore -/README.md export-ignore +/.aiignore export-ignore +/.editorconfig export-ignore +/.env export-ignore +/.gitattributes export-ignore +/.gitignore export-ignore +/.php-cs-fixer.dist.php export-ignore +/AGENTS.md export-ignore +/behat.dist.yaml export-ignore +/composer.json export-ignore +/composer.lock export-ignore +/docker-compose.yaml export-ignore +/docker-compose.override.yaml export-ignore +/phpdoc.dist.xml export-ignore +/phpstan.dist.neon export-ignore +/phpstan.neon.example export-ignore +/phpunit.dist.xml export-ignore +/README.md export-ignore + +.envrc export-ignore *.php diff=php *.phar -diff diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26562b7..7f0fa2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,14 +30,12 @@ jobs: with: os: ${{ matrix.os }} - integration-tests: - name: Integration Tests + feature-tests: + name: Feature Tests strategy: matrix: - os: [ubuntu] - composer-version: ['1.10.27', '2.2.27', 'v2'] + os: [ubuntu, windows] fail-fast: false - uses: ./.github/workflows/integration-tests.yml + uses: ./.github/workflows/feature-tests.yml with: os: ${{ matrix.os }} - composer-version: ${{ matrix.composer-version }} diff --git a/.github/workflows/feature-tests.yml b/.github/workflows/feature-tests.yml new file mode 100644 index 0000000..47c1c7f --- /dev/null +++ b/.github/workflows/feature-tests.yml @@ -0,0 +1,40 @@ +name: Feature Tests + +on: + workflow_call: + inputs: + os: + description: Operating System + required: false + type: string + default: ubuntu + +permissions: + checks: write + contents: read + issues: read + pull-requests: write + +jobs: + feature-tests: + name: Behat + strategy: + matrix: + php-version: ['8.1', '8.2', '8.3', '8.4', '8.5'] + behat-profile: ['latest', 'lowest'] + fail-fast: false + runs-on: ${{ inputs.os }}-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup PHP and Install Composer Packages + uses: ./.github/actions/setup + with: + php-version: '${{ matrix.php-version }}' + + - name: Run Docker-Composer Feature Tests + shell: bash + env: + XDEBUG_MODE: 'off' + run: behat --profile ${{ inputs.behat-profile }} diff --git a/.github/workflows/integration-tests.yml b/.github/workflows/integration-tests.yml deleted file mode 100644 index 59cdbe1..0000000 --- a/.github/workflows/integration-tests.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Integration Tests - -on: - workflow_call: - inputs: - os: - description: Operating System - required: false - type: string - default: ubuntu - composer-version: - description: Composer version to exercise through the integration fixture - required: false - type: string - default: v2 - -permissions: - contents: read - -jobs: - integration-tests: - name: Docker Compose / Composer ${{ inputs.composer-version }} - runs-on: ${{ inputs.os }}-latest - steps: - - name: Checkout - uses: actions/checkout@v6 - - - name: Setup PHP and Install Composer Packages - uses: ./.github/actions/setup - with: - php-version: '8.3' - composer-version: 'v2' - - - name: Setup Composer ${{ inputs.composer-version }} Under Test - if: inputs.composer-version != 'v2' - uses: shivammathur/setup-php@v2 - with: - php-version: '8.3' - tools: composer:${{ inputs.composer-version }} - coverage: none - - - name: Show Tool Versions - shell: bash - run: | - php --version - composer --version - docker --version - docker compose version - - - name: Run Docker Composer Integration Tests - shell: bash - env: - DOCKER_COMPOSER_TEST_COMPOSER_VERSION: ${{ inputs.composer-version }} - XDEBUG_MODE: 'off' - run: vendor/bin/phpunit --testsuite Integration diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml index 159f3ab..c5a50bc 100644 --- a/.idea/codeStyles/Project.xml +++ b/.idea/codeStyles/Project.xml @@ -183,5 +183,8 @@ \ No newline at end of file diff --git a/.idea/docker-composer.iml b/.idea/docker-composer.iml index 41b59e0..eff5559 100644 --- a/.idea/docker-composer.iml +++ b/.idea/docker-composer.iml @@ -109,6 +109,7 @@ + diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index c90aed6..3262a37 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -4,6 +4,7 @@ $finder = (new PhpCsFixer\Finder()) ->in([ + __DIR__ . '/features/bootstrap', __DIR__ . '/src', __DIR__ . '/tests', ]); diff --git a/AGENTS.md b/AGENTS.md index 7ab789a..14a53a4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,43 +1,46 @@ ## Project Structure -- `config/`: Configuration files for Laravel support. -- `features/`: Behat feature test suite, written in Gerkin. - - `features/bootstrap/`: Behat Context classes. -- `src/`: Source files organized by domain. Follows PSR-4. - - `src/Laravel/`: Source code related to Laravel support. -- `tests/`: Test suites, test-specific traits and seeders. - - `tests/Unit/`: Unit test suite for PHPUnit. - - `tests/Integration/`: Integration test suite for PHPUnit. -- `vendor/`: Vendor packages, installed with Composer. -## General Instructions +``` +docker-composer/ +├─╴.github/ — GitHub Actions workflows. +├─╴config/ — Configuration files for Laravel support. +├─╴features/ — Behat feature test suite, written in Gerkin. +│ └─╴bootstrap/ — Behat Context classes. +├─╴src/ — Source files organized by domain. Follows PSR-4. +│ └─╴Laravel/ — Source code related to Laravel support. +├─╴tests/ — Unit tests. +└─╴vendor/ — Vendor packages, installed by Composer. +``` + +## Instructions In commit messages use conventional commits and provide justification of the changes in the body. -## Plan Mode +### Plan Mode At the end of each plan, give me a list of unresolved questions to answer, if any. When asking the user to choose an approach, consider whether chaining multiple approaches is also a valid or even the recommended option. -## Tests +### Tests When writing unit tests, create a TestCase class for each class being tested. At the end of every task, execute these commands to ensure the quality of the code: - `composer cs-fix` - `composer check` -### Feature Tests +#### Feature Tests When adding new behavior, write a Behat feature spec that covers it. When changing behavior, update the corresponding Behat feature spec. -### Coverage +#### Coverage All unit tests are required to have both a branch and line coverage of 100%. -## Architecture +### Architecture DRY: Don't Repeat Yourself — before adding new code, inspect existing abstractions and extend/reuse them. Framework integrations belong in framework-named subdirectories under `src/`. Do not duplicate code when a shared abstraction can cover the behavior. -## Coding Style +### Coding Style 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 +### PHPDoc 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: @@ -117,5 +120,5 @@ class Foo } ``` -## Tools +### Tools If a tool, command or integration fails that one would expect to be working, do not try a different approach. Instead, investigate the problem and suggest a fix to the user. diff --git a/README.md b/README.md index 6d974b2..f69d11d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# docker-composer +# Docker-Composer -Composer plugin that ensures scripts are always executed within a Docker Compose service. +**Docker-Composer** is a Composer plugin that ensures scripts are always executed within a Docker Compose service. ## Installation @@ -9,8 +9,7 @@ composer config allow-plugins.empaphy/docker-composer true composer require --dev empaphy/docker-composer ``` -Composer 2.2 and newer require plugins to be allowed explicitly. Composer 1 ignores -`allow-plugins`. +Composer 2.2 and newer require plugins to be allowed explicitly. ## Configuration @@ -109,7 +108,7 @@ requirements are resolved from inside the configured service: ## Laravel -The package also registers a Laravel service provider through package +**Docker-Composer** also registers a Laravel service provider through package autodiscovery. Publish and enable the Laravel config: ```bash diff --git a/behat.dist.yaml b/behat.dist.yaml new file mode 100644 index 0000000..cf535a0 --- /dev/null +++ b/behat.dist.yaml @@ -0,0 +1,25 @@ +default: + gherkin: { cache: '%paths.base%/var/cache/behat/gherkin' } + testers: { rerun_cache: '%paths.base%/var/cache/behat/rerun' } + suites: + composer_plugin: + paths: ['%paths.base%/features/composer_plugin.feature'] + contexts: + - FeatureContext + laravel: + paths: ['%paths.base%/features/laravel.feature'] + contexts: + - LaravelContext + +lowest: + gherkin: { cache: '%paths.base%/var/cache/behat/gherkin' } + testers: { rerun_cache: '%paths.base%/var/cache/behat/rerun' } + suites: + composer_plugin: + paths: ['%paths.base%/features/composer_plugin.feature'] + contexts: + - FeatureContext: { dependencyResolutionMode: prefer-lowest } + laravel: + paths: ['%paths.base%/features/laravel.feature'] + contexts: + - LaravelContext: { dependencyResolutionMode: prefer-lowest } diff --git a/composer.json b/composer.json index 3a3ce3e..17a065d 100644 --- a/composer.json +++ b/composer.json @@ -20,11 +20,11 @@ }, "require": { "php": ">=8.1", - "composer-plugin-api": ">=1.1" + "composer-plugin-api": ">=2" }, "require-dev": { "behat/behat": "^3", - "composer/composer": ">=1.1", + "composer/composer": ">=2", "empaphy/filharmonic": "^1", "illuminate/console": ">=10", "illuminate/support": ">=10", @@ -57,16 +57,15 @@ } }, "scripts": { - "behat": "XDEBUG_MODE=off behat", - "check": ["@cs-check", "@stan", "@test"], + "behat": [ + "XDEBUG_MODE=off behat", + "XDEBUG_MODE=off behat --profile lowest" + ], + "check": ["@cs-check", "@phpstan", "@test"], "cs-check": "XDEBUG_MODE=off php-cs-fixer check", "cs-fix": "XDEBUG_MODE=off php-cs-fixer fix", "phpstan": "XDEBUG_MODE=off phpstan analyse --memory-limit=1G", - "test": [ - "XDEBUG_MODE=coverage phpunit --coverage-text", - "@feat" - ], - "phpunit-integration": "XDEBUG_MODE=off phpunit --testsuite Integration", + "test": ["@phpunit", "@behat"], "phpunit": "XDEBUG_MODE=coverage phpunit --testsuite Unit --coverage-text" }, "scripts-descriptions": { @@ -74,9 +73,8 @@ "check": "Perform all automated checks.", "cs-check": "Check coding style using `php-cs-fixer check [options] [--] [...]`", "cs-fix": "Fix coding style using `php-cs-fixer fix [options] [--] [...]`", - "phptan": "Perform static analysis using `phpstan analyse [options] [--] [...]`", + "phpstan": "Perform static analysis using `phpstan analyse [options] [--] [...]`", "test": "Run all test suites.", - "phpunit-integration": "Run Integration test suite using `phpunit --testsuite Integration [options] [ ...]`", "phpunit": "Run Unit test suite using `phpunit --testsuite Unit --coverage-text [options] [ ...]`" }, "extra": { diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index fcde4c1..234605c 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -1,22 +1,132 @@ projectDirectory = $this->createProject([]); + } + + #[Given('a Composer project configured for exec mode')] + public function createComposerProjectConfiguredForExecMode(): void + { + $this->projectDirectory = $this->createProject([ + 'service' => 'php', + 'mode' => 'exec', + 'compose-files' => 'docker-compose.yaml', + ]); + } + + #[Given('a Composer project configured for exec install redirection')] + public function createComposerProjectConfiguredForExecInstallRedirection(): void + { + $this->projectDirectory = $this->createProject([ + 'service' => 'php', + 'mode' => 'exec', + 'compose-files' => 'docker-compose.yaml', + 'workdir' => '/usr/src/app', + ]); + } + + #[Given('a Composer project configured with service mapping override')] + public function createComposerProjectConfiguredWithServiceMappingOverride(): void + { + $this->projectDirectory = $this->createProject([ + 'service' => 'php', + 'service-mapping' => [ + 'php_tools' => 'mark', + ], + 'compose-files' => 'docker-compose.yaml', + 'workdir' => '/usr/src/app', + ]); + } + + #[Given('a Composer project configured for run mode')] + public function createComposerProjectConfiguredForRunMode(): void + { + $this->projectDirectory = $this->createProject([ + 'service' => 'php', + 'mode' => 'run', + 'compose-files' => 'docker-compose.yaml', + 'workdir' => '/usr/src/app', + ]); + } + + #[Given('a Composer project without Docker-Composer configuration')] + public function createComposerProjectWithoutDockerComposerConfiguration(): void + { + $this->projectDirectory = $this->createProject([]); + } + /** - * Initializes context. - * - * Every scenario gets its own context instance. - * You can also pass arbitrary arguments to the - * context constructor through behat.yml. + * @param array $dockerComposerConfig + * @param list>|null $repositories */ - public function __construct() + private function createProject(array $dockerComposerConfig, ?array $repositories = null, string $requireVersion = '*'): string { + $projectDirectory = $this->createTemporaryProjectDirectory('docker-composer-integration-'); + + $composerJson = [ + 'name' => 'empaphy/docker-composer-integration', + 'description' => 'Temporary docker-composer integration fixture.', + 'minimum-stability' => 'dev', + 'prefer-stable' => true, + 'repositories' => $repositories ?? [[ + 'type' => 'path', + 'url' => dirname(__DIR__, 2), + 'options' => ['symlink' => false], + ]], + 'require' => [ + 'empaphy/docker-composer' => $requireVersion, + ], + 'config' => [ + 'allow-plugins' => [ + 'empaphy/docker-composer' => true, + ], + ], + 'scripts' => [ + 'mark' => '@php -r "file_put_contents(\'result.txt\', getenv(\'DOCKER_COMPOSER_TEST_MARK\') ?: (getenv(\'DOCKER_COMPOSER_INSIDE\') ?: \'host\'));"', + 'post-autoload-dump' => '@php -r "file_put_contents(\'lifecycle.txt\', getenv(\'DOCKER_COMPOSER_INSIDE\') ?: \'host\');"', + ], + 'extra' => [ + 'docker-composer' => $dockerComposerConfig, + ], + ]; + + $this->writeJson($projectDirectory . '/composer.json', $composerJson); + file_put_contents($projectDirectory . '/docker-compose.yaml', sprintf(<<<'YAML' +services: + php: + image: %s + command: ['sleep', 'infinity'] + working_dir: /usr/src/app + volumes: + - { type: bind, source: '.', target: '/usr/src/app' } + php_tools: + image: %s + command: ['sleep', 'infinity'] + environment: + DOCKER_COMPOSER_TEST_MARK: override + working_dir: /usr/src/app + volumes: + - { type: bind, source: '.', target: '/usr/src/app' } +YAML, $this->getComposerImage(), $this->getComposerImage())); + + return $projectDirectory; } } diff --git a/features/bootstrap/InteractsWithTemporaryProjects.php b/features/bootstrap/InteractsWithTemporaryProjects.php new file mode 100644 index 0000000..3fad8d3 --- /dev/null +++ b/features/bootstrap/InteractsWithTemporaryProjects.php @@ -0,0 +1,310 @@ + + */ + private array $projectDirectories = []; + + /** + * The temporary project used by the current step. + */ + protected ?string $projectDirectory = null; + + /** + * The process result captured from the latest command. + * + * @var array{stdout: string, stderr: string, exitCode: int}|null + */ + protected ?array $lastCommandResult = null; + + /** + * Dependency resolution mode selected by the active Behat profile. + */ + private string $dependencyResolutionMode = 'latest'; + + public function __construct(string $dependencyResolutionMode = 'latest') + { + $this->setDependencyResolutionMode($dependencyResolutionMode); + } + + #[AfterScenario] + public function cleanupProjects(): void + { + foreach ($this->projectDirectories as $projectDirectory) { + $this->runCommand(['docker', 'compose', 'down', '--volumes', '--remove-orphans'], $projectDirectory, [], false); + $this->removeDirectory($projectDirectory); + } + + $this->projectDirectories = []; + $this->projectDirectory = null; + $this->lastCommandResult = null; + } + + #[When('I run :command in the project')] + public function runCommandInProject(string $command): void + { + $this->lastCommandResult = $this->runShellCommand( + $command, + $this->getProjectDirectory(), + $this->explicitProjectCommandEnvironment(), + ); + } + + #[When('I run :command in the :service service of the Composer project')] + public function runCommandInComposerProjectService(string $command, string $service): void + { + $this->runShellCommandInProjectService($command, $service); + } + + #[When('I run :command in the :service service of the Laravel project')] + public function runCommandInLaravelProjectService(string $command, string $service): void + { + $this->runShellCommandInProjectService($command, $service); + } + + #[When('I delete the project file :path')] + public function deleteProjectFile(string $path): void + { + @unlink($this->getProjectDirectory() . '/' . $path); + } + + #[Then('the project file :path should contain :expected')] + public function assertProjectFileShouldContain(string $path, string $expected): void + { + $filePath = $this->getProjectDirectory() . '/' . $path; + + Assert::assertFileExists($filePath); + Assert::assertSame($expected, trim((string) file_get_contents($filePath))); + } + + #[Then('the last command error output should contain :expected')] + public function assertLastCommandErrorOutputShouldContain(string $expected): void + { + Assert::assertStringContainsString($expected, $this->getLastCommandResult()['stderr']); + } + + protected function getProjectDirectory(): string + { + if ($this->projectDirectory === null) { + throw new RuntimeException('No temporary project has been created for this scenario.'); + } + + return $this->projectDirectory; + } + + /** + * @return array{stdout: string, stderr: string, exitCode: int} + */ + protected function getLastCommandResult(): array + { + if ($this->lastCommandResult === null) { + throw new RuntimeException('No command has been run yet.'); + } + + return $this->lastCommandResult; + } + + protected function createTemporaryProjectDirectory(string $prefix): string + { + $projectDirectory = $this->getTempDir() . DIRECTORY_SEPARATOR . $prefix . bin2hex(random_bytes(8)); + if (! mkdir($projectDirectory, 0o777, true) && ! is_dir($projectDirectory)) { + throw new RuntimeException("Unable to create integration project directory `$projectDirectory`."); + } + + $this->projectDirectories[] = $projectDirectory; + + return $projectDirectory; + } + + protected function getComposerImage(): string + { + return 'composer:2'; + } + + /** + * @param array $data + */ + protected function writeJson(string $path, array $data): void + { + $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($encoded === false) { + throw new RuntimeException(sprintf('Unable to encode "%s".', $path)); + } + + file_put_contents($path, $encoded . PHP_EOL); + } + + /** + * @param list $command + * @param array $environment + * + * @return array{stdout: string, stderr: string, exitCode: int} + */ + protected function runCommand(array $command, string $workingDirectory, array $environment = [], bool $failOnError = true): array + { + return $this->runProcess($command, $workingDirectory, $environment, $failOnError); + } + + /** + * @param array $environment + * + * @return array{stdout: string, stderr: string, exitCode: int} + */ + protected function runShellCommand(string $command, string $workingDirectory, array $environment = [], bool $failOnError = true): array + { + return $this->runProcess($command, $workingDirectory, $environment, $failOnError); + } + + /** + * @param list|string $command + * @param array $environment + * + * @return array{stdout: string, stderr: string, exitCode: int} + */ + private function runProcess(array|string $command, string $workingDirectory, array $environment = [], bool $failOnError = true): array + { + $descriptorSpec = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + $processEnvironment = array_merge(getenv() ?: [], [ + 'COMPOSER_CACHE_DIR' => dirname(__DIR__, 2) . '/var/cache/composer', + 'COMPOSER_NO_INTERACTION' => '1', + ], $environment); + + $process = proc_open($command, $descriptorSpec, $pipes, $workingDirectory, $processEnvironment); + if (! is_resource($process)) { + throw new RuntimeException(sprintf('Unable to start command: %s', $this->formatCommand($command))); + } + + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $exitCode = proc_close($process); + + if ($failOnError && $exitCode !== 0) { + Assert::fail(sprintf( + "Command failed with exit code %d:\n%s\n\nSTDOUT:\n%s\n\nSTDERR:\n%s", + $exitCode, + $this->formatCommand($command), + $stdout, + $stderr, + )); + } + + return [ + 'stdout' => $stdout === false ? '' : $stdout, + 'stderr' => $stderr === false ? '' : $stderr, + 'exitCode' => $exitCode, + ]; + } + + private function runShellCommandInProjectService(string $command, string $service): void + { + $environment = $this->explicitProjectCommandEnvironment(); + $dockerCommand = [ + 'docker', + 'compose', + 'run', + '--rm', + '-T', + '--workdir', + '/usr/src/app', + '--env', + 'DOCKER_COMPOSER_INSIDE=1', + ]; + + foreach ($environment as $name => $value) { + $dockerCommand[] = '--env'; + $dockerCommand[] = $name . '=' . $value; + } + + $this->lastCommandResult = $this->runCommand( + array_merge($dockerCommand, [$service, 'sh', '-lc', $command]), + $this->getProjectDirectory(), + $environment, + ); + } + + /** + * @return array + */ + private function explicitProjectCommandEnvironment(): array + { + if ($this->dependencyResolutionMode === 'prefer-lowest') { + return ['COMPOSER_PREFER_LOWEST' => '1']; + } + + return []; + } + + /** + * @param list|string $command + */ + private function formatCommand(array|string $command): string + { + if (is_string($command)) { + return $command; + } + + return implode(' ', $command); + } + + private function setDependencyResolutionMode(string $dependencyResolutionMode): void + { + if (! in_array($dependencyResolutionMode, ['latest', 'prefer-lowest'], true)) { + throw new InvalidArgumentException(sprintf('Unsupported dependency resolution mode "%s".', $dependencyResolutionMode)); + } + + $this->dependencyResolutionMode = $dependencyResolutionMode; + } + + private function removeDirectory(string $directory): void + { + if (! is_dir($directory)) { + return; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($iterator as $fileInfo) { + if ($fileInfo->isDir() && ! $fileInfo->isLink()) { + rmdir($fileInfo->getPathname()); + } else { + unlink($fileInfo->getPathname()); + } + } + + rmdir($directory); + } + + private function getTempDir(): string + { + return dirname(__DIR__, 2) . '/var/tmp/features'; + //return rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR); + } +} diff --git a/features/bootstrap/LaravelContext.php b/features/bootstrap/LaravelContext.php new file mode 100644 index 0000000..3b27686 --- /dev/null +++ b/features/bootstrap/LaravelContext.php @@ -0,0 +1,330 @@ +projectDirectory = $this->createLaravelProject(); + } + + #[When('I configure Laravel Docker-Composer redirection')] + public function configureLaravelDockerComposerRedirection(): void + { + $this->writeLaravelDockerComposerConfig($this->getProjectDirectory()); + } + + #[Then('the Laravel package should autodiscover the Docker-Composer service provider')] + public function assertLaravelPackageAutodiscoversDockerComposerServiceProvider(): void + { + $packagesPath = $this->getProjectDirectory() . '/bootstrap/cache/packages.php'; + + Assert::assertFileExists($packagesPath); + + $packages = require $packagesPath; + if (! is_array($packages)) { + throw new RuntimeException(sprintf('Expected "%s" to return an array.', $packagesPath)); + } + + $package = $packages['empaphy/docker-composer'] ?? null; + if (! is_array($package)) { + throw new RuntimeException('Expected the Docker-Composer package to be discovered.'); + } + + $providers = $package['providers'] ?? null; + if (! is_array($providers)) { + throw new RuntimeException('Expected the Docker-Composer package to define providers.'); + } + + Assert::assertContains('empaphy\\docker_composer\\Laravel\\ServiceProvider', $providers); + } + + #[Then('the Laravel Docker-Composer configuration should exist')] + public function assertLaravelDockerComposerConfigurationShouldExist(): void + { + Assert::assertFileExists($this->getProjectDirectory() . '/config/docker_composer.php'); + } + + private function createLaravelProject(): string + { + $projectDirectory = $this->createTemporaryProjectDirectory('docker-composer-laravel-integration-'); + foreach ([ + 'app/Console/Commands', + 'app/Exceptions', + 'app/Console', + 'bootstrap/cache', + 'config', + 'scripts', + ] as $directory) { + $path = $projectDirectory . '/' . $directory; + if (! is_dir($path) && ! mkdir($path, 0777, true) && ! is_dir($path)) { + throw new RuntimeException(sprintf('Unable to create directory "%s".', $path)); + } + } + + $this->writeJson($projectDirectory . '/composer.json', [ + 'name' => 'empaphy/docker-composer-laravel-integration', + 'description' => 'Temporary docker-composer Laravel integration fixture.', + 'minimum-stability' => 'dev', + 'prefer-stable' => true, + 'repositories' => [[ + 'type' => 'path', + 'url' => dirname(__DIR__, 2), + 'options' => ['symlink' => false], + ]], + 'require' => [ + 'laravel/framework' => '^12.0', + 'empaphy/docker-composer' => '*', + ], + 'autoload' => [ + 'psr-4' => [ + 'App\\' => 'app/', + ], + ], + 'config' => [ + 'allow-plugins' => [ + 'empaphy/docker-composer' => true, + ], + ], + 'scripts' => [ + 'post-autoload-dump' => [ + 'Illuminate\\Foundation\\ComposerScripts::postAutoloadDump', + '@php artisan package:discover --ansi', + ], + ], + ]); + + file_put_contents($projectDirectory . '/artisan', <<<'PHP' +#!/usr/bin/env php +make(Kernel::class); +$input = new ArgvInput(); +$status = $kernel->handle($input, new ConsoleOutput()); +$kernel->terminate($input, $status); + +exit($status); +PHP); + chmod($projectDirectory . '/artisan', 0755); + + file_put_contents($projectDirectory . '/bootstrap/app.php', <<<'PHP' +singleton(KernelContract::class, Kernel::class); +$app->singleton(ExceptionHandler::class, Handler::class); + +return $app; +PHP); + + file_put_contents($projectDirectory . '/config/app.php', <<<'PHP' + 'Docker-Composer Test', + 'env' => 'testing', + 'debug' => true, + 'url' => 'http://localhost', + 'timezone' => 'UTC', + 'locale' => 'en', + 'fallback_locale' => 'en', + 'faker_locale' => 'en_US', + 'key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', + 'cipher' => 'AES-256-CBC', + 'providers' => ServiceProvider::defaultProviders()->toArray(), +]; +PHP); + + file_put_contents($projectDirectory . '/app/Console/Kernel.php', <<<'PHP' + + */ + protected $commands = [ + MarkCommand::class, + ClassMappedCommand::class, + HostOnlyCommand::class, + ]; +} +PHP); + + file_put_contents($projectDirectory . '/app/Exceptions/Handler.php', <<<'PHP' +make(Kernel::class)->bootstrap(); + +file_put_contents(__DIR__ . '/../script.txt', getenv('DOCKER_COMPOSER_TEST_MARK') ?: (getenv('DOCKER_COMPOSER_INSIDE') ?: 'host')); +PHP); + chmod($projectDirectory . '/scripts/bootstrap.php', 0755); + + file_put_contents($projectDirectory . '/docker-compose.yaml', sprintf(<<<'YAML' +services: + php: + image: %s + command: ['sleep', 'infinity'] + working_dir: /usr/src/app + volumes: + - { type: bind, source: '.', target: '/usr/src/app' } + php_tools: + image: %s + command: ['sleep', 'infinity'] + environment: + DOCKER_COMPOSER_TEST_MARK: mapped + working_dir: /usr/src/app + volumes: + - { type: bind, source: '.', target: '/usr/src/app' } +YAML, $this->getComposerImage(), $this->getComposerImage())); + + return $projectDirectory; + } + + private function writeLaravelDockerComposerConfig(string $projectDirectory): void + { + file_put_contents($projectDirectory . '/config/docker_composer.php', <<<'PHP' + env('DOCKER_COMPOSER_LARAVEL', false), + 'service' => 'php', + 'mode' => 'exec', + 'compose_files' => 'docker-compose.yaml', + 'workdir' => '/usr/src/app', + 'exclude' => ['host-only'], + 'service_mapping' => [ + 'php_tools' => [ + App\Console\Commands\ClassMappedCommand::class, + ':scripts/bootstrap.php', + ], + ], +]; +PHP); + } +} diff --git a/features/composer_plugin.feature b/features/composer_plugin.feature new file mode 100644 index 0000000..dd4bd92 --- /dev/null +++ b/features/composer_plugin.feature @@ -0,0 +1,39 @@ +Feature: Composer plugin command redirection + + Scenario: Exec mode redirects custom and lifecycle Composer scripts with auto-up + Given a Composer project configured for exec mode + When I run "composer install" in the project + And I run "docker compose down --volumes --remove-orphans" in the project + And I run "composer run-script mark" in the project + Then the project file "result.txt" should contain "1" + When I delete the project file "lifecycle.txt" + And I run "composer dump-autoload" in the project + Then the project file "lifecycle.txt" should contain "1" + + Scenario: Install redirects after the plugin is already installed + Given a Composer project configured for exec install redirection + When I run "composer install" in the project + And I run "composer install" in the project + Then the last command error output should contain "Running composer install in Docker Compose service php." + + Scenario: Service mapping override redirects to the configured service + Given a Composer project configured with service mapping override + When I run "composer install" in the project + And I run "composer run-script mark" in the project + Then the project file "result.txt" should contain "override" + + Scenario: Run mode, disabled mode, inside-container behavior, and missing config behavior + Given a Composer project configured for run mode + When I run "composer install" in the project + And I run "composer run-script mark" in the project + Then the project file "result.txt" should contain "1" + When I delete the project file "result.txt" + And I run "DOCKER_COMPOSER_DISABLE=1 composer run-script mark" in the project + Then the project file "result.txt" should contain "host" + When I delete the project file "result.txt" + And I run "composer run-script mark" in the "php" service of the Composer project + Then the project file "result.txt" should contain "1" + Given a Composer project without Docker-Composer configuration + When I run "composer install" in the project + And I run "composer run-script mark" in the project + Then the project file "result.txt" should contain "host" diff --git a/features/laravel.feature b/features/laravel.feature new file mode 100644 index 0000000..8647ba9 --- /dev/null +++ b/features/laravel.feature @@ -0,0 +1,21 @@ +Feature: Laravel package integration + + Scenario: Laravel autodiscovery and console redirection + Given a Laravel project + When I run "DOCKER_COMPOSER_LARAVEL=0 composer install" in the project + Then the Laravel package should autodiscover the Docker-Composer service provider + When I run "DOCKER_COMPOSER_LARAVEL=0 php artisan vendor:publish --tag=docker-composer-config --force" in the project + Then the Laravel Docker-Composer configuration should exist + When I configure Laravel Docker-Composer redirection + And I run "docker compose down --volumes --remove-orphans" in the project + And I run "DOCKER_COMPOSER_LARAVEL=true php artisan mark" in the project + Then the project file "result.txt" should contain "1" + When I run "DOCKER_COMPOSER_LARAVEL=true php artisan class-map" in the project + Then the project file "class.txt" should contain "mapped" + When I run "DOCKER_COMPOSER_LARAVEL=true php scripts/bootstrap.php" in the project + Then the project file "script.txt" should contain "mapped" + When I run "DOCKER_COMPOSER_LARAVEL=true php artisan host-only" in the project + Then the project file "host.txt" should contain "host" + When I delete the project file "result.txt" + And I run "DOCKER_COMPOSER_LARAVEL=0 php artisan mark" in the project + Then the project file "result.txt" should contain "host" diff --git a/phpunit.dist.xml b/phpunit.dist.xml index 3fc0a32..15ee496 100644 --- a/phpunit.dist.xml +++ b/phpunit.dist.xml @@ -25,18 +25,12 @@ tests/Unit - - tests/Integration - src - - src/constants.php - diff --git a/src/DockerComposeCommandBuilder.php b/src/DockerComposeCommandBuilder.php index 4be8f1e..a9aeae3 100644 --- a/src/DockerComposeCommandBuilder.php +++ b/src/DockerComposeCommandBuilder.php @@ -27,7 +27,7 @@ class DockerComposeCommandBuilder * Builds the Docker Compose service startup command. * * @param DockerComposeOptions $config - * The Docker Composer configuration that provides service options. + * The Docker-Composer configuration that provides service options. * * @return list * Returns command arguments for `docker compose up -d`. @@ -45,7 +45,7 @@ public function buildUpCommand(DockerComposeOptions $config): array * Builds the Docker Compose running services command. * * @param DockerComposeOptions $config - * The Docker Composer configuration that provides service options. + * The Docker-Composer configuration that provides service options. * * @return list * Returns command arguments for `docker compose ps`. @@ -62,7 +62,7 @@ public function buildRunningServicesCommand(DockerComposeOptions $config): array * Builds the Docker Compose script execution command. * * @param DockerComposeOptions $config - * The Docker Composer configuration that provides service options. + * The Docker-Composer configuration that provides service options. * * @param ScriptEvent $event * The Composer script event to replay inside Docker Compose. @@ -97,7 +97,7 @@ public function buildScriptCommand( * Builds the Docker Compose Composer command execution command. * * @param DockerComposeOptions $config - * The Docker Composer configuration that provides service options. + * The Docker-Composer configuration that provides service options. * * @param string $commandName * The Composer command name to replay inside Docker Compose. diff --git a/src/DockerComposerConfig.php b/src/DockerComposerConfig.php index ad5b1c3..91d2102 100644 --- a/src/DockerComposerConfig.php +++ b/src/DockerComposerConfig.php @@ -1,7 +1,7 @@ @@ -18,7 +18,7 @@ use LogicException; /** - * Parses and exposes Docker Composer configuration from Composer metadata. + * Parses and exposes Docker-Composer configuration from Composer metadata. */ final class DockerComposerConfig implements DockerComposeOptions { @@ -26,7 +26,7 @@ final class DockerComposerConfig implements DockerComposeOptions * Names the Composer extra key used by this plugin. * * @var string - * Stores the `extra` object key containing Docker Composer settings. + * Stores the `extra` object key containing Docker-Composer settings. */ public const EXTRA_KEY = 'docker-composer'; @@ -178,7 +178,7 @@ private function __construct( * The Composer instance that owns the package metadata. * * @return self - * Returns parsed Docker Composer configuration. + * Returns parsed Docker-Composer configuration. * * @throws InvalidArgumentException * Thrown when `extra.docker-composer` has an invalid shape or value. diff --git a/src/DockerComposerPlugin.php b/src/DockerComposerPlugin.php index 82248a0..5755dbe 100644 --- a/src/DockerComposerPlugin.php +++ b/src/DockerComposerPlugin.php @@ -24,7 +24,6 @@ use Composer\Plugin\PluginEvents; use Composer\Plugin\PluginInterface; use Composer\Script\Event as ScriptEvent; -use Composer\Util\Platform; use Composer\Util\ProcessExecutor; use Symfony\Component\Console\Formatter\OutputFormatter; @@ -52,7 +51,7 @@ class DockerComposerPlugin implements EventSubscriberInterface, PluginInterface private ?IOInterface $io = null; /** - * Stores parsed Docker Composer configuration. + * Stores parsed Docker-Composer configuration. */ private ?DockerComposerConfig $config = null; @@ -358,7 +357,7 @@ private function registerScriptListeners(Composer $composer): void * The script event used to lazily access Composer. * * @return DockerComposerConfig - * Returns parsed Docker Composer configuration. + * Returns parsed Docker-Composer configuration. */ private function getConfig(ScriptEvent $event): DockerComposerConfig { @@ -495,7 +494,7 @@ private function writeCommandRedirectNotice(IOInterface $io, string $commandName * The Composer script event being executed. * * @param DockerComposerConfig $config - * The Docker Composer configuration used to build commands. + * The Docker-Composer configuration used to build commands. * * @return void * Returns nothing. @@ -527,7 +526,7 @@ private function runInDocker(ScriptEvent $event, DockerComposerConfig $config): * The Composer command event being executed. * * @param DockerComposerConfig $config - * The Docker Composer configuration used to build commands. + * The Docker-Composer configuration used to build commands. * * @return void * Returns nothing. @@ -581,7 +580,7 @@ private function getProcessRunnerForCommand(): ProcessRunner { if ($this->processRunner === null) { if ($this->io === null) { - throw new ScriptExecutionException('Docker Composer plugin was not activated.', 1); + throw new ScriptExecutionException('Docker-Composer plugin was not activated.', 1); } $this->processRunner = new ComposerProcessRunner($this->io); @@ -594,7 +593,7 @@ private function getProcessRunnerForCommand(): ProcessRunner * Resolves Docker Compose workdir metadata for execution. * * @param DockerComposerConfig $config - * The parsed Docker Composer configuration. + * The parsed Docker-Composer configuration. * * @param string $hostWorkingDirectory * The active host working directory. @@ -614,15 +613,10 @@ private function resolveDockerWorkdir(DockerComposerConfig $config, string $host * Gets the active host working directory. * * @return string - * Returns Composer's current directory, falling back to process CWD. + * Returns the process CWD, falling back to `"."`. */ private function getHostWorkingDirectory(): string { - $cwd = Platform::getCwd(true); - if ($cwd !== '') { - return $cwd; - } - $cwd = getcwd(); return $cwd !== false ? $cwd : '.'; diff --git a/src/Laravel/Config.php b/src/Laravel/Config.php index 8a677c7..678d3f1 100644 --- a/src/Laravel/Config.php +++ b/src/Laravel/Config.php @@ -1,7 +1,7 @@ diff --git a/src/ProcessRunner.php b/src/ProcessRunner.php index 0a8b5ef..98d5a28 100644 --- a/src/ProcessRunner.php +++ b/src/ProcessRunner.php @@ -14,7 +14,7 @@ namespace empaphy\docker_composer; /** - * Runs external commands for Docker Composer. + * Runs external commands for Docker-Composer. */ interface ProcessRunner { diff --git a/tests/Integration/DockerComposerIntegrationTest.php b/tests/Integration/DockerComposerIntegrationTest.php deleted file mode 100644 index 701d48f..0000000 --- a/tests/Integration/DockerComposerIntegrationTest.php +++ /dev/null @@ -1,635 +0,0 @@ - - */ - private array $projectDirectories = []; - - protected function tearDown(): void - { - foreach ($this->projectDirectories as $projectDirectory) { - $this->runCommand(['docker', 'compose', 'down', '--volumes', '--remove-orphans'], $projectDirectory, [], false); - $this->removeDirectory($projectDirectory); - } - - $this->projectDirectories = []; - } - - public function testExecModeRedirectsCustomAndLifecycleScriptsWithAutoUp(): void - { - $projectDirectory = $this->createProject([ - 'service' => 'php', - 'mode' => 'exec', - 'compose-files' => 'docker-compose.yaml', - ]); - $this->installProject($projectDirectory); - - $this->runCommand(['docker', 'compose', 'down', '--volumes', '--remove-orphans'], $projectDirectory); - $this->runCommand(['composer', 'run-script', 'mark'], $projectDirectory); - self::assertSame('1', trim((string) file_get_contents($projectDirectory . '/result.txt'))); - - @unlink($projectDirectory . '/lifecycle.txt'); - $this->runCommand(['composer', 'dump-autoload'], $projectDirectory); - 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([ - 'service' => 'php', - 'service-mapping' => [ - 'php_tools' => 'mark', - ], - 'compose-files' => 'docker-compose.yaml', - 'workdir' => '/usr/src/app', - ]); - $this->installProject($projectDirectory); - - $this->runCommand(['composer', 'run-script', 'mark'], $projectDirectory); - - self::assertSame('override', trim((string) file_get_contents($projectDirectory . '/result.txt'))); - } - - public function testRunModeBypassMissingConfigAndInsideContainerBehavior(): void - { - $runProjectDirectory = $this->createProject([ - 'service' => 'php', - 'mode' => 'run', - 'compose-files' => 'docker-compose.yaml', - 'workdir' => '/usr/src/app', - ]); - $this->installProject($runProjectDirectory); - - $this->runCommand(['composer', 'run-script', 'mark'], $runProjectDirectory); - self::assertSame('1', trim((string) file_get_contents($runProjectDirectory . '/result.txt'))); - - @unlink($runProjectDirectory . '/result.txt'); - $this->runCommand(['composer', 'run-script', 'mark'], $runProjectDirectory, ['DOCKER_COMPOSER_DISABLE' => '1']); - self::assertSame('host', trim((string) file_get_contents($runProjectDirectory . '/result.txt'))); - - @unlink($runProjectDirectory . '/result.txt'); - $this->runCommand([ - 'docker', - 'compose', - 'run', - '--rm', - '-T', - '--workdir', - '/usr/src/app', - '--env', - 'DOCKER_COMPOSER_INSIDE=1', - 'php', - 'composer', - 'run-script', - 'mark', - ], $runProjectDirectory); - self::assertSame('1', trim((string) file_get_contents($runProjectDirectory . '/result.txt'))); - - $missingConfigProjectDirectory = $this->createProject([]); - $this->installProject($missingConfigProjectDirectory); - - $this->runCommand(['composer', 'run-script', 'mark'], $missingConfigProjectDirectory); - self::assertSame('host', trim((string) file_get_contents($missingConfigProjectDirectory . '/result.txt'))); - } - - public function testLaravelAutodiscoveryAndConsoleRedirection(): void - { - $projectDirectory = $this->createLaravelProject(); - $this->installProject($projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => '0']); - - $packages = require $projectDirectory . '/bootstrap/cache/packages.php'; - self::assertContains('empaphy\\docker_composer\\Laravel\\ServiceProvider', $packages['empaphy/docker-composer']['providers'] ?? []); - - $this->runCommand(['php', 'artisan', 'vendor:publish', '--tag=docker-composer-config', '--force'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => '0']); - self::assertFileExists($projectDirectory . '/config/docker_composer.php'); - $this->writeLaravelDockerComposerConfig($projectDirectory); - - $this->runCommand(['docker', 'compose', 'down', '--volumes', '--remove-orphans'], $projectDirectory); - - $result = $this->runCommand(['php', 'artisan', 'mark'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => 'true']); - self::assertSame('1', trim((string) file_get_contents($projectDirectory . '/result.txt')), $result['stdout'] . $result['stderr']); - - $this->runCommand(['php', 'artisan', 'class-map'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => 'true']); - self::assertSame('mapped', trim((string) file_get_contents($projectDirectory . '/class.txt'))); - - $this->runCommand(['php', 'scripts/bootstrap.php'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => 'true']); - self::assertSame('mapped', trim((string) file_get_contents($projectDirectory . '/script.txt'))); - - $this->runCommand(['php', 'artisan', 'host-only'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => 'true']); - self::assertSame('host', trim((string) file_get_contents($projectDirectory . '/host.txt'))); - - @unlink($projectDirectory . '/result.txt'); - $this->runCommand(['php', 'artisan', 'mark'], $projectDirectory, ['DOCKER_COMPOSER_LARAVEL' => '0']); - self::assertSame('host', trim((string) file_get_contents($projectDirectory . '/result.txt'))); - } - - /** - * @param array $dockerComposerConfig - * @param list>|null $repositories - */ - private function createProject(array $dockerComposerConfig, ?array $repositories = null, string $requireVersion = '*'): string - { - $projectDirectory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR - . 'docker-composer-integration-' - . bin2hex(random_bytes(8)); - if (! mkdir($projectDirectory, 0777, true) && ! is_dir($projectDirectory)) { - throw new \RuntimeException(sprintf('Unable to create integration project directory "%s".', $projectDirectory)); - } - - $this->projectDirectories[] = $projectDirectory; - - $composerJson = [ - 'name' => 'empaphy/docker-composer-integration', - 'description' => 'Temporary docker-composer integration fixture.', - 'minimum-stability' => 'dev', - 'prefer-stable' => true, - 'repositories' => $repositories ?? [[ - 'type' => 'path', - 'url' => dirname(__DIR__, 2), - 'options' => ['symlink' => false], - ]], - 'require' => [ - 'empaphy/docker-composer' => $requireVersion, - ], - 'config' => [ - 'allow-plugins' => [ - 'empaphy/docker-composer' => true, - ], - ], - 'scripts' => [ - 'mark' => '@php -r "file_put_contents(\'result.txt\', getenv(\'DOCKER_COMPOSER_TEST_MARK\') ?: (getenv(\'DOCKER_COMPOSER_INSIDE\') ?: \'host\'));"', - 'post-autoload-dump' => '@php -r "file_put_contents(\'lifecycle.txt\', getenv(\'DOCKER_COMPOSER_INSIDE\') ?: \'host\');"', - ], - 'extra' => [ - 'docker-composer' => $dockerComposerConfig, - ], - ]; - - $encodedComposerJson = json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if ($encodedComposerJson === false) { - throw new \RuntimeException('Unable to encode integration composer.json.'); - } - - file_put_contents($projectDirectory . '/composer.json', $encodedComposerJson . PHP_EOL); - file_put_contents($projectDirectory . '/docker-compose.yaml', sprintf(<<<'YAML' -services: - php: - image: %s - command: ['sleep', 'infinity'] - working_dir: /usr/src/app - volumes: - - { type: bind, source: '.', target: '/usr/src/app' } - php_tools: - image: %s - command: ['sleep', 'infinity'] - environment: - DOCKER_COMPOSER_TEST_MARK: override - working_dir: /usr/src/app - volumes: - - { type: bind, source: '.', target: '/usr/src/app' } -YAML, $this->getComposerImage(), $this->getComposerImage())); - - return $projectDirectory; - } - - /** - * @param list> $repositories - */ - protected function updateProjectRepositories(string $projectDirectory, array $repositories): void - { - $composerJsonPath = $projectDirectory . '/composer.json'; - $composerJson = json_decode((string) file_get_contents($composerJsonPath), true); - if (! is_array($composerJson)) { - throw new \RuntimeException(sprintf('Unable to decode "%s".', $composerJsonPath)); - } - - $composerJson['repositories'] = $repositories; - - $encodedComposerJson = json_encode($composerJson, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if ($encodedComposerJson === false) { - throw new \RuntimeException(sprintf('Unable to encode "%s".', $composerJsonPath)); - } - - file_put_contents($composerJsonPath, $encodedComposerJson . PHP_EOL); - } - - private function getComposerImage(): string - { - $composerVersion = getenv('DOCKER_COMPOSER_TEST_COMPOSER_VERSION'); - if ($composerVersion === false || $composerVersion === '' || $composerVersion === 'v2') { - return 'composer:2'; - } - - return 'composer:' . $composerVersion; - } - - /** - * @return list - */ - protected function getRequireCommand(string $package): array - { - $command = ['composer', 'require', $package, '--no-interaction', '--no-progress']; - $composerVersion = getenv('DOCKER_COMPOSER_TEST_COMPOSER_VERSION'); - if ($composerVersion !== false && $composerVersion !== 'v2') { - return $command; - } - - array_splice($command, 2, 0, '-m'); - - return $command; - } - - /** - * @param array $environment - */ - private function installProject(string $projectDirectory, array $environment = []): void - { - $this->runCommand(['composer', 'install', '--no-interaction', '--no-progress', '--prefer-dist'], $projectDirectory, $environment); - } - - private function createLaravelProject(): string - { - $projectDirectory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) - . DIRECTORY_SEPARATOR - . 'docker-composer-laravel-integration-' - . bin2hex(random_bytes(8)); - if (! mkdir($projectDirectory, 0777, true) && ! is_dir($projectDirectory)) { - throw new \RuntimeException(sprintf('Unable to create integration project directory "%s".', $projectDirectory)); - } - - $this->projectDirectories[] = $projectDirectory; - foreach ([ - 'app/Console/Commands', - 'app/Exceptions', - 'app/Console', - 'bootstrap/cache', - 'config', - 'scripts', - ] as $directory) { - $path = $projectDirectory . '/' . $directory; - if (! is_dir($path) && ! mkdir($path, 0777, true) && ! is_dir($path)) { - throw new \RuntimeException(sprintf('Unable to create directory "%s".', $path)); - } - } - - $this->writeJson($projectDirectory . '/composer.json', [ - 'name' => 'empaphy/docker-composer-laravel-integration', - 'description' => 'Temporary docker-composer Laravel integration fixture.', - 'minimum-stability' => 'dev', - 'prefer-stable' => true, - 'repositories' => [[ - 'type' => 'path', - 'url' => dirname(__DIR__, 2), - 'options' => ['symlink' => false], - ]], - 'require' => [ - 'laravel/framework' => '^12.0', - 'empaphy/docker-composer' => '*', - ], - 'autoload' => [ - 'psr-4' => [ - 'App\\' => 'app/', - ], - ], - 'config' => [ - 'allow-plugins' => [ - 'empaphy/docker-composer' => true, - ], - ], - 'scripts' => [ - 'post-autoload-dump' => [ - 'Illuminate\\Foundation\\ComposerScripts::postAutoloadDump', - '@php artisan package:discover --ansi', - ], - ], - ]); - - file_put_contents($projectDirectory . '/artisan', <<<'PHP' -#!/usr/bin/env php -make(Kernel::class); -$input = new ArgvInput(); -$status = $kernel->handle($input, new ConsoleOutput()); -$kernel->terminate($input, $status); - -exit($status); -PHP); - chmod($projectDirectory . '/artisan', 0755); - - file_put_contents($projectDirectory . '/bootstrap/app.php', <<<'PHP' -singleton(KernelContract::class, Kernel::class); -$app->singleton(ExceptionHandler::class, Handler::class); - -return $app; -PHP); - - file_put_contents($projectDirectory . '/config/app.php', <<<'PHP' - 'Docker Composer Test', - 'env' => 'testing', - 'debug' => true, - 'url' => 'http://localhost', - 'timezone' => 'UTC', - 'locale' => 'en', - 'fallback_locale' => 'en', - 'faker_locale' => 'en_US', - 'key' => 'base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=', - 'cipher' => 'AES-256-CBC', - 'providers' => ServiceProvider::defaultProviders()->toArray(), -]; -PHP); - - file_put_contents($projectDirectory . '/app/Console/Kernel.php', <<<'PHP' - - */ - protected $commands = [ - MarkCommand::class, - ClassMappedCommand::class, - HostOnlyCommand::class, - ]; -} -PHP); - - file_put_contents($projectDirectory . '/app/Exceptions/Handler.php', <<<'PHP' -make(Kernel::class)->bootstrap(); - -file_put_contents(__DIR__ . '/../script.txt', getenv('DOCKER_COMPOSER_TEST_MARK') ?: (getenv('DOCKER_COMPOSER_INSIDE') ?: 'host')); -PHP); - chmod($projectDirectory . '/scripts/bootstrap.php', 0755); - - file_put_contents($projectDirectory . '/docker-compose.yaml', sprintf(<<<'YAML' -services: - php: - image: %s - command: ['sleep', 'infinity'] - working_dir: /usr/src/app - volumes: - - { type: bind, source: '.', target: '/usr/src/app' } - php_tools: - image: %s - command: ['sleep', 'infinity'] - environment: - DOCKER_COMPOSER_TEST_MARK: mapped - working_dir: /usr/src/app - volumes: - - { type: bind, source: '.', target: '/usr/src/app' } -YAML, $this->getComposerImage(), $this->getComposerImage())); - - return $projectDirectory; - } - - private function writeLaravelDockerComposerConfig(string $projectDirectory): void - { - file_put_contents($projectDirectory . '/config/docker_composer.php', <<<'PHP' - env('DOCKER_COMPOSER_LARAVEL', false), - 'service' => 'php', - 'mode' => 'exec', - 'compose_files' => 'docker-compose.yaml', - 'workdir' => '/usr/src/app', - 'exclude' => ['host-only'], - 'service_mapping' => [ - 'php_tools' => [ - App\Console\Commands\ClassMappedCommand::class, - ':scripts/bootstrap.php', - ], - ], -]; -PHP); - } - - /** - * @param array $data - */ - private function writeJson(string $path, array $data): void - { - $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); - if ($encoded === false) { - throw new \RuntimeException(sprintf('Unable to encode "%s".', $path)); - } - - file_put_contents($path, $encoded . PHP_EOL); - } - - /** - * @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): array - { - $descriptorSpec = [ - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - ]; - $processEnvironment = array_merge(getenv() ?: [], [ - 'COMPOSER_CACHE_DIR' => $workingDirectory . '/.composer-cache', - 'COMPOSER_NO_INTERACTION' => '1', - ], $environment); - - $process = proc_open($command, $descriptorSpec, $pipes, $workingDirectory, $processEnvironment); - if (! is_resource($process)) { - throw new \RuntimeException(sprintf('Unable to start command: %s', implode(' ', $command))); - } - - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - $exitCode = proc_close($process); - - if ($failOnError && $exitCode !== 0) { - self::fail(sprintf( - "Command failed with exit code %d:\n%s\n\nSTDOUT:\n%s\n\nSTDERR:\n%s", - $exitCode, - implode(' ', $command), - $stdout, - $stderr, - )); - } - - return [ - 'stdout' => $stdout, - 'stderr' => $stderr, - 'exit-code' => $exitCode, - ]; - } - - private function removeDirectory(string $directory): void - { - if (! is_dir($directory)) { - return; - } - - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::CHILD_FIRST, - ); - - foreach ($iterator as $fileInfo) { - if ($fileInfo->isDir() && ! $fileInfo->isLink()) { - rmdir($fileInfo->getPathname()); - } else { - unlink($fileInfo->getPathname()); - } - } - - rmdir($directory); - } -} diff --git a/tests/Unit/DockerComposerPluginTest.php b/tests/Unit/DockerComposerPluginTest.php index 0726bce..9af5ff1 100644 --- a/tests/Unit/DockerComposerPluginTest.php +++ b/tests/Unit/DockerComposerPluginTest.php @@ -875,7 +875,7 @@ public function testCommandProcessRunnerRequiresActivationContext(): void $method = new \ReflectionMethod(DockerComposerPlugin::class, 'getProcessRunnerForCommand'); $this->expectException(ScriptExecutionException::class); - $this->expectExceptionMessage('Docker Composer plugin was not activated.'); + $this->expectExceptionMessage('Docker-Composer plugin was not activated.'); $method->invoke($plugin); } From 5bd7199aa551970adb744009f194ad2bd8d4a1d7 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Fri, 15 May 2026 22:12:22 +0200 Subject: [PATCH 09/10] fix(Laravel): show redirect message when redirecting laravel --- features/laravel.feature | 1 + src/Laravel/ConsoleEntry.php | 28 +++++++++-- src/Laravel/Redirector.php | 43 ++++++++++++++++ tests/Unit/Laravel/ConsoleEntryTest.php | 14 ++++++ tests/Unit/Laravel/RedirectorTest.php | 66 ++++++++++++++++++++++--- 5 files changed, 141 insertions(+), 11 deletions(-) diff --git a/features/laravel.feature b/features/laravel.feature index 8647ba9..a438bb0 100644 --- a/features/laravel.feature +++ b/features/laravel.feature @@ -9,6 +9,7 @@ Feature: Laravel package integration When I configure Laravel Docker-Composer redirection And I run "docker compose down --volumes --remove-orphans" in the project And I run "DOCKER_COMPOSER_LARAVEL=true php artisan mark" in the project + Then the last command error output should contain "Running artisan mark in Docker Compose service php." Then the project file "result.txt" should contain "1" When I run "DOCKER_COMPOSER_LARAVEL=true php artisan class-map" in the project Then the project file "class.txt" should contain "mapped" diff --git a/src/Laravel/ConsoleEntry.php b/src/Laravel/ConsoleEntry.php index 41f0599..48a94d6 100644 --- a/src/Laravel/ConsoleEntry.php +++ b/src/Laravel/ConsoleEntry.php @@ -32,6 +32,11 @@ final class ConsoleEntry */ private array $arguments; + /** + * Stores the entry name shown in redirect notices. + */ + private string $displayName; + /** * Creates a Laravel console entry. * @@ -40,11 +45,15 @@ final class ConsoleEntry * * @param list $arguments * The raw CLI arguments to replay inside Docker. + * + * @param string $displayName + * The entry name shown in redirect notices. */ - private function __construct(array $names, array $arguments) + private function __construct(array $names, array $arguments, string $displayName) { $this->names = array_values(array_unique($names)); $this->arguments = $arguments; + $this->displayName = $displayName; } /** @@ -65,15 +74,17 @@ private function __construct(array $names, array $arguments) public static function artisan(?string $commandName, ?string $commandClass, array $arguments): self { $names = []; + $displayName = $arguments[0] ?? 'artisan'; if ($commandName !== null && $commandName !== '') { $names[] = $commandName; + $displayName = 'artisan ' . $commandName; } if ($commandClass !== null && $commandClass !== '') { $names[] = $commandClass; } - return new self($names, $arguments); + return new self($names, $arguments, $displayName); } /** @@ -90,7 +101,7 @@ public static function artisan(?string $commandName, ?string $commandClass, arra */ public static function script(string $scriptName, array $arguments): self { - return new self([$scriptName], $arguments); + return new self([$scriptName], $arguments, $scriptName); } /** @@ -142,4 +153,15 @@ public function getArguments(): array { return $this->arguments; } + + /** + * Gets the entry name shown in redirect notices. + * + * @return string + * Returns a human-readable Artisan command or script identifier. + */ + public function getDisplayName(): string + { + return $this->displayName; + } } diff --git a/src/Laravel/Redirector.php b/src/Laravel/Redirector.php index 6bc1f43..19b98b9 100644 --- a/src/Laravel/Redirector.php +++ b/src/Laravel/Redirector.php @@ -19,6 +19,7 @@ use empaphy\docker_composer\DockerComposeRunner; use empaphy\docker_composer\DockerComposeWorkdirResolver; use empaphy\docker_composer\ProcessRunner; +use Symfony\Component\Console\Formatter\OutputFormatter; /** * Redirects Laravel console entries into Docker Compose. @@ -30,6 +31,13 @@ final class Redirector */ private DockerComposeWorkdirResolver $workdirResolver; + /** + * Receives redirect notices before Docker execution begins. + * + * @var resource + */ + private $errorOutput; + /** * Creates a Laravel console redirector. * @@ -47,6 +55,9 @@ final class Redirector * * @param DockerComposeWorkdirResolver|null $workdirResolver * The workdir resolver, or `null` for the default resolver. + * + * @param resource|null $errorOutput + * The writable stream receiving redirect notices, or `null` for stderr. */ public function __construct( private readonly DockerComposeRunner $dockerRunner, @@ -54,8 +65,15 @@ public function __construct( private readonly ContainerDetector $containerDetector, private readonly ?ProcessRunner $processRunner = null, ?DockerComposeWorkdirResolver $workdirResolver = null, + $errorOutput = null, ) { $this->workdirResolver = $workdirResolver ?? new DockerComposeWorkdirResolver($this->commandBuilder); + if ($errorOutput === null) { + /** @var resource $errorOutput */ + $errorOutput = fopen('php://stderr', 'w'); + } + + $this->errorOutput = $errorOutput; } /** @@ -87,6 +105,8 @@ public function redirect(Config $config, ConsoleEntry $entry, string $projectRoo return null; } + $this->writeRedirectNotice($entry, $effectiveConfig); + $resolution = $this->workdirResolver->resolve($effectiveConfig, $projectRoot, $this->processRunner, $this->dockerRunner); $effectiveOptions = new DockerComposeResolvedOptions($effectiveConfig, $resolution->getWorkdir()); $arguments = $entry->getArguments(); @@ -101,6 +121,29 @@ public function redirect(Config $config, ConsoleEntry $entry, string $projectRoo return $result->getExitCode(); } + /** + * Writes a redirect notice to the configured error stream. + * + * @param ConsoleEntry $entry + * The Laravel console entry being redirected. + * + * @param Config $config + * The effective Docker configuration for the entry. + * + * @return void + * Returns nothing. + */ + private function writeRedirectNotice(ConsoleEntry $entry, Config $config): void + { + $formatter = new OutputFormatter(false); + + fwrite($this->errorOutput, $formatter->format(sprintf( + 'docker-composer: Running %s in Docker Compose service %s.', + OutputFormatter::escape($entry->getDisplayName()), + OutputFormatter::escape($config->getService()), + )) . PHP_EOL); + } + /** * Converts a project-relative PHP entrypoint to an absolute host path. * diff --git a/tests/Unit/Laravel/ConsoleEntryTest.php b/tests/Unit/Laravel/ConsoleEntryTest.php index e1f1e97..ca149a1 100644 --- a/tests/Unit/Laravel/ConsoleEntryTest.php +++ b/tests/Unit/Laravel/ConsoleEntryTest.php @@ -21,12 +21,26 @@ public function testCreatesArtisanEntryNames(): void self::assertSame(['config:cache', ExampleConsoleEntryCommand::class], $entry->getNames()); self::assertSame(['artisan', 'config:cache'], $entry->getArguments()); + self::assertSame('artisan config:cache', $entry->getDisplayName()); + } + + public function testCreatesFallbackArtisanDisplayName(): void + { + $entry = ConsoleEntry::artisan(null, null, ['/host/app/artisan']); + + self::assertSame([], $entry->getNames()); + self::assertSame('/host/app/artisan', $entry->getDisplayName()); } public function testCreatesRelativeScriptName(): void { + self::assertSame(':', ConsoleEntry::scriptName('/host/app', '/host/app')); self::assertSame(':scripts/task.php', ConsoleEntry::scriptName('/host/app/scripts/task.php', '/host/app')); self::assertSame(':scripts/task.php', ConsoleEntry::scriptName('scripts/task.php', '/host/app')); + + $entry = ConsoleEntry::script(':scripts/task.php', ['scripts/task.php']); + + self::assertSame(':scripts/task.php', $entry->getDisplayName()); } } diff --git a/tests/Unit/Laravel/RedirectorTest.php b/tests/Unit/Laravel/RedirectorTest.php index 8bc93df..4ff580c 100644 --- a/tests/Unit/Laravel/RedirectorTest.php +++ b/tests/Unit/Laravel/RedirectorTest.php @@ -49,11 +49,13 @@ public function testRedirectsMatchingLaravelCommandThroughSharedDockerRunner(): ]); $runner = new MockProcessRunner(); $builder = new DockerComposeCommandBuilder(); - $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false)); + $errorOutput = $this->createErrorOutput(); + $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput); $exitCode = $redirector->redirect($config, ConsoleEntry::artisan('config:cache', null, ['/host/app/artisan', 'config:cache']), '/host/app', false); self::assertSame(0, $exitCode); + self::assertSame("docker-composer: Running artisan config:cache in Docker Compose service php-tools.\n", $this->readErrorOutput($errorOutput)); self::assertSame([ ['docker', 'compose', 'up', '-d', 'php-tools'], [ @@ -72,30 +74,53 @@ public function testRedirectsMatchingLaravelCommandThroughSharedDockerRunner(): ], $runner->commands); } + public function testRedirectNoticeKeepsEscapedConsoleTagsReadable(): void + { + $config = Config::fromArray([ + 'enabled' => true, + 'service' => 'php', + ]); + $runner = new MockProcessRunner(); + $builder = new DockerComposeCommandBuilder(); + $errorOutput = $this->createErrorOutput(); + $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput); + + $exitCode = $redirector->redirect($config, ConsoleEntry::artisan('bad', null, ['artisan', 'bad']), '/host/app', false); + + self::assertSame(0, $exitCode); + self::assertSame("docker-composer: Running artisan bad in Docker Compose service php.\n", $this->readErrorOutput($errorOutput)); + } + public function testReturnsNullWhenDisabledInsideContainerExcludedOrUnconfigured(): void { $entry = ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']); $builder = new DockerComposeCommandBuilder(); + $errorOutput = $this->createErrorOutput(); $disabledRunner = new MockProcessRunner(); $disabled = Config::fromArray(['enabled' => false, 'service' => 'php']); - self::assertNull((new Redirector(new DockerComposeRunner($disabledRunner, $builder), $builder, new MockContainerDetector(false)))->redirect($disabled, $entry, '/host/app', false)); + self::assertNull((new Redirector(new DockerComposeRunner($disabledRunner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput))->redirect($disabled, $entry, '/host/app', false)); self::assertSame([], $disabledRunner->commands); + $defaultOutputRunner = new MockProcessRunner(); + self::assertNull((new Redirector(new DockerComposeRunner($defaultOutputRunner, $builder), $builder, new MockContainerDetector(false)))->redirect($disabled, $entry, '/host/app', false)); + self::assertSame([], $defaultOutputRunner->commands); + $insideRunner = new MockProcessRunner(); $enabled = Config::fromArray(['enabled' => true, 'service' => 'php']); - self::assertNull((new Redirector(new DockerComposeRunner($insideRunner, $builder), $builder, new MockContainerDetector(true)))->redirect($enabled, $entry, '/host/app', false)); + self::assertNull((new Redirector(new DockerComposeRunner($insideRunner, $builder), $builder, new MockContainerDetector(true), errorOutput: $errorOutput))->redirect($enabled, $entry, '/host/app', false)); self::assertSame([], $insideRunner->commands); $excludedRunner = new MockProcessRunner(); $excluded = Config::fromArray(['enabled' => true, 'service' => 'php', 'exclude' => ['migrate']]); - self::assertNull((new Redirector(new DockerComposeRunner($excludedRunner, $builder), $builder, new MockContainerDetector(false)))->redirect($excluded, $entry, '/host/app', false)); + self::assertNull((new Redirector(new DockerComposeRunner($excludedRunner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput))->redirect($excluded, $entry, '/host/app', false)); self::assertSame([], $excludedRunner->commands); $unconfiguredRunner = new MockProcessRunner(); $unconfigured = Config::fromArray(['enabled' => true]); - self::assertNull((new Redirector(new DockerComposeRunner($unconfiguredRunner, $builder), $builder, new MockContainerDetector(false)))->redirect($unconfigured, $entry, '/host/app', false)); + self::assertNull((new Redirector(new DockerComposeRunner($unconfiguredRunner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput))->redirect($unconfigured, $entry, '/host/app', false)); self::assertSame([], $unconfiguredRunner->commands); + self::assertSame('', $this->readErrorOutput($errorOutput)); } public function testRedirectSkipsEntrypointAbsolutizingWithoutPathMapping(): void @@ -107,7 +132,7 @@ public function testRedirectSkipsEntrypointAbsolutizingWithoutPathMapping(): voi ]); $runner = new MockProcessRunner(); $builder = new DockerComposeCommandBuilder(); - $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false)); + $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), errorOutput: $this->createErrorOutput()); try { $exitCode = $redirector->redirect($config, ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']), $projectRoot, false); @@ -137,7 +162,7 @@ public function testRedirectAbsolutizesAndTranslatesEntrypointWithPathMapping(): ], JSON_THROW_ON_ERROR); $runner = new MockOutputCapturingProcessRunner([0, 0, 0], outputs: [$configOutput, "php\n"]); $builder = new DockerComposeCommandBuilder(); - $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), $runner); + $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), $runner, errorOutput: $this->createErrorOutput()); try { $exitCode = $redirector->redirect($config, ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']), $projectRoot, false); @@ -161,15 +186,40 @@ public function testEnvironmentDisableReturnsNull(): void ]); $runner = new MockProcessRunner(); $builder = new DockerComposeCommandBuilder(); - $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false)); + $errorOutput = $this->createErrorOutput(); + $redirector = new Redirector(new DockerComposeRunner($runner, $builder), $builder, new MockContainerDetector(false), errorOutput: $errorOutput); self::assertNull($redirector->redirect($config, ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']), '/host/app', false)); self::assertSame([], $runner->commands); + self::assertSame('', $this->readErrorOutput($errorOutput)); } finally { putenv('DOCKER_COMPOSER_DISABLE'); } } + /** + * @return resource + */ + private function createErrorOutput() + { + $errorOutput = fopen('php://temp', 'w+'); + if ($errorOutput === false) { + throw new \RuntimeException('Unable to create temporary error output stream.'); + } + + return $errorOutput; + } + + /** + * @param resource $errorOutput + */ + private function readErrorOutput($errorOutput): string + { + rewind($errorOutput); + + return stream_get_contents($errorOutput) ?: ''; + } + private function createProjectRootWithArtisan(): string { $projectRoot = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) From 591f96c7863f8b449ae55834562375b351219a3b Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sat, 16 May 2026 19:44:42 +0200 Subject: [PATCH 10/10] tests: 100% line coverage --- .aiignore | 1 + .gitattributes | 1 + .idea/docker-composer.iml | 5 + .idea/php.xml | 95 +- .idea/scopes/Code_Style.xml | 2 +- .idea/scopes/Static_Analysis.xml | 2 +- .php-cs-fixer.dist.php | 1 + AGENTS.md | 2 +- composer.json | 3 +- .../InteractsWithTemporaryProjects.php | 21 +- phpstan.dist.neon | 2 + src/Laravel/ServiceProvider.php | 37 +- src/ShellProcessRunner.php | 41 +- .../Unit/DockerComposeWorkdirResolverTest.php | 155 ++++ tests/Unit/Laravel/ConfigTest.php | 132 ++- tests/Unit/Laravel/ConsoleEntryTest.php | 26 + tests/Unit/Laravel/ServiceProviderTest.php | 827 ++++++++++++++++++ tests/Unit/ShellProcessRunnerTest.php | 92 ++ 18 files changed, 1371 insertions(+), 74 deletions(-) create mode 100644 tests/Unit/Laravel/ServiceProviderTest.php create mode 100644 tests/Unit/ShellProcessRunnerTest.php diff --git a/.aiignore b/.aiignore index 56f5232..eb096a2 100644 --- a/.aiignore +++ b/.aiignore @@ -1,4 +1,5 @@ /.op +/stubs .DS_Store .envrc diff --git a/.gitattributes b/.gitattributes index 2097888..41dc454 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,6 +3,7 @@ /.op export-ignore /.run export-ignore /features export-ignore +/stubs export-ignore /tests export-ignore /var export-ignore /vendor export-ignore diff --git a/.idea/docker-composer.iml b/.idea/docker-composer.iml index eff5559..216fd94 100644 --- a/.idea/docker-composer.iml +++ b/.idea/docker-composer.iml @@ -5,6 +5,8 @@ + + @@ -110,6 +112,9 @@ + + + diff --git a/.idea/php.xml b/.idea/php.xml index 8c67ba6..e2fc9fd 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -28,26 +28,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -61,6 +90,7 @@ + @@ -68,8 +98,14 @@ + + + + + + @@ -77,60 +113,27 @@ - - - - - - - - - - - - - - - - - - - - - - - - + + + + - - - - - - - - - - - - - - - - - - + + + + + + - - - - + + + diff --git a/.idea/scopes/Code_Style.xml b/.idea/scopes/Code_Style.xml index 2559a3d..99ecbb3 100644 --- a/.idea/scopes/Code_Style.xml +++ b/.idea/scopes/Code_Style.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/.idea/scopes/Static_Analysis.xml b/.idea/scopes/Static_Analysis.xml index 4a4a5c6..016c74c 100644 --- a/.idea/scopes/Static_Analysis.xml +++ b/.idea/scopes/Static_Analysis.xml @@ -1,3 +1,3 @@ - + \ No newline at end of file diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index 3262a37..e6868b0 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -16,4 +16,5 @@ ->setRules([ '@PER-CS' => true, '@PER-CS:risky' => true, + //'psr_autoloading' => true, ]); diff --git a/AGENTS.md b/AGENTS.md index 14a53a4..1473255 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,7 +38,7 @@ Do not duplicate code when a shared abstraction can cover the behavior. ### Coding Style 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. +Each class must be in a file by itself. ### PHPDoc 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. diff --git a/composer.json b/composer.json index 17a065d..bb16a28 100644 --- a/composer.json +++ b/composer.json @@ -33,7 +33,8 @@ "phpstan/phpstan": "^2", "phpstan/phpstan-phpunit": "^2", "phpstan/phpstan-strict-rules": "^2", - "phpunit/phpunit": ">=10" + "phpunit/phpunit": ">=10", + "vlucas/phpdotenv": "^5.6.1" }, "autoload": { "psr-4": { diff --git a/features/bootstrap/InteractsWithTemporaryProjects.php b/features/bootstrap/InteractsWithTemporaryProjects.php index 3fad8d3..ec9a450 100644 --- a/features/bootstrap/InteractsWithTemporaryProjects.php +++ b/features/bootstrap/InteractsWithTemporaryProjects.php @@ -1,6 +1,7 @@ dependencyResolutionMode = $dependencyResolutionMode; } - private function removeDirectory(string $directory): void + private function removeDirectory(string $directory): bool { if (! is_dir($directory)) { - return; + return true; } - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), - RecursiveIteratorIterator::CHILD_FIRST, - ); - - foreach ($iterator as $fileInfo) { + foreach ( + new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ) as $fileInfo + ) { + assert($fileInfo instanceof SplFileInfo); if ($fileInfo->isDir() && ! $fileInfo->isLink()) { rmdir($fileInfo->getPathname()); } else { @@ -299,7 +300,7 @@ private function removeDirectory(string $directory): void } } - rmdir($directory); + return rmdir($directory); } private function getTempDir(): string diff --git a/phpstan.dist.neon b/phpstan.dist.neon index 56faa8f..d8ed9b5 100644 --- a/phpstan.dist.neon +++ b/phpstan.dist.neon @@ -2,6 +2,8 @@ parameters: checkUninitializedProperties: true editorUrl: 'phpstorm://open?file=%%file%%&line=%%line%%' level: 8 + parallel: + maximumNumberOfProcesses: 4 paths: - features/bootstrap/ - src/ diff --git a/src/Laravel/ServiceProvider.php b/src/Laravel/ServiceProvider.php index 9199c52..6856f93 100644 --- a/src/Laravel/ServiceProvider.php +++ b/src/Laravel/ServiceProvider.php @@ -13,6 +13,7 @@ namespace empaphy\docker_composer\Laravel; +use Closure; use empaphy\docker_composer\DockerComposeCommandBuilder; use empaphy\docker_composer\DockerComposeRunner; use empaphy\docker_composer\EnvironmentContainerDetector; @@ -27,6 +28,33 @@ */ final class ServiceProvider extends IlluminateServiceProvider { + /** + * Terminates the host process after successful Docker redirection. + * + * @var Closure(int): void + */ + private Closure $terminator; + + /** + * Creates the Laravel Docker service provider. + * + * @param mixed $app + * The Laravel application instance. + * + * @param (Closure(int): void)|null $terminator + * The process terminator, or `null` to use native `exit`. + */ + public function __construct($app, ?Closure $terminator = null) + { + parent::__construct($app); + + $this->terminator = $terminator ?? static function (int $exitCode): void { + // @codeCoverageIgnoreStart + exit($exitCode); + // @codeCoverageIgnoreEnd + }; + } + /** * Registers package configuration defaults. * @@ -141,7 +169,12 @@ private function listenForArtisanCommands(Config $config, Redirector $redirector }); } - $events = $this->app->make('events'); + try { + $events = $this->app->make('events'); + } catch (Throwable) { + return; + } + if (! is_object($events) || ! method_exists($events, 'listen')) { return; } @@ -302,7 +335,7 @@ private function isInteractive(): bool private function exitIfRedirected(?int $exitCode): void { if ($exitCode !== null) { - exit($exitCode); + ($this->terminator)($exitCode); } } } diff --git a/src/ShellProcessRunner.php b/src/ShellProcessRunner.php index 683b2f3..467f656 100644 --- a/src/ShellProcessRunner.php +++ b/src/ShellProcessRunner.php @@ -18,11 +18,30 @@ */ final class ShellProcessRunner implements OutputCapturingProcessRunner { + /** + * Opens process resources. + * + * @var callable(list, array, array): (resource|false) + * Returns an open process resource, or `false` when startup fails. + */ + private $processOpener; + /** * Stores stderr captured from the last command. */ private string $errorOutput = ''; + /** + * Creates a shell-backed process runner. + * + * @param (callable(list, array, array): (resource|false))|null $processOpener + * The process opener, or `null` to use `proc_open`. + */ + public function __construct(?callable $processOpener = null) + { + $this->processOpener = $processOpener ?? 'proc_open'; + } + /** * Runs a command and returns its process status. * @@ -101,32 +120,32 @@ private function runProcess(array $command, bool $captureOutput, string &$output $this->errorOutput = ''; $output = ''; $descriptors = [ - 0 => defined('STDIN') ? STDIN : ['file', 'php://stdin', 'r'], - 1 => $captureOutput ? ['pipe', 'w'] : (defined('STDOUT') ? STDOUT : ['file', 'php://stdout', 'w']), + 0 => ['file', 'php://stdin', 'r'], + 1 => $captureOutput ? ['pipe', 'w'] : ['file', 'php://stdout', 'w'], 2 => ['pipe', 'w'], ]; - $process = proc_open($command, $descriptors, $pipes); + /** @var array $pipes */ + $pipes = []; + $process = ($this->processOpener)($command, $descriptors, $pipes); if (! is_resource($process)) { $this->errorOutput = 'Unable to start process.'; return 1; } - if ($captureOutput && isset($pipes[1]) && is_resource($pipes[1])) { + if ($captureOutput) { $output = stream_get_contents($pipes[1]) ?: ''; fclose($pipes[1]); } - if (isset($pipes[2]) && is_resource($pipes[2])) { - $this->errorOutput = stream_get_contents($pipes[2]) ?: ''; - if ($this->errorOutput !== '' && defined('STDERR')) { - fwrite(STDERR, $this->errorOutput); - } - - fclose($pipes[2]); + $this->errorOutput = stream_get_contents($pipes[2]) ?: ''; + if ($this->errorOutput !== '') { + file_put_contents('php://stderr', $this->errorOutput); } + fclose($pipes[2]); + return proc_close($process); } } diff --git a/tests/Unit/DockerComposeWorkdirResolverTest.php b/tests/Unit/DockerComposeWorkdirResolverTest.php index 1847419..dac1439 100644 --- a/tests/Unit/DockerComposeWorkdirResolverTest.php +++ b/tests/Unit/DockerComposeWorkdirResolverTest.php @@ -167,6 +167,150 @@ public function testImageWorkdirFallbackIgnoresEmptyImageWorkdir(): void self::assertNull($empty->getContainerWorkingDirectory()); } + public function testComposeConfigDiscoveryFailuresResolveNothing(): void + { + $this->assertUnresolvedAfterConfigDiscovery(new MockOutputCapturingProcessRunner([1], outputs: [''])); + $this->assertUnresolvedAfterConfigDiscovery(new MockOutputCapturingProcessRunner(outputs: ['not-json'])); + $this->assertUnresolvedAfterConfigDiscovery(new MockOutputCapturingProcessRunner(outputs: [ + json_encode(['networks' => []], JSON_THROW_ON_ERROR), + ])); + $this->assertUnresolvedAfterConfigDiscovery(new MockOutputCapturingProcessRunner(outputs: [ + json_encode(['services' => ['worker' => []]], JSON_THROW_ON_ERROR), + ])); + $this->assertUnresolvedAfterConfigDiscovery(new MockOutputCapturingProcessRunner(outputs: [ + json_encode(['services' => ['php' => 'invalid']], JSON_THROW_ON_ERROR), + ])); + } + + public function testIgnoresNonListVolumesAndReadsWorkingDir(): void + { + $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([ + 'volumes' => [ + 'source' => '/host/app', + 'target' => '/mounted', + ], + 'working_dir' => '/srv/app', + ])]); + $config = $this->createConfig(['service' => 'php']); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner)); + + self::assertSame('/srv/app', $resolution->getWorkdir()); + self::assertNull($resolution->getContainerWorkingDirectory()); + } + + public function testIgnoresMalformedVolumeEntriesAndReadsWorkingDir(): void + { + $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([ + 'volumes' => [ + 'not-an-object', + ['type' => 'volume', 'source' => '/host/app', 'target' => '/mounted'], + ['type' => 'bind', 'source' => '', 'target' => '/mounted'], + ['type' => 'bind', 'source' => '/host/app', 'target' => ''], + ['type' => 'bind', 'source' => false, 'target' => '/mounted'], + ['type' => 'bind', 'source' => '/host/app', 'target' => false], + ], + 'working_dir' => '/srv/app', + ])]); + $config = $this->createConfig(['service' => 'php']); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner)); + + self::assertSame('/srv/app', $resolution->getWorkdir()); + self::assertNull($resolution->getContainerWorkingDirectory()); + } + + public function testVolumeMappingNormalizesWindowsPaths(): void + { + $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([ + 'volumes' => [ + ['type' => 'bind', 'source' => 'C:\\Users\\project', 'target' => '/workspace'], + ], + ])]); + $config = $this->createConfig(['service' => 'php']); + + $resolution = $this->createResolver()->resolve($config, 'C:\\Users\\project\\packages\\app', $runner, $this->createRunner($runner)); + + self::assertSame('/workspace/packages/app', $resolution->getWorkdir()); + self::assertSame('/workspace/packages/app', $resolution->getContainerWorkingDirectory()); + } + + public function testVolumeMappingAppendsDescendantPathsToContainerRoot(): void + { + $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([ + 'volumes' => [ + ['type' => 'bind', 'source' => '/host', 'target' => '/'], + ], + ])]); + $config = $this->createConfig(['service' => 'php']); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner)); + + self::assertSame('/app', $resolution->getWorkdir()); + self::assertSame('/app', $resolution->getContainerWorkingDirectory()); + } + + public function testAppendPathReturnsBaseForEmptySuffix(): void + { + $method = new \ReflectionMethod(DockerComposeWorkdirResolver::class, 'appendPath'); + + self::assertSame('/container', $method->invoke($this->createResolver(), '/container', '')); + } + + public function testExecModeProbeReturnsNullWithoutDockerRunner(): void + { + $runner = new MockOutputCapturingProcessRunner(outputs: [$this->composeOutput([])]); + $config = $this->createConfig(['service' => 'php']); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner); + + self::assertNull($resolution->getWorkdir()); + self::assertNull($resolution->getContainerWorkingDirectory()); + self::assertSame([['docker', 'compose', 'config', '--format', 'json']], $runner->commands); + } + + public function testExecModeProbeReturnsNullWhenServiceStartupFails(): void + { + $runner = new MockOutputCapturingProcessRunner( + [0, 1, 2], + outputs: [$this->composeOutput([]), ''], + ); + $config = $this->createConfig(['service' => 'php']); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner)); + + self::assertNull($resolution->getWorkdir()); + self::assertNull($resolution->getContainerWorkingDirectory()); + self::assertSame(['config', 'ps', 'up'], [ + $runner->commands[0][2], + $runner->commands[1][2], + $runner->commands[2][2], + ]); + } + + public function testImageWorkdirFallbackReturnsNullWhenImageIsMissingOrEmpty(): void + { + foreach ([[], ['image' => '']] as $service) { + $runner = new MockOutputCapturingProcessRunner( + [0, 1], + outputs: [$this->composeOutput($service), ''], + ); + $config = $this->createConfig([ + 'service' => 'php', + 'mode' => 'run', + ]); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner, $this->createRunner($runner)); + + self::assertNull($resolution->getWorkdir()); + self::assertNull($resolution->getContainerWorkingDirectory()); + self::assertSame(['config', 'run'], [ + $runner->commands[0][2], + $runner->commands[1][2], + ]); + } + } + public function testResolvedOptionsDelegateExceptWorkdir(): void { $config = $this->createConfig([ @@ -192,6 +336,17 @@ private function composeOutput(array $service): string return json_encode(['services' => ['php' => $service]], JSON_THROW_ON_ERROR); } + private function assertUnresolvedAfterConfigDiscovery(MockOutputCapturingProcessRunner $runner): void + { + $config = $this->createConfig(['service' => 'php']); + + $resolution = $this->createResolver()->resolve($config, '/host/app', $runner); + + self::assertNull($resolution->getWorkdir()); + self::assertNull($resolution->getContainerWorkingDirectory()); + self::assertSame([['docker', 'compose', 'config', '--format', 'json']], $runner->commands); + } + /** * @param array $options */ diff --git a/tests/Unit/Laravel/ConfigTest.php b/tests/Unit/Laravel/ConfigTest.php index 4e7c95d..e0bcc03 100644 --- a/tests/Unit/Laravel/ConfigTest.php +++ b/tests/Unit/Laravel/ConfigTest.php @@ -8,6 +8,7 @@ namespace Tests\Unit\Laravel; +use empaphy\docker_composer\DockerComposeOptions; use empaphy\docker_composer\Laravel\Config; use empaphy\docker_composer\Laravel\ConsoleEntry; use InvalidArgumentException; @@ -19,6 +20,19 @@ #[CoversClass(ConsoleEntry::class)] final class ConfigTest extends TestCase { + public function testUsesDefaultsForOmittedConfig(): void + { + $config = Config::fromArray([]); + + self::assertFalse($config->isEnabled()); + self::assertSame(DockerComposeOptions::MODE_EXEC, $config->getMode()); + self::assertSame([], $config->getComposeFiles()); + self::assertNull($config->getProjectDirectory()); + self::assertNull($config->getWorkdir()); + self::assertFalse($config->excludes(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']))); + self::assertNull($config->forEntry(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']))); + } + public function testParsesLaravelConfig(): void { $config = Config::fromArray([ @@ -53,6 +67,36 @@ public function testParsesLaravelConfig(): void self::assertSame('php-tools', $config->forEntry(ConsoleEntry::script(':scripts/task.php', ['scripts/task.php']))?->getService()); } + public function testAcceptsEnabledForms(): void + { + /** @var list $cases */ + $cases = [ + [true, true], + [false, false], + [1, true], + [0, false], + ['true', true], + ['false', false], + ['yes', true], + ['no', false], + ['on', true], + ['off', false], + ['1', true], + ['0', false], + ]; + + foreach ($cases as [$value, $expected]) { + self::assertSame($expected, Config::fromArray(['enabled' => $value])->isEnabled()); + } + } + + public function testRejectsInvalidEnabledValues(): void + { + $this->assertInvalidConfig(['enabled' => 2], 'docker_composer.enabled must be a boolean.'); + $this->assertInvalidConfig(['enabled' => 'maybe'], 'docker_composer.enabled must be a boolean.'); + $this->assertInvalidConfig(['enabled' => []], 'docker_composer.enabled must be a boolean.'); + } + public function testDefaultServiceAppliesWhenNoMappingMatches(): void { $config = Config::fromArray([ @@ -85,7 +129,75 @@ public function testRejectsUnknownKeys(): void Config::fromArray(['unknown' => true]); } - public function testRejectsInvalidMappingShape(): void + public function testRejectsNonStringTopLevelKeys(): void + { + $this->assertInvalidConfig([0 => 'invalid'], 'docker_composer must be an array with string keys.'); + } + + public function testRejectsInvalidOptionalStrings(): void + { + foreach (['service', 'project_directory', 'workdir'] as $key) { + $this->assertInvalidConfig([$key => ''], sprintf('docker_composer.%s must be a non-empty string.', $key)); + $this->assertInvalidConfig([$key => false], sprintf('docker_composer.%s must be a non-empty string.', $key)); + } + } + + public function testRejectsInvalidModeAndLists(): void + { + $this->assertInvalidConfig(['mode' => 'invalid'], 'docker_composer.mode must be "exec" or "run".'); + $this->assertInvalidConfig(['mode' => false], 'docker_composer.mode must be "exec" or "run".'); + $this->assertInvalidConfig(['compose_files' => ''], 'docker_composer.compose_files must contain non-empty strings.'); + $this->assertInvalidConfig(['compose_files' => false], 'docker_composer.compose_files must be a list of strings.'); + $this->assertInvalidConfig(['compose_files' => ['path' => 'docker-compose.yaml']], 'docker_composer.compose_files must be a list of strings.'); + $this->assertInvalidConfig(['compose_files' => ['']], 'docker_composer.compose_files must contain only non-empty strings.'); + $this->assertInvalidConfig(['compose_files' => [false]], 'docker_composer.compose_files must contain only non-empty strings.'); + $this->assertInvalidConfig(['exclude' => false], 'docker_composer.exclude must be a list of strings.'); + $this->assertInvalidConfig(['exclude' => ['command' => 'migrate']], 'docker_composer.exclude must be a list of strings.'); + $this->assertInvalidConfig(['exclude' => ['']], 'docker_composer.exclude must contain only non-empty strings.'); + $this->assertInvalidConfig(['exclude' => [false]], 'docker_composer.exclude must contain only non-empty strings.'); + } + + public function testAcceptsComposeFileListAndEmptyServiceMapping(): void + { + $config = Config::fromArray([ + 'compose_files' => ['docker-compose.yaml', 'docker-compose.override.yaml'], + 'service_mapping' => [], + ]); + + self::assertSame(['docker-compose.yaml', 'docker-compose.override.yaml'], $config->getComposeFiles()); + self::assertNull($config->forEntry(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']))); + } + + public function testRejectsInvalidMappingShapes(): void + { + $this->assertInvalidConfig(['service_mapping' => 'php'], 'docker_composer.service_mapping must be an object of strings or lists of strings.'); + $this->assertInvalidConfig(['service_mapping' => ['php']], 'docker_composer.service_mapping must be an object of strings or lists of strings.'); + $this->assertInvalidConfig(['service_mapping' => ['' => 'migrate']], 'docker_composer.service_mapping must use non-empty string keys.'); + $this->assertInvalidConfig(['service_mapping' => [1 => 'migrate']], 'docker_composer.service_mapping must use non-empty string keys.'); + $this->assertInvalidConfig(['service_mapping' => ['php' => []]], 'docker_composer.service_mapping must contain only non-empty strings or lists of non-empty strings.'); + $this->assertInvalidConfig(['service_mapping' => ['php' => ['migrate' => 'migrate']]], 'docker_composer.service_mapping must contain only non-empty strings or lists of non-empty strings.'); + $this->assertInvalidConfig(['service_mapping' => ['php' => false]], 'docker_composer.service_mapping must contain only non-empty strings or lists of non-empty strings.'); + $this->assertInvalidConfig(['service_mapping' => ['php' => '']], 'docker_composer.service_mapping must contain only non-empty strings or lists of non-empty strings.'); + $this->assertInvalidConfig(['service_mapping' => ['php' => ['']]], 'docker_composer.service_mapping must contain only non-empty strings or lists of non-empty strings.'); + } + + public function testExpandsNamePropertyAndAcceptsDuplicateSameServiceMapping(): void + { + $config = Config::fromArray([ + 'service_mapping' => [ + 'php-tools' => [ + NamedCommand::class, + 'migrate', + 'migrate', + ], + ], + ]); + + self::assertSame('php-tools', $config->forEntry(ConsoleEntry::artisan('named:run', null, ['artisan', 'named:run']))?->getService()); + self::assertSame('php-tools', $config->forEntry(ConsoleEntry::artisan('migrate', null, ['artisan', 'migrate']))?->getService()); + } + + public function testRejectsDuplicateMappingToDifferentServices(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('docker_composer.service_mapping must not assign an entry to multiple services.'); @@ -97,6 +209,19 @@ public function testRejectsInvalidMappingShape(): void ], ]); } + + /** + * @param array $raw + */ + private function assertInvalidConfig(array $raw, string $message): void + { + try { + Config::fromArray($raw); + self::fail(sprintf('Expected invalid config exception for message "%s".', $message)); + } catch (InvalidArgumentException $exception) { + self::assertSame($message, $exception->getMessage()); + } + } } final class ExampleCommand {} @@ -110,3 +235,8 @@ final class ExcludedSignatureCommand { protected string $signature = 'excluded:run'; } + +final class NamedCommand +{ + protected string $name = 'named:run'; +} diff --git a/tests/Unit/Laravel/ConsoleEntryTest.php b/tests/Unit/Laravel/ConsoleEntryTest.php index ca149a1..565b247 100644 --- a/tests/Unit/Laravel/ConsoleEntryTest.php +++ b/tests/Unit/Laravel/ConsoleEntryTest.php @@ -32,6 +32,23 @@ public function testCreatesFallbackArtisanDisplayName(): void self::assertSame('/host/app/artisan', $entry->getDisplayName()); } + public function testIgnoresEmptyArtisanNames(): void + { + $method = new \ReflectionMethod(ConsoleEntry::class, 'artisan'); + $entry = $method->invoke(null, '', '', ['artisan']); + + self::assertInstanceOf(ConsoleEntry::class, $entry); + self::assertSame([], $entry->getNames()); + self::assertSame('artisan', $entry->getDisplayName()); + } + + public function testDeduplicatesArtisanNames(): void + { + $entry = ConsoleEntry::artisan(DuplicateConsoleEntryCommand::class, DuplicateConsoleEntryCommand::class, ['artisan']); + + self::assertSame([DuplicateConsoleEntryCommand::class], $entry->getNames()); + } + public function testCreatesRelativeScriptName(): void { self::assertSame(':', ConsoleEntry::scriptName('/host/app', '/host/app')); @@ -42,6 +59,15 @@ public function testCreatesRelativeScriptName(): void self::assertSame(':scripts/task.php', $entry->getDisplayName()); } + + public function testNormalizesWindowsScriptNames(): void + { + self::assertSame(':', ConsoleEntry::scriptName('C:\\host\\app\\', 'C:\\host\\app')); + self::assertSame(':scripts/task.php', ConsoleEntry::scriptName('C:\\host\\app\\scripts\\task.php', 'C:\\host\\app')); + self::assertSame(':scripts/task.php', ConsoleEntry::scriptName('scripts\\task.php', 'C:\\host\\app')); + } } final class ExampleConsoleEntryCommand {} + +final class DuplicateConsoleEntryCommand {} diff --git a/tests/Unit/Laravel/ServiceProviderTest.php b/tests/Unit/Laravel/ServiceProviderTest.php new file mode 100644 index 0000000..b15d276 --- /dev/null +++ b/tests/Unit/Laravel/ServiceProviderTest.php @@ -0,0 +1,827 @@ + [ + 'enabled' => true, + 'service' => 'php', + ], + ]); + $provider = new ServiceProvider(new FakeLaravelApplication($config)); + + $provider->register(); + + $merged = $config->get('docker_composer'); + + self::assertIsArray($merged); + self::assertTrue($merged['enabled']); + self::assertSame('php', $merged['service']); + self::assertSame('exec', $merged['mode']); + self::assertSame([], $merged['compose_files']); + self::assertNull($merged['project_directory']); + self::assertNull($merged['workdir']); + self::assertSame([], $merged['exclude']); + self::assertSame([], $merged['service_mapping']); + } + + public function testBootPublishesConfigAndReturnsOutsideConsole(): void + { + $config = new FakeLaravelConfig(); + $app = new FakeLaravelApplication($config, runningInConsole: false, basePath: '/host/app'); + $provider = new ServiceProvider($app); + + $provider->boot(); + + $source = dirname(__DIR__, 3) . '/config/docker_composer.php'; + + self::assertSame( + [$source => '/host/app/config/docker_composer.php'], + IlluminateServiceProvider::pathsToPublish(ServiceProvider::class, 'docker-composer-config'), + ); + self::assertSame([], $app->makeCalls); + } + + public function testBootReturnsWhenServerArgumentsAreUnavailable(): void + { + $this->assertBootReturnsBeforeResolvingServices(null, hasServerArguments: false); + $this->assertBootReturnsBeforeResolvingServices([]); + $this->assertBootReturnsBeforeResolvingServices('artisan'); + $this->assertBootReturnsBeforeResolvingServices([1 => 'artisan']); + $this->assertBootReturnsBeforeResolvingServices(['artisan', 1]); + } + + public function testBootsNonArtisanScriptWithDisabledConfig(): void + { + $config = new FakeLaravelConfig([ + 'docker_composer' => [ + 'enabled' => false, + 'service' => 'php', + ], + ]); + $app = new FakeLaravelApplication($config, runningInConsole: true, basePath: '/host/app'); + $exitCodes = []; + $provider = new ServiceProvider($app, function (int $exitCode) use (&$exitCodes): void { + $exitCodes[] = $exitCode; + }); + + $this->bootWithArguments($provider, ['/host/app/scripts/task.php', '--flag']); + + self::assertSame(['config'], $app->makeCalls); + self::assertSame([], $exitCodes); + } + + public function testBootReturnsWhenArtisanEventsBindingIsUnavailable(): void + { + $config = new FakeLaravelConfig([ + 'docker_composer' => 'invalid', + ]); + $app = new FakeLaravelApplication($config, runningInConsole: true); + $provider = new ServiceProvider($app); + + $this->bootWithArguments($provider, ['artisan', 'migrate']); + + self::assertSame(['config', 'events'], $app->makeCalls); + } + + #[DataProvider('invalidEventDispatcherExamples')] + public function testBootReturnsWhenArtisanEventsDispatcherIsInvalid(mixed $events): void + { + $config = new FakeLaravelConfig([ + 'docker_composer' => [ + 'enabled' => false, + 'service' => 'php', + ], + ]); + $app = new FakeLaravelApplication($config, runningInConsole: true, events: $events, bindEvents: true); + $provider = new ServiceProvider($app); + + $this->bootWithArguments($provider, ['artisan', 'migrate']); + + self::assertSame(['config', 'events'], $app->makeCalls); + } + + public function testBootRegistersAndInvokesCommandStartingListener(): void + { + $events = new FakeEventsDispatcher(); + $kernel = new FakeLaravelKernel([ + 'migrate' => new FakeListedArtisanCommand(), + ]); + $config = new FakeLaravelConfig([ + 'docker_composer' => [ + 'enabled' => false, + 'service' => 'php', + ], + ]); + $app = new FakeLaravelApplication($config, runningInConsole: true, events: $events, bindEvents: true, kernel: $kernel, bindKernel: true); + $exitCodes = []; + $provider = new ServiceProvider($app, function (int $exitCode) use (&$exitCodes): void { + $exitCodes[] = $exitCode; + }); + + $this->bootWithArguments($provider, ['/host/app/artisan', 'migrate']); + + $listeners = $events->listeners[CommandStarting::class] ?? []; + self::assertCount(1, $listeners); + + $listeners[0](new CommandStarting('migrate', new ArrayInput([]), new NullOutput())); + + self::assertSame(1, $kernel->allCalls); + self::assertSame([], $exitCodes); + } + + public function testBootRegistersAndInvokesArtisanStartingCallback(): void + { + $events = new FakeEventsDispatcher(); + $config = new FakeLaravelConfig([ + 'docker_composer' => [ + 'enabled' => false, + 'service' => 'php', + ], + ]); + $app = new FakeLaravelApplication($config, runningInConsole: true, events: $events, bindEvents: true); + $exitCodes = []; + $provider = new ServiceProvider($app, function (int $exitCode) use (&$exitCodes): void { + $exitCodes[] = $exitCode; + }); + + $this->bootWithArguments($provider, ['/host/app/artisan', '--env=testing', 'migrate']); + $bootstrappers = $this->getArtisanBootstrappers(); + + self::assertCount(1, $bootstrappers); + + $bootstrappers[0](new \stdClass()); + + self::assertSame([], $exitCodes); + } + + /** + * @param list $arguments + */ + #[DataProvider('commandNameArgumentExamples')] + public function testGetsCommandNameFromArguments(array $arguments, ?string $expected): void + { + $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig())); + + self::assertSame($expected, $this->invokeProviderMethod($provider, 'getCommandNameFromArguments', [$arguments])); + } + + #[DataProvider('eventCommandExamples')] + public function testGetsCommandNameFromCommandStartingEvent(string $command, ?string $expected): void + { + $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig())); + $event = new CommandStarting($command, new ArrayInput([]), new NullOutput()); + + self::assertSame($expected, $this->invokeProviderMethod($provider, 'getEventCommandName', [$event])); + } + + public function testGetsNullCommandNameFromUnsetCommandStartingEvent(): void + { + $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig())); + $event = new CommandStarting('migrate', new ArrayInput([]), new NullOutput()); + unset($event->command); + + self::assertNull($this->invokeProviderMethod($provider, 'getEventCommandName', [$event])); + } + + public function testResolvesArtisanCommandClassFromKernelCommandList(): void + { + $kernel = new FakeLaravelKernel([ + 'migrate' => new FakeListedArtisanCommand(), + ]); + $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig(), kernel: $kernel, bindKernel: true)); + + self::assertSame(FakeListedArtisanCommand::class, $this->invokeProviderMethod($provider, 'resolveArtisanCommandClass', ['migrate'])); + } + + public function testResolvesArtisanCommandClassFromArtisanFind(): void + { + $artisan = new FakeArtisanApplication([ + 'queue:work' => new FakeFoundArtisanCommand(), + ]); + $kernel = new FakeLaravelKernel([ + 'migrate' => new FakeListedArtisanCommand(), + ], $artisan); + $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig(), kernel: $kernel, bindKernel: true)); + + self::assertSame(FakeFoundArtisanCommand::class, $this->invokeProviderMethod($provider, 'resolveArtisanCommandClass', ['queue:work'])); + self::assertSame(1, $artisan->findCalls); + } + + public function testArtisanCommandClassResolutionReturnsNullWhenNameIsMissing(): void + { + $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig())); + + self::assertNull($this->invokeProviderMethod($provider, 'resolveArtisanCommandClass', [null])); + } + + public function testArtisanCommandClassResolutionReturnsNullWhenKernelIsMissingOrInvalid(): void + { + $missingKernel = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig())); + $invalidKernel = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig(), kernel: 'invalid', bindKernel: true)); + + self::assertNull($this->invokeProviderMethod($missingKernel, 'resolveArtisanCommandClass', ['migrate'])); + self::assertNull($this->invokeProviderMethod($invalidKernel, 'resolveArtisanCommandClass', ['migrate'])); + } + + public function testArtisanCommandClassResolutionReturnsNullWhenArtisanIsMissingOrInvalid(): void + { + $withoutArtisan = new ServiceProvider(new FakeLaravelApplication( + new FakeLaravelConfig(), + kernel: new FakeLaravelKernelWithoutArtisan(), + bindKernel: true, + )); + $nonObjectArtisan = new ServiceProvider(new FakeLaravelApplication( + new FakeLaravelConfig(), + kernel: new FakeLaravelKernel(artisan: 'invalid'), + bindKernel: true, + )); + $objectWithoutFind = new ServiceProvider(new FakeLaravelApplication( + new FakeLaravelConfig(), + kernel: new FakeLaravelKernel(artisan: new \stdClass()), + bindKernel: true, + )); + $nonObjectCommand = new ServiceProvider(new FakeLaravelApplication( + new FakeLaravelConfig(), + kernel: new FakeLaravelKernel(artisan: new FakeArtisanApplication(['migrate' => 'invalid'])), + bindKernel: true, + )); + + self::assertNull($this->invokeProviderMethod($withoutArtisan, 'resolveArtisanCommandClass', ['migrate'])); + self::assertNull($this->invokeProviderMethod($nonObjectArtisan, 'resolveArtisanCommandClass', ['migrate'])); + self::assertNull($this->invokeProviderMethod($objectWithoutFind, 'resolveArtisanCommandClass', ['migrate'])); + self::assertNull($this->invokeProviderMethod($nonObjectCommand, 'resolveArtisanCommandClass', ['migrate'])); + } + + public function testArtisanCommandClassResolutionReturnsNullWhenResolutionThrows(): void + { + $throwingKernel = new ServiceProvider(new FakeLaravelApplication( + new FakeLaravelConfig(), + kernel: new ThrowingLaravelKernel(), + bindKernel: true, + )); + $throwingArtisan = new ServiceProvider(new FakeLaravelApplication( + new FakeLaravelConfig(), + kernel: new FakeLaravelKernel(artisan: new FakeArtisanApplication(throws: true)), + bindKernel: true, + )); + + self::assertNull($this->invokeProviderMethod($throwingKernel, 'resolveArtisanCommandClass', ['migrate'])); + self::assertNull($this->invokeProviderMethod($throwingArtisan, 'resolveArtisanCommandClass', ['migrate'])); + } + + public function testExitIfRedirectedIgnoresNullExitCode(): void + { + $exitCodes = []; + $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig()), function (int $exitCode) use (&$exitCodes): void { + $exitCodes[] = $exitCode; + }); + + $this->invokeProviderMethod($provider, 'exitIfRedirected', [null]); + + self::assertSame([], $exitCodes); + } + + public function testExitIfRedirectedUsesInjectedTerminator(): void + { + $exitCodes = []; + $provider = new ServiceProvider(new FakeLaravelApplication(new FakeLaravelConfig()), function (int $exitCode) use (&$exitCodes): void { + $exitCodes[] = $exitCode; + }); + + $this->invokeProviderMethod($provider, 'exitIfRedirected', [17]); + + self::assertSame([17], $exitCodes); + } + + /** + * @return iterable + */ + public static function invalidEventDispatcherExamples(): iterable + { + yield 'scalar' => ['invalid']; + yield 'object without listen method' => [new \stdClass()]; + } + + /** + * @return iterable, 1: string|null}> + */ + public static function commandNameArgumentExamples(): iterable + { + yield 'empty arguments' => [[], null]; + yield 'only entrypoint' => [['artisan'], null]; + yield 'argument separator' => [['artisan', '--', 'migrate'], null]; + yield 'options before command' => [['artisan', '--env=testing', '-v', '', 'migrate'], 'migrate']; + yield 'first non-option argument' => [['artisan', 'about', '--json'], 'about']; + } + + /** + * @return iterable + */ + public static function eventCommandExamples(): iterable + { + yield 'non-empty command' => ['migrate', 'migrate']; + yield 'empty command' => ['', null]; + } + + /** + * @param mixed $serverArguments + * The temporary `$_SERVER['argv']` value. + */ + private function assertBootReturnsBeforeResolvingServices(mixed $serverArguments, bool $hasServerArguments = true): void + { + $this->withServerArguments($serverArguments, $hasServerArguments, function (): void { + $app = new FakeLaravelApplication(new FakeLaravelConfig(), runningInConsole: true); + $provider = new ServiceProvider($app); + + $provider->boot(); + + self::assertSame([], $app->makeCalls); + }); + } + + /** + * @param list $arguments + * The temporary server arguments. + */ + private function bootWithArguments(ServiceProvider $provider, array $arguments): void + { + $this->withServerArguments($arguments, true, function () use ($provider): void { + $provider->boot(); + }); + } + + /** + * @param mixed $serverArguments + * The temporary `$_SERVER['argv']` value. + * + * @param Closure(): void $callback + * The callback to run with the temporary arguments. + */ + private function withServerArguments(mixed $serverArguments, bool $hasServerArguments, Closure $callback): void + { + $hadOriginalArguments = array_key_exists('argv', $_SERVER); + $originalArguments = $_SERVER['argv'] ?? null; + + try { + if ($hasServerArguments) { + $_SERVER['argv'] = $serverArguments; + } else { + unset($_SERVER['argv']); + } + + $callback(); + } finally { + if ($hadOriginalArguments) { + $_SERVER['argv'] = $originalArguments; + } else { + unset($_SERVER['argv']); + } + } + } + + /** + * @param list $arguments + * The private method arguments. + */ + private function invokeProviderMethod(ServiceProvider $provider, string $method, array $arguments = []): mixed + { + return (new ReflectionMethod($provider, $method))->invoke($provider, ...$arguments); + } + + /** + * @return list + */ + private function getArtisanBootstrappers(): array + { + $property = (new ReflectionClass(ArtisanApplication::class))->getProperty('bootstrappers'); + $bootstrappers = $property->getValue(); + self::assertIsArray($bootstrappers); + + $callbacks = []; + foreach ($bootstrappers as $bootstrapper) { + self::assertInstanceOf(Closure::class, $bootstrapper); + $callbacks[] = $bootstrapper; + } + + return $callbacks; + } +} + +final class FakeLaravelApplication extends Container implements Application +{ + /** + * Stores resolved container keys. + * + * @var list + */ + public array $makeCalls = []; + + public function __construct( + FakeLaravelConfig $config, + private readonly bool $runningInConsole = false, + private readonly string $basePath = '/host/app', + mixed $events = null, + bool $bindEvents = false, + mixed $kernel = null, + bool $bindKernel = false, + ) { + $this->instance('config', $config); + if ($bindEvents) { + $this->instance('events', $events); + } + + if ($bindKernel) { + $this->instance(Kernel::class, $kernel); + } + } + + /** + * @param array $parameters + */ + public function make($abstract, array $parameters = []) + { + $this->makeCalls[] = is_string($abstract) ? $abstract : get_debug_type($abstract); + + return parent::make($abstract, $parameters); + } + + public function version(): string + { + return 'testing'; + } + + public function basePath($path = ''): string + { + return $this->path($this->basePath, $path); + } + + public function bootstrapPath($path = ''): string + { + return $this->path($this->basePath . '/bootstrap', $path); + } + + public function configPath($path = ''): string + { + return $this->path($this->basePath . '/config', $path); + } + + public function databasePath($path = ''): string + { + return $this->path($this->basePath . '/database', $path); + } + + public function langPath($path = ''): string + { + return $this->path($this->basePath . '/lang', $path); + } + + public function publicPath($path = ''): string + { + return $this->path($this->basePath . '/public', $path); + } + + public function resourcePath($path = ''): string + { + return $this->path($this->basePath . '/resources', $path); + } + + public function storagePath($path = ''): string + { + return $this->path($this->basePath . '/storage', $path); + } + + /** + * @param string|array ...$environments + */ + public function environment(...$environments): string|bool + { + if ($environments === []) { + return 'testing'; + } + + $expected = []; + foreach ($environments as $environment) { + foreach ((array) $environment as $name) { + $expected[] = $name; + } + } + + return in_array('testing', $expected, true); + } + + public function runningInConsole(): bool + { + return $this->runningInConsole; + } + + public function runningUnitTests(): bool + { + return true; + } + + public function hasDebugModeEnabled(): bool + { + return false; + } + + public function maintenanceMode(): MaintenanceMode + { + return new FakeMaintenanceMode(); + } + + public function isDownForMaintenance(): bool + { + return false; + } + + public function registerConfiguredProviders(): void {} + + public function register($provider, $force = false): IlluminateServiceProvider + { + if ($provider instanceof IlluminateServiceProvider) { + return $provider; + } + + if (is_string($provider) && is_a($provider, IlluminateServiceProvider::class, true)) { + return new $provider($this); + } + + throw new \InvalidArgumentException('Expected a service provider instance or class name.'); + } + + public function registerDeferredProvider($provider, $service = null): void {} + + public function resolveProvider($provider): IlluminateServiceProvider + { + if (is_string($provider) && is_a($provider, IlluminateServiceProvider::class, true)) { + return new $provider($this); + } + + throw new \InvalidArgumentException('Expected a service provider class name.'); + } + + public function boot(): void {} + + public function booting($callback): void {} + + public function booted($callback): void {} + + /** + * @param array $bootstrappers + */ + public function bootstrapWith(array $bootstrappers): void {} + + public function getLocale(): string + { + return 'en'; + } + + public function getNamespace(): string + { + return 'Tests\\'; + } + + /** + * @return list + */ + public function getProviders($provider): array + { + return []; + } + + public function hasBeenBootstrapped(): bool + { + return false; + } + + public function loadDeferredProviders(): void {} + + public function setLocale($locale): void {} + + public function shouldSkipMiddleware(): bool + { + return false; + } + + public function terminating($callback): Application + { + return $this; + } + + public function terminate(): void {} + + private function path(string $base, mixed $path): string + { + $path = is_string($path) ? $path : ''; + + return $base . ($path === '' ? '' : '/' . $path); + } +} + +final class FakeMaintenanceMode implements MaintenanceMode +{ + /** + * @param array $payload + */ + public function activate(array $payload): void {} + + public function deactivate(): void {} + + public function active(): bool + { + return false; + } + + /** + * @return array + */ + public function data(): array + { + return []; + } +} + +final class FakeLaravelConfig +{ + /** + * Stores fake Laravel configuration values. + * + * @param array $values + * The initial config values. + */ + public function __construct(private array $values = []) {} + + public function get(string $key, mixed $default = null): mixed + { + return $this->values[$key] ?? $default; + } + + public function set(string $key, mixed $value): void + { + $this->values[$key] = $value; + } +} + +final class FakeEventsDispatcher +{ + /** + * Stores listeners by event class. + * + * @var array> + */ + public array $listeners = []; + + public function listen(string $event, Closure $listener): void + { + $this->listeners[$event] ??= []; + $this->listeners[$event][] = $listener; + } +} + +abstract class FakeLaravelKernelBase +{ + /** + * @return array + */ + abstract public function all(): array; +} + +final class FakeLaravelKernel extends FakeLaravelKernelBase +{ + public int $allCalls = 0; + + /** + * @param array $commands + */ + public function __construct(private readonly array $commands = [], private readonly mixed $artisan = null) {} + + /** + * @return array + */ + public function all(): array + { + $this->allCalls++; + + return $this->commands; + } + + public function getArtisan(): mixed + { + return $this->artisan; + } +} + +final class FakeLaravelKernelWithoutArtisan extends FakeLaravelKernelBase +{ + /** + * @param array $commands + */ + public function __construct(private readonly array $commands = []) {} + + /** + * @return array + */ + public function all(): array + { + return $this->commands; + } +} + +final class ThrowingLaravelKernel extends FakeLaravelKernelBase +{ + /** + * @return array + */ + public function all(): array + { + throw new \RuntimeException('Kernel command resolution failed.'); + } +} + +final class FakeArtisanApplication +{ + public int $findCalls = 0; + + /** + * @param array $commands + */ + public function __construct(private readonly array $commands = [], private readonly bool $throws = false) {} + + public function find(string $commandName): mixed + { + $this->findCalls++; + if ($this->throws) { + throw new \RuntimeException('Artisan command resolution failed.'); + } + + return $this->commands[$commandName] ?? null; + } +} + +final class FakeListedArtisanCommand {} + +final class FakeFoundArtisanCommand {} diff --git a/tests/Unit/ShellProcessRunnerTest.php b/tests/Unit/ShellProcessRunnerTest.php new file mode 100644 index 0000000..5ab71d7 --- /dev/null +++ b/tests/Unit/ShellProcessRunnerTest.php @@ -0,0 +1,92 @@ +runWithOutput([ + PHP_BINARY, + '-r', + 'fwrite(STDOUT, "captured output\n");', + ], $output); + + self::assertSame(0, $exitCode); + self::assertSame("captured output\n", $output); + self::assertSame('', $runner->getErrorOutput()); + } + + public function testRunReturnsExitCodeAndCapturesStderr(): void + { + $runner = new ShellProcessRunner(); + + $exitCode = $runner->run([ + PHP_BINARY, + '-r', + 'fwrite(STDERR, "captured error\n"); exit(7);', + ]); + + self::assertSame(7, $exitCode); + self::assertSame("captured error\n", $runner->getErrorOutput()); + } + + public function testRunWithOutputReturnsFailureWhenProcessCannotStart(): void + { + $runner = new ShellProcessRunner(self::failToOpenProcess(...)); + $output = 'previous output'; + + $exitCode = $runner->runWithOutput([ + PHP_BINARY, + '-r', + 'fwrite(STDOUT, "unreachable output\n");', + ], $output); + + self::assertSame(1, $exitCode); + self::assertSame('', $output); + self::assertSame('Unable to start process.', $runner->getErrorOutput()); + } + + public function testSupportsTtyReturnsFalse(): void + { + self::assertFalse((new ShellProcessRunner())->supportsTty()); + } + + /** + * Fakes a failed process startup. + * + * @param list $command + * The command that would have been executed. + * + * @param array $descriptors + * The process descriptors that would have been used. + * + * @param array $pipes + * The process pipe resources. + * + * @return false + * Always returns `false` to mimic `proc_open` startup failure. + */ + private static function failToOpenProcess(array $command, array $descriptors, array &$pipes): mixed + { + unset($command, $descriptors); + + $pipes = []; + + return false; + } +}