From 09fb571120babf0aecd16f6dd89511729e57669f Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sat, 9 May 2026 13:47:19 +0200 Subject: [PATCH 1/5] fix: tolerate stale config during plugin update Composer can keep helper classes loaded from the old plugin version while eval-loading the new plugin class during an update. Guarding the new duplicate service-mapping API prevents the update from failing when projects already use service-mapping. Add an integration regression that installs the old plugin commit and updates to the current package. --- src/DockerComposerPlugin.php | 15 +++++ .../DockerComposerIntegrationTest.php | 66 ++++++++++++++++++- 2 files changed, 78 insertions(+), 3 deletions(-) diff --git a/src/DockerComposerPlugin.php b/src/DockerComposerPlugin.php index 88de511..639840a 100644 --- a/src/DockerComposerPlugin.php +++ b/src/DockerComposerPlugin.php @@ -291,6 +291,13 @@ private function writeUnknownConfigWarning(): void } foreach ($this->config->getUnknownKeys() as $key) { + if ( + $key === 'service-mapping' + && ! method_exists($this->config, 'getDuplicateServiceMappingScripts') // @phpstan-ignore function.alreadyNarrowedType + ) { + continue; + } + $this->io->writeError(sprintf( 'docker-composer: Unknown extra.docker-composer key "%s" will be ignored.', $key, @@ -312,6 +319,14 @@ private function writeDuplicateServiceMappingWarnings(IOInterface $io): void return; } + if ( + ! method_exists($this->config, 'getDuplicateServiceMappingScripts') // @phpstan-ignore function.alreadyNarrowedType + ) { + $this->duplicateServiceMappingWarningsWritten = true; + + return; + } + foreach ($this->config->getDuplicateServiceMappingScripts() as $duplicate) { $io->writeError(sprintf( 'docker-composer: duplicate service-mapping script "%s" for service "%s" will be ignored.', diff --git a/tests/Integration/DockerComposerIntegrationTest.php b/tests/Integration/DockerComposerIntegrationTest.php index e0ad3d1..328a008 100644 --- a/tests/Integration/DockerComposerIntegrationTest.php +++ b/tests/Integration/DockerComposerIntegrationTest.php @@ -63,6 +63,44 @@ public function testServiceMappingOverrideRedirectsToConfiguredService(): void self::assertSame('override', trim((string) file_get_contents($projectDirectory . '/result.txt'))); } + public function testServiceMappingProjectCanUpdateFromOldPluginVersion(): void + { + $projectDirectory = $this->createProject( + [ + 'service-mapping' => [ + 'php' => 'mark', + ], + ], + [ + [ + 'type' => 'vcs', + 'url' => dirname(__DIR__, 2), + ], + ], + 'dev-main#1e1b4e7', + ); + $this->installProject($projectDirectory); + + $this->updateProjectRepositories($projectDirectory, [ + [ + 'type' => 'path', + 'url' => dirname(__DIR__, 2), + 'options' => ['symlink' => false], + ], + [ + 'type' => 'vcs', + 'url' => dirname(__DIR__, 2), + ], + ]); + $result = $this->runCommand( + ['composer', 'require', '-m', 'empaphy/docker-composer:*@dev', '--no-interaction', '--no-progress'], + $projectDirectory, + ); + + self::assertStringNotContainsString('getDuplicateServiceMappingScripts', $result->stderr); + self::assertStringNotContainsString('Update of empaphy/docker-composer failed', $result->stderr); + } + public function testRunModeBypassMissingConfigAndInsideContainerBehavior(): void { $runProjectDirectory = $this->createProject([ @@ -107,8 +145,9 @@ public function testRunModeBypassMissingConfigAndInsideContainerBehavior(): void /** * @param array $dockerComposerConfig + * @param list>|null $repositories */ - private function createProject(array $dockerComposerConfig): string + private function createProject(array $dockerComposerConfig, ?array $repositories = null, string $requireVersion = '*'): string { $projectDirectory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR @@ -125,13 +164,13 @@ private function createProject(array $dockerComposerConfig): string 'description' => 'Temporary docker-composer integration fixture.', 'minimum-stability' => 'dev', 'prefer-stable' => true, - 'repositories' => [[ + 'repositories' => $repositories ?? [[ 'type' => 'path', 'url' => dirname(__DIR__, 2), 'options' => ['symlink' => false], ]], 'require' => [ - 'empaphy/docker-composer' => '*', + 'empaphy/docker-composer' => $requireVersion, ], 'config' => [ 'allow-plugins' => [ @@ -174,6 +213,27 @@ private function createProject(array $dockerComposerConfig): string return $projectDirectory; } + /** + * @param list> $repositories + */ + private 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'); From d51e1926c94aa105187d27d0c18f0b20024eb3c2 Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sat, 9 May 2026 20:38:06 +0200 Subject: [PATCH 2/5] test: make plugin update regression CI-safe The regression installed the old plugin from the local checkout as a VCS repository, but GitHub Actions checks out pull requests as shallow detached merge refs without usable branch or tag metadata. Use the canonical GitHub VCS repository for the old install and keep the local path repository for the update under test. --- tests/Integration/DockerComposerIntegrationTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Integration/DockerComposerIntegrationTest.php b/tests/Integration/DockerComposerIntegrationTest.php index 328a008..96aa8e9 100644 --- a/tests/Integration/DockerComposerIntegrationTest.php +++ b/tests/Integration/DockerComposerIntegrationTest.php @@ -74,7 +74,7 @@ public function testServiceMappingProjectCanUpdateFromOldPluginVersion(): void [ [ 'type' => 'vcs', - 'url' => dirname(__DIR__, 2), + 'url' => 'https://github.com/empaphy/docker-composer.git', ], ], 'dev-main#1e1b4e7', @@ -89,7 +89,7 @@ public function testServiceMappingProjectCanUpdateFromOldPluginVersion(): void ], [ 'type' => 'vcs', - 'url' => dirname(__DIR__, 2), + 'url' => 'https://github.com/empaphy/docker-composer.git', ], ]); $result = $this->runCommand( From 71eeba61c033645b15248152a584a5a5574aa12f Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sat, 9 May 2026 20:40:49 +0200 Subject: [PATCH 3/5] test: support composer 1 require command Composer 1 does not provide the minimal-changes require option used by the regression. Build the require command from the active integration Composer version so the update path is still covered across the CI matrix. --- .../DockerComposerIntegrationTest.php | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/tests/Integration/DockerComposerIntegrationTest.php b/tests/Integration/DockerComposerIntegrationTest.php index 96aa8e9..1fbf633 100644 --- a/tests/Integration/DockerComposerIntegrationTest.php +++ b/tests/Integration/DockerComposerIntegrationTest.php @@ -93,7 +93,7 @@ public function testServiceMappingProjectCanUpdateFromOldPluginVersion(): void ], ]); $result = $this->runCommand( - ['composer', 'require', '-m', 'empaphy/docker-composer:*@dev', '--no-interaction', '--no-progress'], + $this->getRequireCommand('empaphy/docker-composer:*@dev'), $projectDirectory, ); @@ -244,6 +244,28 @@ private function getComposerImage(): string return 'composer:' . $composerVersion; } + /** + * Gets a Composer require command for the active integration Composer version. + * + * @param string $package + * The package constraint to require. + * + * @return list + * Returns a Composer require command compatible with the active version. + */ + private function getRequireCommand(string $package): array + { + $command = ['composer', 'require', $package, '--no-interaction', '--no-progress']; + $composerVersion = getenv('DOCKER_COMPOSER_TEST_COMPOSER_VERSION'); + if ($composerVersion === false || str_starts_with($composerVersion, '1.')) { + return $command; + } + + array_splice($command, 2, 0, '-m'); + + return $command; + } + private function installProject(string $projectDirectory): void { $this->runCommand(['composer', 'install', '--no-interaction', '--no-progress', '--prefer-dist'], $projectDirectory); From fb9629e9a3fe7d23973d6d6faa2fdf4e83d6a54b Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sat, 9 May 2026 20:43:11 +0200 Subject: [PATCH 4/5] test: avoid minimal changes for pinned composer Pinned Composer versions in the integration matrix do not all support the require --minimal-changes option. Keep that option for the floating current Composer v2 case and use the broadly supported require command for historical versions. --- tests/Integration/DockerComposerIntegrationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Integration/DockerComposerIntegrationTest.php b/tests/Integration/DockerComposerIntegrationTest.php index 1fbf633..dfa86c5 100644 --- a/tests/Integration/DockerComposerIntegrationTest.php +++ b/tests/Integration/DockerComposerIntegrationTest.php @@ -257,7 +257,7 @@ private function getRequireCommand(string $package): array { $command = ['composer', 'require', $package, '--no-interaction', '--no-progress']; $composerVersion = getenv('DOCKER_COMPOSER_TEST_COMPOSER_VERSION'); - if ($composerVersion === false || str_starts_with($composerVersion, '1.')) { + if ($composerVersion !== false && $composerVersion !== 'v2') { return $command; } From d0fac23c8377f980c04c2479918e1d285e5f321e Mon Sep 17 00:00:00 2001 From: Alwin Garside Date: Sat, 9 May 2026 22:15:57 +0200 Subject: [PATCH 5/5] tests: improve tests --- AGENTS.md | 18 +- src/DockerComposerPlugin.php | 15 - .../DockerComposerIntegrationTest.php | 64 +-- tests/TestCase.php | 31 +- tests/Unit/ComposerProcessRunnerTest.php | 101 ++++ .../Unit/DockerComposeCommandBuilderTest.php | 65 +++ tests/Unit/DockerComposerConfigTest.php | 110 ++++ ...nTest.php => DockerComposerPluginTest.php} | 535 +++--------------- .../Unit/EnvironmentContainerDetectorTest.php | 56 ++ tests/Unit/Mocks/MockCommandBuilder.php | 27 + tests/Unit/Mocks/MockContainerDetector.php | 17 + .../MockOutputCapturingProcessRunner.php | 36 ++ tests/Unit/Mocks/MockProcessExecutor.php | 53 ++ tests/Unit/Mocks/MockProcessRunner.php | 50 ++ 14 files changed, 625 insertions(+), 553 deletions(-) create mode 100644 tests/Unit/ComposerProcessRunnerTest.php create mode 100644 tests/Unit/DockerComposeCommandBuilderTest.php create mode 100644 tests/Unit/DockerComposerConfigTest.php rename tests/Unit/{DockerComposePluginTest.php => DockerComposerPluginTest.php} (57%) create mode 100644 tests/Unit/EnvironmentContainerDetectorTest.php create mode 100644 tests/Unit/Mocks/MockCommandBuilder.php create mode 100644 tests/Unit/Mocks/MockContainerDetector.php create mode 100644 tests/Unit/Mocks/MockOutputCapturingProcessRunner.php create mode 100644 tests/Unit/Mocks/MockProcessExecutor.php create mode 100644 tests/Unit/Mocks/MockProcessRunner.php diff --git a/AGENTS.md b/AGENTS.md index 6c2bd5a..b258cc9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,12 @@ In commit messages use conventional commits and provide justification of the changes in the body. In all interactions and plans be extremely concise — sacrifice grammar for the sake of conciseness. Conciseness alone does not justify omitting information or intent. +## Plan Mode +Make plans extremely concise — sacrifice grammar for the sake of concision. Conciseness alone does not justify omitting information or intent. +At the end of each plan, give me a list of unresolved questions to answer, if any. + ## 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` @@ -16,9 +21,9 @@ If PHPStan cannot model valid runtime behavior, use the narrowest fix: 2. otherwise add a targeted `@phpstan-ignore ` on the exact line. Do not add broad suppressions, baselines, or unclear type workarounds. -## Plan Mode -Make plans extremely concise — sacrifice grammar for the sake of concision. 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. +## Coding Style +All PHP code must adhere to PER Coding Syle 3.0, which also includes PSR-1: Basic Coding Standard. +Files should _either_ declare symbols _or_ cause side-effects but not both. ## PHPDoc Add descriptive PHPDoc comments to all Structural Elements in PHP code under `src/`. Include descriptive `@param` and `@return` tags for all argument and return types, and `@var` tags for all parameters. @@ -87,10 +92,3 @@ class ## 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. - -## CI -CI runs in GitHub Actions. It checks the following: -- PHP Coding Style using PHP-CS-Fixer -- Static Analysis using PHPStan -- Unit Tests using PHPUnit -- Integration Tests using PHPUnit diff --git a/src/DockerComposerPlugin.php b/src/DockerComposerPlugin.php index 639840a..88de511 100644 --- a/src/DockerComposerPlugin.php +++ b/src/DockerComposerPlugin.php @@ -291,13 +291,6 @@ private function writeUnknownConfigWarning(): void } foreach ($this->config->getUnknownKeys() as $key) { - if ( - $key === 'service-mapping' - && ! method_exists($this->config, 'getDuplicateServiceMappingScripts') // @phpstan-ignore function.alreadyNarrowedType - ) { - continue; - } - $this->io->writeError(sprintf( 'docker-composer: Unknown extra.docker-composer key "%s" will be ignored.', $key, @@ -319,14 +312,6 @@ private function writeDuplicateServiceMappingWarnings(IOInterface $io): void return; } - if ( - ! method_exists($this->config, 'getDuplicateServiceMappingScripts') // @phpstan-ignore function.alreadyNarrowedType - ) { - $this->duplicateServiceMappingWarningsWritten = true; - - return; - } - foreach ($this->config->getDuplicateServiceMappingScripts() as $duplicate) { $io->writeError(sprintf( 'docker-composer: duplicate service-mapping script "%s" for service "%s" will be ignored.', diff --git a/tests/Integration/DockerComposerIntegrationTest.php b/tests/Integration/DockerComposerIntegrationTest.php index dfa86c5..3fcee3b 100644 --- a/tests/Integration/DockerComposerIntegrationTest.php +++ b/tests/Integration/DockerComposerIntegrationTest.php @@ -63,44 +63,6 @@ public function testServiceMappingOverrideRedirectsToConfiguredService(): void self::assertSame('override', trim((string) file_get_contents($projectDirectory . '/result.txt'))); } - public function testServiceMappingProjectCanUpdateFromOldPluginVersion(): void - { - $projectDirectory = $this->createProject( - [ - 'service-mapping' => [ - 'php' => 'mark', - ], - ], - [ - [ - 'type' => 'vcs', - 'url' => 'https://github.com/empaphy/docker-composer.git', - ], - ], - 'dev-main#1e1b4e7', - ); - $this->installProject($projectDirectory); - - $this->updateProjectRepositories($projectDirectory, [ - [ - 'type' => 'path', - 'url' => dirname(__DIR__, 2), - 'options' => ['symlink' => false], - ], - [ - 'type' => 'vcs', - 'url' => 'https://github.com/empaphy/docker-composer.git', - ], - ]); - $result = $this->runCommand( - $this->getRequireCommand('empaphy/docker-composer:*@dev'), - $projectDirectory, - ); - - self::assertStringNotContainsString('getDuplicateServiceMappingScripts', $result->stderr); - self::assertStringNotContainsString('Update of empaphy/docker-composer failed', $result->stderr); - } - public function testRunModeBypassMissingConfigAndInsideContainerBehavior(): void { $runProjectDirectory = $this->createProject([ @@ -216,7 +178,7 @@ private function createProject(array $dockerComposerConfig, ?array $repositories /** * @param list> $repositories */ - private function updateProjectRepositories(string $projectDirectory, array $repositories): void + protected function updateProjectRepositories(string $projectDirectory, array $repositories): void { $composerJsonPath = $projectDirectory . '/composer.json'; $composerJson = json_decode((string) file_get_contents($composerJsonPath), true); @@ -253,7 +215,7 @@ private function getComposerImage(): string * @return list * Returns a Composer require command compatible with the active version. */ - private function getRequireCommand(string $package): array + protected function getRequireCommand(string $package): array { $command = ['composer', 'require', $package, '--no-interaction', '--no-progress']; $composerVersion = getenv('DOCKER_COMPOSER_TEST_COMPOSER_VERSION'); @@ -275,7 +237,7 @@ private function installProject(string $projectDirectory): void * @param list $command * @param array $environment */ - private function runCommand(array $command, string $workingDirectory, array $environment = [], bool $failOnError = true): ProcessResult + private function runCommand(array $command, string $workingDirectory, array $environment = [], bool $failOnError = true): void { $descriptorSpec = [ 1 => ['pipe', 'w'], @@ -297,18 +259,15 @@ private function runCommand(array $command, string $workingDirectory, array $env fclose($pipes[2]); $exitCode = proc_close($process); - $result = new ProcessResult($exitCode, (string) $stdout, (string) $stderr); - if ($failOnError && $result->exitCode !== 0) { + if ($failOnError && $exitCode !== 0) { self::fail(sprintf( "Command failed with exit code %d:\n%s\n\nSTDOUT:\n%s\n\nSTDERR:\n%s", - $result->exitCode, + $exitCode, implode(' ', $command), - $result->stdout, - $result->stderr, + $stdout, + $stderr, )); } - - return $result; } private function removeDirectory(string $directory): void @@ -333,12 +292,3 @@ private function removeDirectory(string $directory): void rmdir($directory); } } - -final class ProcessResult -{ - public function __construct( - public int $exitCode, - public string $stdout, - public string $stderr, - ) {} -} diff --git a/tests/TestCase.php b/tests/TestCase.php index 3e28fb9..b6942ce 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -2,7 +2,36 @@ namespace Tests; +use Composer\Composer; +use Composer\Config; +use Composer\EventDispatcher\EventDispatcher; +use Composer\IO\BufferIO; +use Composer\Package\RootPackage; +use Symfony\Component\Console\Output\StreamOutput; + +use function getcwd; + abstract class TestCase extends \PHPUnit\Framework\TestCase { - // + /** + * @param array> $scripts + * @param array $extra + * + * @return array{0: Composer, 1: BufferIO} + */ + protected function createComposer(array $scripts, array $extra): array + { + $composer = new Composer(); + $package = new RootPackage('root/project', '1.0.0', '1.0.0'); + $package->setScripts($scripts); + $package->setExtra($extra); + $composer->setPackage($package); + $composer->setConfig(new Config(false, getcwd() ?: null)); + + $io = new BufferIO('', StreamOutput::VERBOSITY_NORMAL); + $dispatcher = new EventDispatcher($composer, $io); + $composer->setEventDispatcher($dispatcher); + + return [$composer, $io]; + } } diff --git a/tests/Unit/ComposerProcessRunnerTest.php b/tests/Unit/ComposerProcessRunnerTest.php new file mode 100644 index 0000000..1385a5b --- /dev/null +++ b/tests/Unit/ComposerProcessRunnerTest.php @@ -0,0 +1,101 @@ + true); + $processExecutor = new MockProcessExecutor(3, 4, 'executor error'); + $property = new ReflectionProperty($runner, 'processExecutor'); + $property->setValue($runner, $processExecutor); + + self::assertTrue($runner->supportsTty()); + self::assertSame(3, $runner->run(['docker', 'compose'])); + self::assertSame(4, $runner->run(['docker', 'compose'], true)); + self::assertSame('executor error', $runner->getErrorOutput()); + $expectedCommand = implode(' ', array_map([ProcessExecutor::class, 'escape'], ['docker', 'compose'])); + + self::assertSame([$expectedCommand], $processExecutor->commands); + self::assertSame([$expectedCommand], $processExecutor->ttyCommands); + } + + public function testComposerProcessRunnerCapturesOutput(): void + { + $io = new BufferIO(); + $runner = new ComposerProcessRunner($io, static fn(): bool => true); + $processExecutor = new MockProcessExecutor(3, 4, 'executor error', 'captured output'); + $property = new ReflectionProperty($runner, 'processExecutor'); + $property->setValue($runner, $processExecutor); + + $output = ''; + + self::assertSame(3, $runner->runWithOutput(['docker', 'compose'], $output)); + self::assertSame('captured output', $output); + } + + public function testComposerProcessRunnerFallsBackWhenCurrentProcessDoesNotSupportTty(): void + { + $io = new BufferIO(); + $runner = new ComposerProcessRunner($io, static fn(): bool => false); + $processExecutor = new MockProcessExecutor(3, 4, 'executor error'); + $property = new ReflectionProperty($runner, 'processExecutor'); + $property->setValue($runner, $processExecutor); + + self::assertFalse($runner->supportsTty()); + self::assertSame(3, $runner->run(['docker', 'compose'], true)); + $expectedCommand = implode(' ', array_map([ProcessExecutor::class, 'escape'], ['docker', 'compose'])); + + self::assertSame([$expectedCommand], $processExecutor->commands); + self::assertSame([], $processExecutor->ttyCommands); + } + + public function testComposerProcessRunnerUsesComposerPlatformTtyDetection(): void + { + $method = new \ReflectionMethod(ComposerProcessRunner::class, 'detectTtySupport'); + + self::assertSame(Platform::isTty(), $method->invoke(null)); + } + + #[RunInSeparateProcess] + #[PreserveGlobalState(false)] + public function testComposerProcessRunnerUsesStreamFallbackWithoutComposerPlatform(): void + { + $method = new \ReflectionMethod(ComposerProcessRunner::class, 'detectTtySupport'); + $autoloaders = spl_autoload_functions() ?: []; + + foreach ($autoloaders as $autoload) { + spl_autoload_unregister($autoload); + } + + try { + $supportsTty = $method->invoke(null); + } finally { + foreach ($autoloaders as $autoload) { + spl_autoload_register($autoload); + } + } + + self::assertSame(defined('STDOUT') && stream_isatty(STDOUT), $supportsTty); + } +} diff --git a/tests/Unit/DockerComposeCommandBuilderTest.php b/tests/Unit/DockerComposeCommandBuilderTest.php new file mode 100644 index 0000000..f45ef81 --- /dev/null +++ b/tests/Unit/DockerComposeCommandBuilderTest.php @@ -0,0 +1,65 @@ +createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $config = DockerComposerConfig::fromComposer($composer); + $event = new ScriptEvent('test', $composer, $io, false, [null, true]); + + $command = (new DockerComposeCommandBuilder())->buildScriptCommand($config, $event, false); + + self::assertSame(['--', '', '1'], array_slice($command, -3)); + } + + public function testCommandBuilderForwardsComposerProcessTimeout(): void + { + [$composer, $io] = $this->createComposer([], [ + 'docker-composer' => ['service' => 'php'], + ]); + $previousTimeout = ProcessExecutor::getTimeout(); + + ProcessExecutor::setTimeout(42); + try { + $config = DockerComposerConfig::fromComposer($composer); + $event = new ScriptEvent('test', $composer, $io); + + $command = (new DockerComposeCommandBuilder())->buildScriptCommand($config, $event, false); + } finally { + ProcessExecutor::setTimeout($previousTimeout); + } + + self::assertSame('--timeout=42', $command[count($command) - 2]); + } + + public function testCommandBuilderRejectsNonScalarArguments(): void + { + $method = new \ReflectionMethod(DockerComposeCommandBuilder::class, 'stringifyArgument'); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Composer script arguments must be scalar values.'); + + $method->invoke(new DockerComposeCommandBuilder(), []); + } +} diff --git a/tests/Unit/DockerComposerConfigTest.php b/tests/Unit/DockerComposerConfigTest.php new file mode 100644 index 0000000..2159529 --- /dev/null +++ b/tests/Unit/DockerComposerConfigTest.php @@ -0,0 +1,110 @@ +createComposer([], []); + + $config = DockerComposerConfig::fromComposer($composer); + + self::assertFalse($config->isConfigured()); + self::assertSame(DockerComposerConfig::MODE_EXEC, $config->getMode()); + self::assertSame([], $config->getComposeFiles()); + self::assertNull($config->getProjectDirectory()); + self::assertNull($config->getWorkdir()); + self::assertFalse($config->isExcluded('test')); + self::assertSame([], $config->getUnknownKeys()); + } + + public function testConfigAcceptsEmptyServiceMapping(): void + { + [$composer] = $this->createComposer([], [ + 'docker-composer' => [ + 'service-mapping' => [], + ], + ]); + + $config = DockerComposerConfig::fromComposer($composer); + + self::assertFalse($config->isConfiguredForScript('test')); + } + + public function testConfigRejectsInvalidShapes(): void + { + $this->assertInvalidConfig(['docker-composer' => 'invalid'], 'extra.docker-composer must be an object.'); + $this->assertInvalidConfig(['docker-composer' => [0 => 'invalid']], 'extra.docker-composer must be an object.'); + $this->assertInvalidConfig(['docker-composer' => ['service' => '']], 'extra.docker-composer.service must be a non-empty string.'); + $this->assertInvalidConfig(['docker-composer' => ['compose-files' => '']], 'extra.docker-composer.compose-files must contain non-empty strings.'); + $this->assertInvalidConfig(['docker-composer' => ['exclude' => ['script' => true]]], 'extra.docker-composer.exclude must be a list of strings.'); + $this->assertInvalidConfig(['docker-composer' => ['exclude' => [1]]], 'extra.docker-composer.exclude must contain only non-empty strings.'); + $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => 'php']], 'extra.docker-composer.service-mapping must be an object of strings or lists of strings.'); + $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php']]], 'extra.docker-composer.service-mapping must be an object of strings or lists of strings.'); + $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['' => 'test']]], 'extra.docker-composer.service-mapping must use non-empty string keys.'); + $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php' => '']]], 'extra.docker-composer.service-mapping must contain only non-empty strings or lists of non-empty strings.'); + $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php' => ['test' => 'test']]]], 'extra.docker-composer.service-mapping must contain only non-empty strings or lists of non-empty strings.'); + $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php' => ['']]]], 'extra.docker-composer.service-mapping must contain only non-empty strings or lists of non-empty strings.'); + $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php' => []]]], 'extra.docker-composer.service-mapping must map each service to at least one script.'); + $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php' => 'test', 'php-tools' => ['test']]]], 'extra.docker-composer.service-mapping must not assign a script to multiple services.'); + } + + public function testUnconfiguredServiceAccessFails(): void + { + [$composer] = $this->createComposer([], []); + $config = DockerComposerConfig::fromComposer($composer); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Docker Compose service is not configured.'); + + $config->getService(); + } + + /** + * @param array $extra + */ + private function assertInvalidConfig(array $extra, string $message): void + { + [$composer] = $this->createComposer([], $extra); + + try { + DockerComposerConfig::fromComposer($composer); + self::fail(sprintf('Expected invalid config exception for message "%s".', $message)); + } catch (\InvalidArgumentException $exception) { + self::assertSame($message, $exception->getMessage()); + } + } +} diff --git a/tests/Unit/DockerComposePluginTest.php b/tests/Unit/DockerComposerPluginTest.php similarity index 57% rename from tests/Unit/DockerComposePluginTest.php rename to tests/Unit/DockerComposerPluginTest.php index c0f89eb..65bf5ae 100644 --- a/tests/Unit/DockerComposePluginTest.php +++ b/tests/Unit/DockerComposerPluginTest.php @@ -8,42 +8,32 @@ namespace Tests\Unit; -use Composer\Composer; -use Composer\Config; use Composer\EventDispatcher\Event; -use Composer\EventDispatcher\EventDispatcher; use Composer\EventDispatcher\ScriptExecutionException; -use Composer\IO\BufferIO; -use Composer\Package\RootPackage; use Composer\Script\Event as ScriptEvent; -use Composer\Util\Platform; -use Composer\Util\ProcessExecutor; use empaphy\docker_composer\ComposerProcessRunner; -use empaphy\docker_composer\ContainerDetector; use empaphy\docker_composer\DockerComposeCommandBuilder; use empaphy\docker_composer\DockerComposerConfig; use empaphy\docker_composer\DockerComposerPlugin; -use empaphy\docker_composer\EnvironmentContainerDetector; -use empaphy\docker_composer\OutputCapturingProcessRunner; -use empaphy\docker_composer\ProcessRunner; +use InvalidArgumentException; use PHPUnit\Framework\Attributes\BackupGlobals; use PHPUnit\Framework\Attributes\CoversClass; -use PHPUnit\Framework\Attributes\PreserveGlobalState; -use PHPUnit\Framework\Attributes\RunInSeparateProcess; -use Symfony\Component\Console\Output\StreamOutput; use Tests\TestCase; +use Tests\Unit\Mocks\MockCommandBuilder; +use Tests\Unit\Mocks\MockContainerDetector; +use Tests\Unit\Mocks\MockOutputCapturingProcessRunner; +use Tests\Unit\Mocks\MockProcessRunner; #[CoversClass(DockerComposerPlugin::class)] +#[CoversClass(ComposerProcessRunner::class)] #[CoversClass(DockerComposerConfig::class)] #[CoversClass(DockerComposeCommandBuilder::class)] -#[CoversClass(ComposerProcessRunner::class)] -#[CoversClass(EnvironmentContainerDetector::class)] -class DockerComposePluginTest extends TestCase +class DockerComposerPluginTest extends TestCase { public function testPluginLifecycleMethodsAreSafe(): void { [$composer, $io] = $this->createComposer([], []); - $plugin = new DockerComposerPlugin(new TestProcessRunner(), new TestContainerDetector(false)); + $plugin = new DockerComposerPlugin(new MockProcessRunner(), new MockContainerDetector(false)); self::assertSame([], DockerComposerPlugin::getSubscribedEvents()); @@ -67,8 +57,8 @@ public function testRedirectsCustomScriptWithExecModeAndAutoUp(): void ], ], ); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('test', $composer, $io, false, ['--filter', 'Example']); $plugin->activate($composer, $io); @@ -128,8 +118,8 @@ public function testServiceMappingOverrideChangesTargetService(): void ], ], ); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('test', $composer, $io); $plugin->activate($composer, $io); @@ -151,8 +141,8 @@ public function testRedirectNoticeEscapesScriptAndServiceNames(): void ], ], ); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('badscript', $composer, $io); $plugin->activate($composer, $io); @@ -180,8 +170,8 @@ public function testServiceMappingOverrideCanConfigureServiceWithoutDefault(): v ], ], ); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $testEvent = new ScriptEvent('test', $composer, $io); $csEvent = new ScriptEvent('cs', $composer, $io); @@ -202,8 +192,8 @@ public function testMissingServiceWarningEscapesScriptName(): void ['badscript' => ['host-command']], ['docker-composer' => []], ); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('badscript', $composer, $io); $plugin->activate($composer, $io); @@ -229,8 +219,8 @@ public function testExecModeStartsServiceOnlyOncePerComposeTarget(): void ], ], ); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $plugin->activate($composer, $io); $plugin->onScript(new ScriptEvent('test', $composer, $io)); @@ -307,8 +297,8 @@ public function testExecModeStartsEachServiceMappingOverrideOnce(): void ], ], ); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $plugin->activate($composer, $io); $plugin->onScript(new ScriptEvent('test', $composer, $io)); @@ -339,8 +329,8 @@ public function testExecModeSkipsAutoUpWhenServiceIsAlreadyRunning(): void ], ], ); - $runner = new TestOutputCapturingProcessRunner([0, 0], outputs: ['php' . PHP_EOL]); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockOutputCapturingProcessRunner([0, 0], outputs: ['php' . PHP_EOL]); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $plugin->activate($composer, $io); $plugin->onScript(new ScriptEvent('test', $composer, $io)); @@ -387,8 +377,8 @@ public function testExecModeRunsAutoUpWhenServiceIsNotRunning(): void ['test' => ['host-command']], ['docker-composer' => ['service' => 'php']], ); - $runner = new TestOutputCapturingProcessRunner([0, 0, 0], outputs: ['']); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockOutputCapturingProcessRunner([0, 0, 0], outputs: ['']); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $plugin->activate($composer, $io); $plugin->onScript(new ScriptEvent('test', $composer, $io)); @@ -406,8 +396,8 @@ public function testExecModeRunsAutoUpWhenRunningServiceCheckFails(): void ['test' => ['host-command']], ['docker-composer' => ['service' => 'php']], ); - $runner = new TestOutputCapturingProcessRunner([7, 0, 0], outputs: ['']); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockOutputCapturingProcessRunner([7, 0, 0], outputs: ['']); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $plugin->activate($composer, $io); $plugin->onScript(new ScriptEvent('test', $composer, $io)); @@ -431,8 +421,8 @@ public function testRunModeUsesOneOffContainerWithoutAutoUp(): void ], ], ); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('cs', $composer, $io, true); $plugin->activate($composer, $io); @@ -466,8 +456,8 @@ public function testContainerExecutionFallsThroughToComposerScripts(): void [$composer, $io] = $this->createComposer([], [ 'docker-composer' => ['service' => 'php'], ]); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(true)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(true)); $event = new ScriptEvent('test', $composer, $io); $plugin->activate($composer, $io); @@ -485,8 +475,8 @@ public function testCanRunWithoutActivationWhenCalledDirectly(): void ); $plugin = new DockerComposerPlugin( null, - new TestContainerDetector(false), - new TestCommandBuilder(), + new MockContainerDetector(false), + new MockCommandBuilder(), ); $event = new ScriptEvent('test', $composer, $io); @@ -500,8 +490,8 @@ public function testUnconfiguredLifecycleEventIsNotRedirected(): void [$composer, $io] = $this->createComposer([], [ 'docker-composer' => ['service' => 'php'], ]); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $plugin->activate($composer, $io); @@ -516,8 +506,8 @@ public function testInteractiveScriptsUseTtyExecution(): void ['docker-composer' => ['service' => 'php']], ); $io->setUserInputs(['yes']); - $runner = new TestProcessRunner(supportsTty: true); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(supportsTty: true); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('prompt', $composer, $io); $plugin->activate($composer, $io); @@ -536,8 +526,8 @@ public function testInteractiveScriptsUseNonTtyExecutionWhenRunnerDoesNotSupport ['docker-composer' => ['service' => 'php']], ); $io->setUserInputs(['yes']); - $runner = new TestProcessRunner(supportsTty: false); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(supportsTty: false); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('prompt', $composer, $io); $plugin->activate($composer, $io); @@ -553,8 +543,8 @@ public function testMissingServiceWarnsOnceAndFallsThrough(): void [$composer, $io] = $this->createComposer([], [ 'docker-composer' => [], ]); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $firstEvent = new ScriptEvent('test', $composer, $io); $secondEvent = new ScriptEvent('cs', $composer, $io); @@ -577,8 +567,8 @@ public function testEmptyAndInvalidScriptNamesAreIgnoredDuringActivation(): void ], ['docker-composer' => ['service' => 'php']], ); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $plugin->activate($composer, $io); @@ -594,8 +584,8 @@ public function testExcludedScriptFallsThrough(): void 'exclude' => ['test'], ], ]); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('test', $composer, $io); $plugin->activate($composer, $io); @@ -610,8 +600,8 @@ public function testNestedScriptFallsThrough(): void [$composer, $io] = $this->createComposer([], [ 'docker-composer' => ['service' => 'php'], ]); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('child', $composer, $io); $event->setOriginatingEvent(new Event('parent')); @@ -627,8 +617,8 @@ public function testDockerFailurePreservesExitCode(): void [$composer, $io] = $this->createComposer([], [ 'docker-composer' => ['service' => 'php'], ]); - $runner = new TestProcessRunner([0, 7], 'docker failed'); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner([0, 7], 'docker failed'); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('test', $composer, $io); $plugin->activate($composer, $io); @@ -647,8 +637,8 @@ public function testDockerUpFailurePreservesExitCode(): void [$composer, $io] = $this->createComposer([], [ 'docker-composer' => ['service' => 'php'], ]); - $runner = new TestProcessRunner([7], 'up failed'); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner([7], 'up failed'); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('test', $composer, $io); $plugin->activate($composer, $io); @@ -670,8 +660,8 @@ public function testDockerRunFailureReportsRunPhase(): void 'mode' => 'run', ], ]); - $runner = new TestProcessRunner([7], 'run failed'); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner([7], 'run failed'); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('test', $composer, $io); $plugin->activate($composer, $io); @@ -690,8 +680,8 @@ public function testDockerFailureUsesGenericMessageWithoutErrorOutput(): void [$composer, $io] = $this->createComposer([], [ 'docker-composer' => ['service' => 'php'], ]); - $runner = new TestProcessRunner([0, 7]); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner([0, 7]); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('test', $composer, $io); $plugin->activate($composer, $io); @@ -709,72 +699,14 @@ public function testInvalidKnownConfigFailsStrictly(): void [$composer, $io] = $this->createComposer([], [ 'docker-composer' => ['mode' => 'invalid'], ]); - $plugin = new DockerComposerPlugin(new TestProcessRunner(), new TestContainerDetector(false)); + $plugin = new DockerComposerPlugin(new MockProcessRunner(), new MockContainerDetector(false)); - $this->expectException(\InvalidArgumentException::class); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('extra.docker-composer.mode must be "exec" or "run".'); $plugin->activate($composer, $io); } - public function testConfigDefaultsWhenDockerComposerExtraIsMissing(): void - { - [$composer] = $this->createComposer([], []); - - $config = DockerComposerConfig::fromComposer($composer); - - self::assertFalse($config->isConfigured()); - self::assertSame(DockerComposerConfig::MODE_EXEC, $config->getMode()); - self::assertSame([], $config->getComposeFiles()); - self::assertNull($config->getProjectDirectory()); - self::assertNull($config->getWorkdir()); - self::assertFalse($config->isExcluded('test')); - self::assertSame([], $config->getUnknownKeys()); - } - - public function testConfigAcceptsEmptyServiceMapping(): void - { - [$composer] = $this->createComposer([], [ - 'docker-composer' => [ - 'service-mapping' => [], - ], - ]); - - $config = DockerComposerConfig::fromComposer($composer); - - self::assertFalse($config->isConfiguredForScript('test')); - } - - public function testConfigRejectsInvalidShapes(): void - { - $this->assertInvalidConfig(['docker-composer' => 'invalid'], 'extra.docker-composer must be an object.'); - $this->assertInvalidConfig(['docker-composer' => [0 => 'invalid']], 'extra.docker-composer must be an object.'); - $this->assertInvalidConfig(['docker-composer' => ['service' => '']], 'extra.docker-composer.service must be a non-empty string.'); - $this->assertInvalidConfig(['docker-composer' => ['compose-files' => '']], 'extra.docker-composer.compose-files must contain non-empty strings.'); - $this->assertInvalidConfig(['docker-composer' => ['exclude' => ['script' => true]]], 'extra.docker-composer.exclude must be a list of strings.'); - $this->assertInvalidConfig(['docker-composer' => ['exclude' => [1]]], 'extra.docker-composer.exclude must contain only non-empty strings.'); - $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => 'php']], 'extra.docker-composer.service-mapping must be an object of strings or lists of strings.'); - $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php']]], 'extra.docker-composer.service-mapping must be an object of strings or lists of strings.'); - $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['' => 'test']]], 'extra.docker-composer.service-mapping must use non-empty string keys.'); - $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php' => '']]], 'extra.docker-composer.service-mapping must contain only non-empty strings or lists of non-empty strings.'); - $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php' => ['test' => 'test']]]], 'extra.docker-composer.service-mapping must contain only non-empty strings or lists of non-empty strings.'); - $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php' => ['']]]], 'extra.docker-composer.service-mapping must contain only non-empty strings or lists of non-empty strings.'); - $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php' => []]]], 'extra.docker-composer.service-mapping must map each service to at least one script.'); - $this->assertInvalidConfig(['docker-composer' => ['service-mapping' => ['php' => 'test', 'php-tools' => ['test']]]], 'extra.docker-composer.service-mapping must not assign a script to multiple services.'); - - } - - public function testUnconfiguredServiceAccessFails(): void - { - [$composer] = $this->createComposer([], []); - $config = DockerComposerConfig::fromComposer($composer); - - $this->expectException(\LogicException::class); - $this->expectExceptionMessage('Docker Compose service is not configured.'); - - $config->getService(); - } - public function testUnknownConfigKeysWarnAndContinue(): void { [$composer, $io] = $this->createComposer([], [ @@ -783,7 +715,7 @@ public function testUnknownConfigKeysWarnAndContinue(): void 'future-key' => true, ], ]); - $plugin = new DockerComposerPlugin(new TestProcessRunner(), new TestContainerDetector(false)); + $plugin = new DockerComposerPlugin(new MockProcessRunner(), new MockContainerDetector(false)); $plugin->activate($composer, $io); $plugin->activate($composer, $io); @@ -804,8 +736,8 @@ public function testDuplicateServiceMappingScriptForSameServiceWarnsAndContinues ], ], ); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('test', $composer, $io); $plugin->activate($composer, $io); @@ -831,8 +763,8 @@ public function testDuplicateServiceMappingScriptWarnsWithoutActivation(): void ], ); $plugin = new DockerComposerPlugin( - new TestProcessRunner(), - new TestContainerDetector(false), + new MockProcessRunner(), + new MockContainerDetector(false), ); $event = new ScriptEvent('test', $composer, $io); @@ -849,8 +781,8 @@ public function testDisableEnvironmentVariableFallsThrough(): void [$composer, $io] = $this->createComposer([], [ 'docker-composer' => ['service' => 'php'], ]); - $runner = new TestProcessRunner(); - $plugin = new DockerComposerPlugin($runner, new TestContainerDetector(false)); + $runner = new MockProcessRunner(); + $plugin = new DockerComposerPlugin($runner, new MockContainerDetector(false)); $event = new ScriptEvent('test', $composer, $io); putenv('DOCKER_COMPOSER_DISABLE=1'); @@ -865,201 +797,6 @@ public function testDisableEnvironmentVariableFallsThrough(): void self::assertSame([], $runner->commands); } - public function testCommandBuilderStringifiesNullAndBoolArguments(): void - { - [$composer, $io] = $this->createComposer([], [ - 'docker-composer' => ['service' => 'php'], - ]); - $config = DockerComposerConfig::fromComposer($composer); - $event = new ScriptEvent('test', $composer, $io, false, [null, true]); - - $command = (new DockerComposeCommandBuilder())->buildScriptCommand($config, $event, false); - - self::assertSame(['--', '', '1'], array_slice($command, -3)); - } - - public function testCommandBuilderForwardsComposerProcessTimeout(): void - { - [$composer, $io] = $this->createComposer([], [ - 'docker-composer' => ['service' => 'php'], - ]); - $previousTimeout = ProcessExecutor::getTimeout(); - - ProcessExecutor::setTimeout(42); - try { - $config = DockerComposerConfig::fromComposer($composer); - $event = new ScriptEvent('test', $composer, $io); - - $command = (new DockerComposeCommandBuilder())->buildScriptCommand($config, $event, false); - } finally { - ProcessExecutor::setTimeout($previousTimeout); - } - - self::assertSame('--timeout=42', $command[count($command) - 2]); - } - - public function testCommandBuilderRejectsNonScalarArguments(): void - { - $method = new \ReflectionMethod(DockerComposeCommandBuilder::class, 'stringifyArgument'); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Composer script arguments must be scalar values.'); - - $method->invoke(new DockerComposeCommandBuilder(), []); - } - - #[BackupGlobals(true)] - public function testEnvironmentDetectorUsesExplicitMarker(): void - { - putenv('DOCKER_COMPOSER_INSIDE=1'); - try { - self::assertTrue((new EnvironmentContainerDetector())->isInsideContainer()); - } finally { - putenv('DOCKER_COMPOSER_INSIDE'); - } - } - - public function testEnvironmentDetectorUsesContainerFilesAndCgroups(): void - { - $getEnv = static fn(string $name): bool => false; - $missingFiles = static fn(string $path): bool => false; - - self::assertTrue((new EnvironmentContainerDetector( - $getEnv, - static fn(string $path): bool => $path === '/.dockerenv', - static fn(string $path): bool => false, - ))->isInsideContainer()); - self::assertFalse((new EnvironmentContainerDetector( - $getEnv, - $missingFiles, - static fn(string $path): bool => false, - ))->isInsideContainer()); - self::assertFalse((new EnvironmentContainerDetector( - $getEnv, - $missingFiles, - static fn(string $path): string => '0::/user.slice', - ))->isInsideContainer()); - self::assertTrue((new EnvironmentContainerDetector( - $getEnv, - $missingFiles, - static fn(string $path): string => '0::/kubepods.slice/containerd', - ))->isInsideContainer()); - } - - public function testComposerProcessRunnerDelegatesToProcessExecutor(): void - { - $io = new BufferIO(); - $runner = new ComposerProcessRunner($io, static fn(): bool => true); - $processExecutor = new TestProcessExecutor(3, 4, 'executor error'); - $property = new \ReflectionProperty($runner, 'processExecutor'); - $property->setValue($runner, $processExecutor); - - self::assertTrue($runner->supportsTty()); - self::assertSame(3, $runner->run(['docker', 'compose'])); - self::assertSame(4, $runner->run(['docker', 'compose'], true)); - self::assertSame('executor error', $runner->getErrorOutput()); - $expectedCommand = implode(' ', array_map([ProcessExecutor::class, 'escape'], ['docker', 'compose'])); - - self::assertSame([$expectedCommand], $processExecutor->commands); - self::assertSame([$expectedCommand], $processExecutor->ttyCommands); - } - - public function testComposerProcessRunnerCapturesOutput(): void - { - $io = new BufferIO(); - $runner = new ComposerProcessRunner($io, static fn(): bool => true); - $processExecutor = new TestProcessExecutor(3, 4, 'executor error', 'captured output'); - $property = new \ReflectionProperty($runner, 'processExecutor'); - $property->setValue($runner, $processExecutor); - - $output = ''; - - self::assertSame(3, $runner->runWithOutput(['docker', 'compose'], $output)); - self::assertSame('captured output', $output); - } - - public function testComposerProcessRunnerFallsBackWhenCurrentProcessDoesNotSupportTty(): void - { - $io = new BufferIO(); - $runner = new ComposerProcessRunner($io, static fn(): bool => false); - $processExecutor = new TestProcessExecutor(3, 4, 'executor error'); - $property = new \ReflectionProperty($runner, 'processExecutor'); - $property->setValue($runner, $processExecutor); - - self::assertFalse($runner->supportsTty()); - self::assertSame(3, $runner->run(['docker', 'compose'], true)); - $expectedCommand = implode(' ', array_map([ProcessExecutor::class, 'escape'], ['docker', 'compose'])); - - self::assertSame([$expectedCommand], $processExecutor->commands); - self::assertSame([], $processExecutor->ttyCommands); - } - - public function testComposerProcessRunnerUsesComposerPlatformTtyDetection(): void - { - $method = new \ReflectionMethod(ComposerProcessRunner::class, 'detectTtySupport'); - - self::assertSame(Platform::isTty(), $method->invoke(null)); - } - - #[RunInSeparateProcess] - #[PreserveGlobalState(false)] - public function testComposerProcessRunnerUsesStreamFallbackWithoutComposerPlatform(): void - { - $method = new \ReflectionMethod(ComposerProcessRunner::class, 'detectTtySupport'); - $autoloaders = spl_autoload_functions() ?: []; - - foreach ($autoloaders as $autoload) { - spl_autoload_unregister($autoload); - } - - try { - $supportsTty = $method->invoke(null); - } finally { - foreach ($autoloaders as $autoload) { - spl_autoload_register($autoload); - } - } - - self::assertSame(defined('STDOUT') && stream_isatty(STDOUT), $supportsTty); - } - - /** - * @param array> $scripts - * @param array $extra - * - * @return array{0: Composer, 1: BufferIO} - */ - private function createComposer(array $scripts, array $extra): array - { - $composer = new Composer(); - $package = new RootPackage('root/project', '1.0.0', '1.0.0'); - $package->setScripts($scripts); - $package->setExtra($extra); - $composer->setPackage($package); - $composer->setConfig(new Config(false, getcwd() ?: null)); - - $io = new BufferIO('', StreamOutput::VERBOSITY_NORMAL); - $dispatcher = new EventDispatcher($composer, $io); - $composer->setEventDispatcher($dispatcher); - - return [$composer, $io]; - } - - /** - * @param array $extra - */ - private function assertInvalidConfig(array $extra, string $message): void - { - [$composer] = $this->createComposer([], $extra); - - try { - DockerComposerConfig::fromComposer($composer); - self::fail(sprintf('Expected invalid config exception for message "%s".', $message)); - } catch (\InvalidArgumentException $exception) { - self::assertSame($message, $exception->getMessage()); - } - } - private function assertScriptExecutionFails(DockerComposerPlugin $plugin, ScriptEvent $event): ScriptExecutionException { try { @@ -1071,145 +808,3 @@ private function assertScriptExecutionFails(DockerComposerPlugin $plugin, Script self::fail('Expected Docker script execution to fail.'); } } - -final class TestContainerDetector implements ContainerDetector -{ - public function __construct(private bool $inside) {} - - public function isInsideContainer(): bool - { - return $this->inside; - } -} - -class TestProcessRunner implements ProcessRunner -{ - /** @var list> */ - public array $commands = []; - - /** @var list */ - public array $tty = []; - - /** @var list */ - private array $exitCodes; - - /** - * @param list $exitCodes - */ - public function __construct( - array $exitCodes = [0], - private string $errorOutput = '', - private bool $supportsTty = false, - ) { - $this->exitCodes = $exitCodes; - } - - public function run(array $command, bool $tty = false): int - { - $this->commands[] = $command; - $this->tty[] = $tty; - - return array_shift($this->exitCodes) ?? 0; - } - - public function getErrorOutput(): string - { - return $this->errorOutput; - } - - public function supportsTty(): bool - { - return $this->supportsTty; - } -} - -final class TestOutputCapturingProcessRunner extends TestProcessRunner implements OutputCapturingProcessRunner -{ - /** @var list */ - private array $outputs; - - /** - * @param list $exitCodes - * @param list $outputs - */ - public function __construct( - array $exitCodes = [0], - string $errorOutput = '', - bool $supportsTty = false, - array $outputs = [], - ) { - parent::__construct($exitCodes, $errorOutput, $supportsTty); - $this->outputs = $outputs; - } - - public function runWithOutput(array $command, string &$output): int - { - $output = array_shift($this->outputs) ?? ''; - - return $this->run($command); - } -} - -final class TestCommandBuilder extends DockerComposeCommandBuilder -{ - public function buildRunningServicesCommand(DockerComposerConfig $config): array - { - return ['php', '-r', 'exit(1);']; - } - - public function buildUpCommand(DockerComposerConfig $config): array - { - return ['php', '-r', 'exit(0);']; - } - - public function buildScriptCommand(DockerComposerConfig $config, ScriptEvent $event, bool $interactive): array - { - return ['php', '-r', 'exit(0);']; - } -} - -final class TestProcessExecutor extends ProcessExecutor -{ - /** @var list */ - public array $commands = []; - - /** @var list */ - public array $ttyCommands = []; - - /** - * @noinspection PhpMissingParentConstructorInspection - */ - public function __construct( - private int $executeExitCode, - private int $ttyExitCode, - private string $testErrorOutput, - private string $testOutput = '', - ) {} - - /** - * @param mixed $command - * @param mixed $output - */ - public function execute($command, &$output = null, ?string $cwd = null): int - { - $this->commands[] = (string) $command; - $output = $this->testOutput; - - return $this->executeExitCode; - } - - /** - * @param mixed $command - */ - public function executeTty($command, ?string $cwd = null): int - { - $this->ttyCommands[] = (string) $command; - - return $this->ttyExitCode; - } - - public function getErrorOutput(): string - { - return $this->testErrorOutput; - } -} diff --git a/tests/Unit/EnvironmentContainerDetectorTest.php b/tests/Unit/EnvironmentContainerDetectorTest.php new file mode 100644 index 0000000..e1573d5 --- /dev/null +++ b/tests/Unit/EnvironmentContainerDetectorTest.php @@ -0,0 +1,56 @@ +isInsideContainer()); + } finally { + putenv('DOCKER_COMPOSER_INSIDE'); + } + } + + public function testEnvironmentDetectorUsesContainerFilesAndCgroups(): void + { + $getEnv = static fn(string $name): bool => false; + $missingFiles = static fn(string $path): bool => false; + + self::assertTrue((new EnvironmentContainerDetector( + $getEnv, + static fn(string $path): bool => $path === '/.dockerenv', + static fn(string $path): bool => false, + ))->isInsideContainer()); + self::assertFalse((new EnvironmentContainerDetector( + $getEnv, + $missingFiles, + static fn(string $path): bool => false, + ))->isInsideContainer()); + self::assertFalse((new EnvironmentContainerDetector( + $getEnv, + $missingFiles, + static fn(string $path): string => '0::/user.slice', + ))->isInsideContainer()); + self::assertTrue((new EnvironmentContainerDetector( + $getEnv, + $missingFiles, + static fn(string $path): string => '0::/kubepods.slice/containerd', + ))->isInsideContainer()); + } +} diff --git a/tests/Unit/Mocks/MockCommandBuilder.php b/tests/Unit/Mocks/MockCommandBuilder.php new file mode 100644 index 0000000..8ed4de4 --- /dev/null +++ b/tests/Unit/Mocks/MockCommandBuilder.php @@ -0,0 +1,27 @@ +inside; + } +} diff --git a/tests/Unit/Mocks/MockOutputCapturingProcessRunner.php b/tests/Unit/Mocks/MockOutputCapturingProcessRunner.php new file mode 100644 index 0000000..554d978 --- /dev/null +++ b/tests/Unit/Mocks/MockOutputCapturingProcessRunner.php @@ -0,0 +1,36 @@ + */ + private array $outputs; + + /** + * @param list $exitCodes + * @param list $outputs + */ + public function __construct( + array $exitCodes = [0], + string $errorOutput = '', + bool $supportsTty = false, + array $outputs = [], + ) { + parent::__construct($exitCodes, $errorOutput, $supportsTty); + $this->outputs = $outputs; + } + + public function runWithOutput(array $command, string &$output): int + { + $output = array_shift($this->outputs) ?? ''; + + return $this->run($command); + } +} diff --git a/tests/Unit/Mocks/MockProcessExecutor.php b/tests/Unit/Mocks/MockProcessExecutor.php new file mode 100644 index 0000000..2005a67 --- /dev/null +++ b/tests/Unit/Mocks/MockProcessExecutor.php @@ -0,0 +1,53 @@ + */ + public array $commands = []; + + /** @var list */ + public array $ttyCommands = []; + + /** + * @noinspection PhpMissingParentConstructorInspection + */ + public function __construct( + private int $executeExitCode, + private int $ttyExitCode, + private string $testErrorOutput, + private string $testOutput = '', + ) {} + + /** + * @param mixed $command + * @param mixed $output + */ + public function execute($command, &$output = null, ?string $cwd = null): int + { + $this->commands[] = (string) $command; + $output = $this->testOutput; + + return $this->executeExitCode; + } + + /** + * @param mixed $command + */ + public function executeTty($command, ?string $cwd = null): int + { + $this->ttyCommands[] = (string) $command; + + return $this->ttyExitCode; + } + + public function getErrorOutput(): string + { + return $this->testErrorOutput; + } +} diff --git a/tests/Unit/Mocks/MockProcessRunner.php b/tests/Unit/Mocks/MockProcessRunner.php new file mode 100644 index 0000000..3072785 --- /dev/null +++ b/tests/Unit/Mocks/MockProcessRunner.php @@ -0,0 +1,50 @@ +> */ + public array $commands = []; + + /** @var list */ + public array $tty = []; + + /** @var list */ + private array $exitCodes; + + /** + * @param list $exitCodes + */ + public function __construct( + array $exitCodes = [0], + private string $errorOutput = '', + private bool $supportsTty = false, + ) { + $this->exitCodes = $exitCodes; + } + + public function run(array $command, bool $tty = false): int + { + $this->commands[] = $command; + $this->tty[] = $tty; + + return array_shift($this->exitCodes) ?? 0; + } + + public function getErrorOutput(): string + { + return $this->errorOutput; + } + + public function supportsTty(): bool + { + return $this->supportsTty; + } +}