Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand All @@ -16,9 +21,9 @@ If PHPStan cannot model valid runtime behavior, use the narrowest fix:
2. otherwise add a targeted `@phpstan-ignore <identifier>` 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.
Expand Down Expand Up @@ -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
72 changes: 52 additions & 20 deletions tests/Integration/DockerComposerIntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,9 @@ public function testRunModeBypassMissingConfigAndInsideContainerBehavior(): void

/**
* @param array<string, mixed> $dockerComposerConfig
* @param list<array<string, mixed>>|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
Expand All @@ -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' => [
Expand Down Expand Up @@ -174,6 +175,27 @@ private function createProject(array $dockerComposerConfig): string
return $projectDirectory;
}

/**
* @param list<array<string, mixed>> $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');
Expand All @@ -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<string>
* 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);
Expand All @@ -193,7 +237,7 @@ private function installProject(string $projectDirectory): void
* @param list<string> $command
* @param array<string, string> $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'],
Expand All @@ -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
Expand All @@ -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,
) {}
}
31 changes: 30 additions & 1 deletion tests/TestCase.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, list<string>> $scripts
* @param array<string, mixed> $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];
}
}
101 changes: 101 additions & 0 deletions tests/Unit/ComposerProcessRunnerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

/**
* @noinspection PhpUnhandledExceptionInspection
*/

declare(strict_types=1);

namespace Tests\Unit;

use Composer\IO\BufferIO;
use Composer\Util\Platform;
use Composer\Util\ProcessExecutor;
use empaphy\docker_composer\ComposerProcessRunner;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\PreserveGlobalState;
use PHPUnit\Framework\Attributes\RunInSeparateProcess;
use ReflectionProperty;
use Tests\TestCase;
use Tests\Unit\Mocks\MockProcessExecutor;

#[CoversClass(ComposerProcessRunner::class)]
class ComposerProcessRunnerTest extends TestCase
{
public function testComposerProcessRunnerDelegatesToProcessExecutor(): void
{
$io = new BufferIO();
$runner = new ComposerProcessRunner($io, static fn(): bool => 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);
}
}
65 changes: 65 additions & 0 deletions tests/Unit/DockerComposeCommandBuilderTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
<?php

/**
* @noinspection StaticClosureCanBeUsedInspection
*/

declare(strict_types=1);

namespace Tests\Unit;

use Composer\Script\Event as ScriptEvent;
use Composer\Util\ProcessExecutor;
use empaphy\docker_composer\DockerComposeCommandBuilder;
use empaphy\docker_composer\DockerComposerConfig;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
use Tests\TestCase;

#[CoversClass(DockerComposeCommandBuilder::class)]
#[UsesClass(DockerComposerConfig::class)]
class DockerComposeCommandBuilderTest extends TestCase
{
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(), []);
}
}
Loading
Loading