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/tests/Integration/DockerComposerIntegrationTest.php b/tests/Integration/DockerComposerIntegrationTest.php index e0ad3d1..3fcee3b 100644 --- a/tests/Integration/DockerComposerIntegrationTest.php +++ b/tests/Integration/DockerComposerIntegrationTest.php @@ -107,8 +107,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 +126,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 +175,27 @@ private function createProject(array $dockerComposerConfig): string 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'); @@ -184,6 +206,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. + */ + 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; + } + private function installProject(string $projectDirectory): void { $this->runCommand(['composer', 'install', '--no-interaction', '--no-progress', '--prefer-dist'], $projectDirectory); @@ -193,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'], @@ -215,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 @@ -251,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; + } +}