diff --git a/.github/wiki b/.github/wiki index 6176cf773..6248aa1cd 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 6176cf7737b54b6e1da59fea1961ff0aa4a41bf3 +Subproject commit 6248aa1cd359641141ba617e7b1f9e3f71034797 diff --git a/AGENTS.md b/AGENTS.md index 2bc3512f4..daadcb6b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,6 +111,30 @@ composer dev-tools tests -- --coverage=.dev-tools/coverage - Rector rules: `src/Rector/` - Composer plugin: `src/Composer/` +**Architecture Direction:** + +- Avoid introducing new dependencies on `composer/composer` outside the + existing Composer plugin integration and legacy surfaces already awaiting + decoupling. +- Prefer DevTools-owned interfaces for generic runtime concerns such as + environment variables, process execution, filesystem access, and console + presentation instead of reaching for Composer utility classes. +- When an existing Composer utility is convenient, first check whether a small + local abstraction would support the ongoing Composer decoupling work with + minimal code. +- During the Composer-to-Symfony command migration, preserve the global + execution affordances already relied on by nested tools: `--ansi`/`--no-ansi`, + cache and `--cache-dir` handling, and working-directory behavior. +- Keep color behavior explicit in command wrappers for tools with known flags + instead of probing binaries dynamically; for example, Symfony/Composer-style + tools can receive `--ansi`, while PHPUnit uses `--colors=always`. +- Keep child-process environment policy centralized in `ProcessQueue` + configurators, including disabling Xdebug for non-coverage subprocesses while + preserving coverage drivers when PCOV is unavailable. +- Prefer the project-approved `Safe\*` function imports whenever replacing or + adding native PHP calls that have Safe equivalents, so Rector does not have + to rewrite them during commit checks. + **Naming Conventions:** - Classes: PascalCase diff --git a/CHANGELOG.md b/CHANGELOG.md index e9af9326a..9fe751be3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Preserve color-friendly nested command environments, explicit Symfony Console ANSI flags, concise process section labels, and fixture-safe PhpMetrics execution with bounded Packagist lookups without restoring PTY (#239) +- Disable Xdebug for queued child processes unless coverage requires it without PCOV, reducing repeated Composer Xdebug warnings in orchestrated commands (#239) - Keep the reports workflow permission warning loop shell-safe for paths containing backslashes (#244) - Keep required PHPUnit matrix checks reporting after workflow-managed `.github/wiki` pointer commits by running the pull-request test workflow without top-level path filters and aligning the packaged consumer test wrapper (#230) - Publish pending and per-version required PHPUnit statuses for workflow-dispatched test runs so wiki pointer commits do not wait for an all-matrix aggregate status (#230) diff --git a/docs/commands/metrics.rst b/docs/commands/metrics.rst index 271a9b84e..0005663d2 100644 --- a/docs/commands/metrics.rst +++ b/docs/commands/metrics.rst @@ -33,7 +33,7 @@ Options Comma-separated directories that should be excluded from analysis. Default: - ``vendor,test,tests,tmp,cache,spec,build,.dev-tools,backup,resources``. + ``vendor,tmp,cache,spec,build,.dev-tools,backup,resources,tests/Fixtures``. ``--target=`` Output directory for the generated metrics reports. @@ -93,4 +93,9 @@ Behavior - ``--json`` and ``--pretty-json`` keep DevTools itself structured while running PhpMetrics in a quieter mode to avoid polluting the captured payload; - it runs PhpMetrics through the active PHP binary and suppresses PhpMetrics - deprecation notices emitted by the dependency itself. + deprecation notices emitted by the dependency itself; +- it keeps PhpMetrics' Composer analysis enabled so the reports include package + metadata from the root ``composer.json`` and ``composer.lock``, while the + default exclusions keep nested fixture projects out of that Composer scan; +- it limits PhpMetrics' per-package Packagist socket wait so package freshness + enrichment cannot leave metrics generation stuck indefinitely. diff --git a/docs/internals/architecture.rst b/docs/internals/architecture.rst index e56374111..8fb3f63d7 100644 --- a/docs/internals/architecture.rst +++ b/docs/internals/architecture.rst @@ -75,7 +75,9 @@ command list: - Services * - ``FastForward\DevTools\Process\ProcessBuilderInterface`` - ``ProcessBuilderInterface`` and ``ProcessQueueInterface`` build and - execute subprocess pipelines. + execute subprocess pipelines, while process environment and output + Symfony-style sections keep nested command output readable without PTY + and suppress unnecessary Xdebug overhead in child processes. * - ``Filesystem and metadata`` - ``FilesystemInterface``, ``ComposerJsonInterface``, and ``FileLocatorInterface`` resolve local files, project metadata, and diff --git a/docs/running/specialized-commands.rst b/docs/running/specialized-commands.rst index 5e0f3630f..ef78641f1 100644 --- a/docs/running/specialized-commands.rst +++ b/docs/running/specialized-commands.rst @@ -132,7 +132,12 @@ Important details: structured while forwarding JSON or quieter modes to the wrapped tools where available; - it suppresses deprecation notices emitted by the PhpMetrics dependency - itself so the command output stays readable. + itself so the command output stays readable; +- it keeps PhpMetrics' Composer analysis enabled so report generation includes + root package metadata, while default exclusions keep nested fixture projects + out of that Composer scan; +- it limits PhpMetrics' per-package Packagist socket wait so package freshness + enrichment cannot leave report generation stuck indefinitely. ``code-style`` -------------- @@ -270,6 +275,12 @@ Important details: ``--progress`` re-enables it for human-readable runs; - ``--json`` and ``--pretty-json`` are propagated to each subprocess, while their progress output is suppressed where supported; +- human-readable runs keep nested command output grouped with concise local + section boundaries, pass color-friendly environment variables to + subprocesses, and forward explicit ANSI flags to Symfony Console tools; +- queued subprocesses run with ``XDEBUG_MODE=off`` when Xdebug is loaded but + the command does not need Xdebug for coverage, or when PCOV can provide + coverage instead; - it is the reporting stage used by ``standards``. ``skills`` diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index 064c55da8..4dd33ab0f 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -156,6 +156,26 @@ Recovery: When calling lower-level tools directly, use their non-interactive flags and provide required values through environment variables or workflow inputs. +Repeated Composer Xdebug Warnings +--------------------------------- + +Scope: local orchestration commands. + +Symptoms: + +- Composer repeatedly prints that it is operating slower than normal because + Xdebug is enabled; +- aggregate commands emit the warning once per nested Composer subprocess. + +Behavior: + +DevTools sets ``XDEBUG_MODE=off`` for queued child processes when Xdebug is +loaded but the child command does not need Xdebug for coverage. Coverage runs +keep Xdebug available when PCOV is not loaded, and use PCOV when it is +available. A warning printed by the top-level ``composer dev-tools`` process +can still appear before DevTools itself starts; run that command with +``XDEBUG_MODE=off`` when Xdebug is not needed for the top-level process. + GitHub Actions Error Annotations -------------------------------- diff --git a/src/Console/Command/CodeStyleCommand.php b/src/Console/Command/CodeStyleCommand.php index f06dc6938..28114575e 100644 --- a/src/Console/Command/CodeStyleCommand.php +++ b/src/Console/Command/CodeStyleCommand.php @@ -132,6 +132,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->build('composer normalize'); $processBuilder = $this->processBuilder + ->withArgument('--ansi') ->withArgument('--config', $this->fileLocator->locate(self::CONFIG)); if (! $progress) { @@ -148,9 +149,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int $ecs = $processBuilder->build('vendor/bin/ecs'); - $this->processQueue->add($composerUpdate); - $this->processQueue->add($composerNormalize); - $this->processQueue->add($ecs); + $this->processQueue->add(process: $composerUpdate, label: 'Refreshing Composer Lock'); + $this->processQueue->add( + process: $composerNormalize, + label: 'Normalizing composer.json with Composer Normalize' + ); + $this->processQueue->add(process: $ecs, label: 'Checking Code Style with Easy Coding Standard'); $result = $this->processQueue->run($processOutput); diff --git a/src/Console/Command/DependenciesCommand.php b/src/Console/Command/DependenciesCommand.php index 4cd40494d..e80c4481b 100644 --- a/src/Console/Command/DependenciesCommand.php +++ b/src/Console/Command/DependenciesCommand.php @@ -129,12 +129,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int return $this->failure($invalidArgumentException->getMessage(), $input); } - $this->processQueue->add($this->getRaiseToInstalledCommand($input)); - $this->processQueue->add($this->getOpenVersionsCommand($input)); + $this->processQueue->add( + process: $this->getRaiseToInstalledCommand($input), + label: 'Raising Dependency Constraints with Jack', + ); + $this->processQueue->add( + process: $this->getOpenVersionsCommand($input), + label: 'Opening Dependency Constraints with Jack', + ); if ($input->getOption('upgrade')) { - $this->processQueue->add($this->getComposerUpdateCommand()); - $this->processQueue->add($this->getComposerNormalizeCommand()); + $this->processQueue->add( + process: $this->getComposerUpdateCommand(), + label: 'Updating Dependencies with Composer' + ); + $this->processQueue->add( + process: $this->getComposerNormalizeCommand(), + label: 'Normalizing composer.json with Composer Normalize', + ); } if (! $jsonOutput) { @@ -143,10 +155,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int ]); } - $this->processQueue->add($this->getComposerDependencyAnalyserCommand($input)); $this->processQueue->add( - $this->getJackBreakpointCommand($input, $maximumOutdated), - $this->shouldIgnoreOutdatedFailures($maximumOutdated), + process: $this->getComposerDependencyAnalyserCommand($input), + label: 'Analyzing Dependencies with Composer Dependency Analyser', + ); + $this->processQueue->add( + process: $this->getJackBreakpointCommand($input, $maximumOutdated), + ignoreFailure: $this->shouldIgnoreOutdatedFailures($maximumOutdated), + label: 'Checking Outdated Dependencies with Jack', ); $result = $this->processQueue->run($processOutput); @@ -279,7 +295,9 @@ private function getComposerUpdateCommand(): Process */ private function getComposerNormalizeCommand(): Process { - return $this->processBuilder->build('composer normalize'); + return $this->processBuilder + ->withArgument('--ansi') + ->build('composer normalize'); } /** diff --git a/src/Console/Command/DocsCommand.php b/src/Console/Command/DocsCommand.php index 79de40b8c..a5822dfe3 100644 --- a/src/Console/Command/DocsCommand.php +++ b/src/Console/Command/DocsCommand.php @@ -165,7 +165,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $phpdoc = $processBuilder->build('vendor/bin/phpdoc'); - $this->processQueue->add($phpdoc); + $this->processQueue->add(process: $phpdoc, label: 'Generating API Docs with phpDocumentor'); $result = $this->processQueue->run($processOutput); diff --git a/src/Console/Command/FundingCommand.php b/src/Console/Command/FundingCommand.php index c2b254414..58d1f528e 100644 --- a/src/Console/Command/FundingCommand.php +++ b/src/Console/Command/FundingCommand.php @@ -500,7 +500,10 @@ private function normalizeComposerFile(string $composerFile, OutputInterface $ou $processBuilder = $processBuilder->withArgument('--file', $composerBasename); } - $this->processQueue->add($processBuilder->build('composer normalize')); + $this->processQueue->add( + process: $processBuilder->build('composer normalize'), + label: 'Normalizing composer.json with Composer Normalize', + ); return $this->processQueue->run($output); } diff --git a/src/Console/Command/MetricsCommand.php b/src/Console/Command/MetricsCommand.php index d668b03a5..59471a96b 100644 --- a/src/Console/Command/MetricsCommand.php +++ b/src/Console/Command/MetricsCommand.php @@ -50,6 +50,11 @@ final class MetricsCommand extends BaseCommand implements LoggerAwareCommandInte */ private const int PHP_ERROR_REPORTING = \E_ALL & ~\E_DEPRECATED; + /** + * @var int the maximum seconds PhpMetrics may wait on each Packagist package lookup + */ + private const int PHP_DEFAULT_SOCKET_TIMEOUT = 1; + /** * @param ProcessBuilderInterface $processBuilder the builder used to assemble the PhpMetrics process * @param ProcessQueueInterface $processQueue the queue used to execute the PhpMetrics process @@ -80,7 +85,7 @@ protected function configure(): void name: 'exclude', mode: InputOption::VALUE_OPTIONAL, description: 'Comma-separated directories that SHOULD be excluded from analysis.', - default: 'vendor,test,tests,tmp,cache,spec,build,.dev-tools,backup,resources', + default: 'vendor,tmp,cache,spec,build,.dev-tools,backup,resources,tests/Fixtures', ) ->addOption( name: 'target', @@ -132,9 +137,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $this->processQueue->add( - $processBuilder + process: $processBuilder ->withArgument('.') - ->build([\PHP_BINARY, '-derror_reporting=' . self::PHP_ERROR_REPORTING, self::BINARY]) + ->build([ + \PHP_BINARY, + '-derror_reporting=' . self::PHP_ERROR_REPORTING, + '-ddefault_socket_timeout=' . self::PHP_DEFAULT_SOCKET_TIMEOUT, + self::BINARY, + ]), + label: 'Generating Metrics with PhpMetrics', ); $result = $this->processQueue->run($processOutput); diff --git a/src/Console/Command/PhpDocCommand.php b/src/Console/Command/PhpDocCommand.php index 1892c62e3..75cf08129 100644 --- a/src/Console/Command/PhpDocCommand.php +++ b/src/Console/Command/PhpDocCommand.php @@ -184,6 +184,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $phpCsFixer = $processBuilder->build('vendor/bin/php-cs-fixer fix'); $processBuilder = $this->processBuilder + ->withArgument('--ansi') ->withArgument('--config', $this->fileLocator->locate(RefactorCommand::CONFIG)) ->withArgument('--autoload-file', 'vendor/autoload.php') ->withArgument('--only', AddMissingMethodPhpDocRector::class); @@ -203,8 +204,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $rector = $processBuilder->build('vendor/bin/rector process'); - $this->processQueue->add($phpCsFixer); - $this->processQueue->add($rector); + $this->processQueue->add(process: $phpCsFixer, label: 'Fixing PHPDoc File Headers with PHP-CS-Fixer'); + $this->processQueue->add(process: $rector, label: 'Adding Missing PHPDoc with Rector'); $result = $this->processQueue->run($processOutput); diff --git a/src/Console/Command/RefactorCommand.php b/src/Console/Command/RefactorCommand.php index abc768c5f..8a7fb769d 100644 --- a/src/Console/Command/RefactorCommand.php +++ b/src/Console/Command/RefactorCommand.php @@ -121,6 +121,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $processBuilder = $this->processBuilder ->withArgument('process') + ->withArgument('--ansi') ->withArgument('--config') ->withArgument($this->fileLocator->locate(self::CONFIG)); @@ -137,7 +138,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $processBuilder = $processBuilder->withArgument('--dry-run'); } - $this->processQueue->add($processBuilder->build('vendor/bin/rector')); + $this->processQueue->add( + process: $processBuilder->build('vendor/bin/rector'), + label: 'Refactoring Code with Rector', + ); $result = $this->processQueue->run($processOutput); diff --git a/src/Console/Command/ReportsCommand.php b/src/Console/Command/ReportsCommand.php index 9a56a576a..d896c6afa 100644 --- a/src/Console/Command/ReportsCommand.php +++ b/src/Console/Command/ReportsCommand.php @@ -131,6 +131,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int $docsBuilder = $this->processBuilder ->withArgument('--target', $target); + $coverageBuilder = $this->processBuilder + ->withArgument('--coverage-summary') + ->withArgument('--coverage', $coveragePath); + + $metricsBuilder = $this->processBuilder + ->withArgument('--junit', $coveragePath . '/junit.xml') + ->withArgument('--target', $metricsPath); + + if (! $jsonOutput) { + $docsBuilder = $docsBuilder->withArgument('--ansi'); + $coverageBuilder = $coverageBuilder->withArgument('--ansi'); + $metricsBuilder = $metricsBuilder->withArgument('--ansi'); + } + if (null !== $cacheArgument) { $docsBuilder = $docsBuilder->withArgument($cacheArgument); } @@ -153,10 +167,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $docs = $docsBuilder->build('composer dev-tools docs --'); - $coverageBuilder = $this->processBuilder - ->withArgument('--coverage-summary') - ->withArgument('--coverage', $coveragePath); - if (null !== $cacheArgument) { $coverageBuilder = $coverageBuilder->withArgument($cacheArgument); } @@ -179,10 +189,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $coverage = $coverageBuilder->build('composer dev-tools tests --'); - $metricsBuilder = $this->processBuilder - ->withArgument('--junit', $coveragePath . '/junit.xml') - ->withArgument('--target', $metricsPath); - if ($progress) { $metricsBuilder = $metricsBuilder->withArgument('--progress'); } @@ -197,9 +203,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $metrics = $metricsBuilder->build('composer dev-tools metrics --'); - $this->processQueue->add(process: $docs, detached: true); - $this->processQueue->add(process: $coverage); - $this->processQueue->add(process: $metrics); + $this->processQueue->add(process: $docs, detached: true, label: 'Generating API Docs Report'); + $this->processQueue->add(process: $coverage, label: 'Generating Coverage Report'); + $this->processQueue->add(process: $metrics, label: 'Generating Metrics Report'); $result = $this->processQueue->run($processOutput); diff --git a/src/Console/Command/StandardsCommand.php b/src/Console/Command/StandardsCommand.php index 655ef48b4..e176644fb 100644 --- a/src/Console/Command/StandardsCommand.php +++ b/src/Console/Command/StandardsCommand.php @@ -112,6 +112,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $commands[] = $command; $processBuilder = $this->processBuilder; + if (! $jsonOutput) { + $processBuilder = $processBuilder->withArgument('--ansi'); + } + if ($progress) { $processBuilder = $processBuilder->withArgument('--progress'); } @@ -140,7 +144,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $processBuilder = $processBuilder->withArgument('--pretty-json'); } - $this->processQueue->add($processBuilder->build('composer dev-tools ' . $command . ' --')); + $this->processQueue->add( + process: $processBuilder->build('composer dev-tools ' . $command . ' --'), + label: $this->getProcessLabel($command), + ); } $result = $this->processQueue->run($commandOutput); @@ -157,4 +164,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'commands' => $commands, ]); } + + /** + * Resolves a human-readable process label for a nested standards command. + * + * @param string $command the nested dev-tools command name + * + * @return string the process section label + */ + private function getProcessLabel(string $command): string + { + return match ($command) { + 'refactor' => 'Refactoring Code with DevTools', + 'phpdoc' => 'Checking PHPDoc with DevTools', + 'code-style' => 'Checking Code Style with DevTools', + 'reports' => 'Generating Reports with DevTools', + default => 'Running DevTools Command', + }; + } } diff --git a/src/Console/Command/SyncCommand.php b/src/Console/Command/SyncCommand.php index 80f1fcf5a..c08fd2414 100644 --- a/src/Console/Command/SyncCommand.php +++ b/src/Console/Command/SyncCommand.php @@ -221,12 +221,16 @@ private function queueDevToolsCommand( $arguments[] = '--pretty-json'; } + if (! $jsonOutput && ! $prettyJsonOutput && ! \in_array('--ansi', $arguments, true)) { + $arguments[] = '--ansi'; + } + foreach ($arguments as $argument) { $processBuilder = $processBuilder->withArgument($argument); } $process = $processBuilder->build(DevToolsPathResolver::getBinaryPath()); - $this->processQueue->add($process, detached: $detached); + $this->processQueue->add(process: $process, detached: $detached, label: 'Running DevTools Sync Hook'); } } diff --git a/src/Console/Command/TestsCommand.php b/src/Console/Command/TestsCommand.php index 42bec6dcd..d62ba27b7 100644 --- a/src/Console/Command/TestsCommand.php +++ b/src/Console/Command/TestsCommand.php @@ -181,6 +181,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $processBuilder = $processBuilder->withArgument('--no-progress'); } + if (! $jsonOutput) { + $processBuilder = $processBuilder->withArgument('--colors=always'); + } + if ($cacheEnabled) { $processBuilder = $processBuilder->withArgument( '--cache-result', @@ -200,9 +204,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $this->processQueue->add( - $processBuilder + process: $processBuilder ->withArgument($input->getArgument('path')) - ->build('vendor/bin/phpunit') + ->build('vendor/bin/phpunit'), + label: 'Running PHPUnit Tests', ); $result = $this->processQueue->run($processOutput); diff --git a/src/Console/Command/WikiCommand.php b/src/Console/Command/WikiCommand.php index 7c0d8ba9f..85b9b9119 100644 --- a/src/Console/Command/WikiCommand.php +++ b/src/Console/Command/WikiCommand.php @@ -132,6 +132,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $processBuilder = $this->processBuilder + ->withArgument('--ansi') ->withArgument('--visibility', 'public,protected') ->withArgument('--template', 'vendor/saggre/phpdocumentor-markdown/themes/markdown') ->withArgument('--title', $this->composer->getDescription()) @@ -151,7 +152,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $processBuilder = $processBuilder->withArgument('--defaultpackagename', $defaultPackageName); } - $this->processQueue->add($processBuilder->build('vendor/bin/phpdoc')); + $this->processQueue->add( + process: $processBuilder->build('vendor/bin/phpdoc'), + label: 'Generating Wiki with phpDocumentor', + ); $result = $this->processQueue->run($processOutput); @@ -204,7 +208,8 @@ private function initializeWikiSubmodule(InputInterface $input, string $target, ->withArgument('add') ->withArgument($wikiRepoUrl) ->withArgument(Path::makeRelative($wikiSubmodulePath, getcwd())) - ->build('git') + ->build('git'), + label: 'Initializing Wiki Submodule with Git', ); $result = $this->processQueue->run($output); diff --git a/src/Console/Output/GithubActionOutput.php b/src/Console/Output/GithubActionOutput.php index be732f9a0..8d21d1c56 100644 --- a/src/Console/Output/GithubActionOutput.php +++ b/src/Console/Output/GithubActionOutput.php @@ -19,7 +19,7 @@ namespace FastForward\DevTools\Console\Output; -use Composer\Util\Platform; +use FastForward\DevTools\Environment\EnvironmentInterface; use Symfony\Component\Console\Output\ConsoleOutputInterface; /** @@ -35,9 +35,11 @@ final class GithubActionOutput /** * @param ConsoleOutputInterface $output the console output used to emit workflow commands + * @param EnvironmentInterface $environment reads runtime environment flags */ public function __construct( private readonly ConsoleOutputInterface $output, + private readonly EnvironmentInterface $environment, ) {} /** @@ -245,8 +247,22 @@ private function emit(string $command, string $message = '', array $properties = */ private function supportsWorkflowCommands(): bool { - return (bool) Platform::getEnv('GITHUB_ACTIONS') - && ! (bool) Platform::getEnv('COMPOSER_TESTS_ARE_RUNNING'); + return $this->isTruthyEnvironmentFlag('GITHUB_ACTIONS') + && ! $this->isTruthyEnvironmentFlag('COMPOSER_TESTS_ARE_RUNNING'); + } + + /** + * Determines whether an environment flag is set to a truthy value. + * + * @param string $name the environment variable name + * + * @return bool true when the environment variable is truthy + */ + private function isTruthyEnvironmentFlag(string $name): bool + { + $value = $this->environment->get($name, ''); + + return null !== $value && '' !== $value && '0' !== $value; } /** diff --git a/src/Console/Output/OutputCapabilityDetector.php b/src/Console/Output/OutputCapabilityDetector.php new file mode 100644 index 000000000..95f0dffa4 --- /dev/null +++ b/src/Console/Output/OutputCapabilityDetector.php @@ -0,0 +1,49 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Console\Output; + +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Output\StreamOutput; + +/** + * Detects ANSI-friendly output by decoration state or TTY-backed streams. + */ +final class OutputCapabilityDetector implements OutputCapabilityDetectorInterface +{ + /** + * Determines whether the output supports ANSI-capable human interaction. + * + * @param OutputInterface $output the output to inspect + * + * @return bool true when the output is decorated or connected to a TTY + */ + public function supportsAnsi(OutputInterface $output): bool + { + if ($output->isDecorated()) { + return true; + } + + if (! $output instanceof StreamOutput) { + return false; + } + + return stream_isatty($output->getStream()); + } +} diff --git a/src/Console/Output/OutputCapabilityDetectorInterface.php b/src/Console/Output/OutputCapabilityDetectorInterface.php new file mode 100644 index 000000000..e9cdddae8 --- /dev/null +++ b/src/Console/Output/OutputCapabilityDetectorInterface.php @@ -0,0 +1,37 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Console\Output; + +use Symfony\Component\Console\Output\OutputInterface; + +/** + * Detects console output capabilities without requiring PTY-backed processes. + */ +interface OutputCapabilityDetectorInterface +{ + /** + * Determines whether the output supports ANSI-capable human interaction. + * + * @param OutputInterface $output the output to inspect + * + * @return bool true when the output is decorated or connected to a TTY + */ + public function supportsAnsi(OutputInterface $output): bool; +} diff --git a/src/Environment/Environment.php b/src/Environment/Environment.php new file mode 100644 index 000000000..bdf8358b8 --- /dev/null +++ b/src/Environment/Environment.php @@ -0,0 +1,45 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Environment; + +/** + * Reads environment variables through PHP's native runtime. + */ +final class Environment implements EnvironmentInterface +{ + /** + * Reads an environment variable. + * + * @param string $name the environment variable name + * @param string|null $default the value returned when the variable is not defined + * + * @return string|null the variable value, or the default when it is not defined + */ + public function get(string $name, ?string $default = null): ?string + { + $value = getenv($name); + + if (false === $value) { + return $default; + } + + return $value; + } +} diff --git a/src/Environment/EnvironmentInterface.php b/src/Environment/EnvironmentInterface.php new file mode 100644 index 000000000..18b6ef9d2 --- /dev/null +++ b/src/Environment/EnvironmentInterface.php @@ -0,0 +1,36 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Environment; + +/** + * Reads process environment variables without binding callers to Composer APIs. + */ +interface EnvironmentInterface +{ + /** + * Reads an environment variable. + * + * @param string $name the environment variable name + * @param string|null $default the value returned when the variable is not defined + * + * @return string|null the variable value, or the default when it is not defined + */ + public function get(string $name, ?string $default = null): ?string; +} diff --git a/src/Php/Extension.php b/src/Php/Extension.php new file mode 100644 index 000000000..6ed485fe3 --- /dev/null +++ b/src/Php/Extension.php @@ -0,0 +1,38 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Php; + +/** + * Checks PHP runtime extension availability through PHP's native runtime. + */ +final class Extension implements ExtensionInterface +{ + /** + * Determines whether a PHP extension is loaded in the current runtime. + * + * @param string $name the extension name + * + * @return bool true when the extension is loaded + */ + public function isLoaded(string $name): bool + { + return \extension_loaded($name); + } +} diff --git a/src/Php/ExtensionInterface.php b/src/Php/ExtensionInterface.php new file mode 100644 index 000000000..34b67c5eb --- /dev/null +++ b/src/Php/ExtensionInterface.php @@ -0,0 +1,35 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Php; + +/** + * Checks PHP runtime extension availability without coupling callers to global functions. + */ +interface ExtensionInterface +{ + /** + * Determines whether a PHP extension is loaded in the current runtime. + * + * @param string $name the extension name + * + * @return bool true when the extension is loaded + */ + public function isLoaded(string $name): bool; +} diff --git a/src/Process/ColorPreservingProcessEnvironmentConfigurator.php b/src/Process/ColorPreservingProcessEnvironmentConfigurator.php new file mode 100644 index 000000000..ca833672c --- /dev/null +++ b/src/Process/ColorPreservingProcessEnvironmentConfigurator.php @@ -0,0 +1,137 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Process; + +use FastForward\DevTools\Console\Output\OutputCapabilityDetectorInterface; +use FastForward\DevTools\Environment\EnvironmentInterface; +use Stringable; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +/** + * Keeps nested process output color-friendly without requiring PTY support. + */ +final readonly class ColorPreservingProcessEnvironmentConfigurator implements ProcessEnvironmentConfiguratorInterface +{ + /** + * @param EnvironmentInterface $environment reads parent process environment variables + * @param OutputCapabilityDetectorInterface $outputCapabilityDetector detects TTY/decorated output capabilities + */ + public function __construct( + private EnvironmentInterface $environment, + private OutputCapabilityDetectorInterface $outputCapabilityDetector, + ) {} + + /** + * Configures color-related environment variables for nested commands. + * + * @param Process $process the queued process that will be started + * @param OutputInterface $output the parent output used to infer console capabilities + */ + public function configure(Process $process, OutputInterface $output): void + { + if (! $this->shouldForceColor($output)) { + return; + } + + $env = $process->getEnv(); + + if ($this->hasNoColorOptOut($env)) { + return; + } + + $changed = $this->setDefault($env, 'FORCE_COLOR', '1'); + $changed = $this->setDefault($env, 'CLICOLOR_FORCE', '1') || $changed; + + if (null !== ($term = $this->environment->get('TERM'))) { + $changed = $this->setDefault($env, 'TERM', $term) || $changed; + } + + if ($changed) { + $process->setEnv($env); + } + } + + /** + * Determines whether child processes should be nudged toward ANSI output. + * + * @param OutputInterface $output the parent process output + * + * @return bool true when color should be forced for child processes + */ + private function shouldForceColor(OutputInterface $output): bool + { + if ($this->outputCapabilityDetector->supportsAnsi($output)) { + return true; + } + if ($this->isTruthyEnvironmentFlag('FORCE_COLOR')) { + return true; + } + + return $this->isTruthyEnvironmentFlag('CLICOLOR_FORCE'); + } + + /** + * Determines whether an environment flag is set to a truthy value. + * + * @param string $name the environment variable name + * + * @return bool true when the environment variable is truthy + */ + private function isTruthyEnvironmentFlag(string $name): bool + { + $value = $this->environment->get($name, ''); + + return null !== $value && '' !== $value && '0' !== $value; + } + + /** + * Determines whether the process or parent environment opted out of color. + * + * @param array $env the process-specific environment variables + * + * @return bool true when NO_COLOR is present + */ + private function hasNoColorOptOut(array $env): bool + { + return \array_key_exists('NO_COLOR', $env) + || null !== $this->environment->get('NO_COLOR'); + } + + /** + * Sets an environment default while preserving caller-provided values. + * + * @param array $env the environment map to update + * @param string $name the environment variable name + * @param string $value the default value + * + * @return bool true when the environment map changed + */ + private function setDefault(array &$env, string $name, string $value): bool + { + if (\array_key_exists($name, $env)) { + return false; + } + + $env[$name] = $value; + + return true; + } +} diff --git a/src/Process/CompositeProcessEnvironmentConfigurator.php b/src/Process/CompositeProcessEnvironmentConfigurator.php new file mode 100644 index 000000000..c6edffc94 --- /dev/null +++ b/src/Process/CompositeProcessEnvironmentConfigurator.php @@ -0,0 +1,49 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Process; + +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +/** + * Applies multiple process environment configurators in a stable order. + */ +final readonly class CompositeProcessEnvironmentConfigurator implements ProcessEnvironmentConfiguratorInterface +{ + /** + * @param iterable $configurators ordered environment configurators + */ + public function __construct( + private iterable $configurators + ) {} + + /** + * Configures environment variables for a queued process. + * + * @param Process $process the queued process that will be started + * @param OutputInterface $output the parent output used to infer console capabilities + */ + public function configure(Process $process, OutputInterface $output): void + { + foreach ($this->configurators as $configurator) { + $configurator->configure($process, $output); + } + } +} diff --git a/src/Process/ProcessEnvironmentConfiguratorInterface.php b/src/Process/ProcessEnvironmentConfiguratorInterface.php new file mode 100644 index 000000000..806726acf --- /dev/null +++ b/src/Process/ProcessEnvironmentConfiguratorInterface.php @@ -0,0 +1,40 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Process; + +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +/** + * Configures subprocess environment variables before queued execution. + */ +interface ProcessEnvironmentConfiguratorInterface +{ + /** + * Configures environment variables for a queued process. + * + * Implementations MUST preserve process-specific environment values that + * callers already configured before enqueueing the process. + * + * @param Process $process the queued process that will be started + * @param OutputInterface $output the parent output used to infer console capabilities + */ + public function configure(Process $process, OutputInterface $output): void; +} diff --git a/src/Process/ProcessQueue.php b/src/Process/ProcessQueue.php index fc626c52f..5f239e4ff 100644 --- a/src/Process/ProcessQueue.php +++ b/src/Process/ProcessQueue.php @@ -21,10 +21,14 @@ use Closure; use FastForward\DevTools\Console\Output\GithubActionOutput; +use FastForward\DevTools\Console\Output\OutputCapabilityDetectorInterface; +use FastForward\DevTools\Environment\EnvironmentInterface; use ReflectionProperty; +use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Process\Exception\ProcessStartFailedException; use Symfony\Component\Process\Process; @@ -68,9 +72,15 @@ final class ProcessQueue implements ProcessQueueInterface /** * @param GithubActionOutput $githubActionOutput wraps grouped queue output in GitHub Actions logs when supported + * @param ProcessEnvironmentConfiguratorInterface $environmentConfigurator + * @param EnvironmentInterface $environment reads runtime environment flags + * @param OutputCapabilityDetectorInterface $outputCapabilityDetector detects ANSI-capable output */ public function __construct( private readonly GithubActionOutput $githubActionOutput, + private readonly ProcessEnvironmentConfiguratorInterface $environmentConfigurator, + private readonly EnvironmentInterface $environment, + private readonly OutputCapabilityDetectorInterface $outputCapabilityDetector, ) {} /** @@ -121,7 +131,7 @@ public function run(?OutputInterface $output = new NullOutput()): int $label = $entry['label']; if ($detached) { - $startupStatusCode = $this->startDetachedProcess($process, $label); + $startupStatusCode = $this->startDetachedProcess($process, $output, $label); if ( ! $ignoreFailure @@ -176,12 +186,13 @@ public function wait(?OutputInterface $output = null): void * sequence completes without throwing an exception. * * @param Process $process the process to start + * @param OutputInterface $output the parent output used to infer process environment * @param string $label the label used when presenting the buffered output * * @return int returns 0 when the process starts successfully, or a non-zero * value when startup fails */ - private function startDetachedProcess(Process $process, string $label): int + private function startDetachedProcess(Process $process, OutputInterface $output, string $label): int { $entry = (object) [ 'process' => $process, @@ -191,6 +202,7 @@ private function startDetachedProcess(Process $process, string $label): int ]; try { + $this->environmentConfigurator->configure($process, $output); $process->start($this->createBufferedOutputCallback($entry)); $this->runningDetachedProcesses[] = $entry; @@ -212,6 +224,7 @@ private function startDetachedProcess(Process $process, string $label): int private function runBlockingProcess(Process $process, OutputInterface $output): int { try { + $this->environmentConfigurator->configure($process, $output); $process->run($this->createOutputCallback($output)); return $process->getExitCode() ?? self::FAILURE; @@ -231,7 +244,11 @@ private function runBlockingProcess(Process $process, OutputInterface $output): */ private function runLabeledBlockingProcess(Process $process, OutputInterface $output, string $label): int { - $runBlockingProcess = fn(): int => $this->runBlockingProcess($process, $output); + $runBlockingProcess = fn(): int => $this->runInOutputSection( + $label, + $output, + fn(): int => $this->runBlockingProcess($process, $output) + ); return $this->githubActionOutput->group($label, $runBlockingProcess); } @@ -310,8 +327,13 @@ private function flushDetachedProcessesOutput(OutputInterface $output): bool $entry->errorOutput, $output ); + $renderDetachedOutput = fn(): mixed => $this->runInOutputSection( + $entry->label, + $output, + $writeDetachedOutput + ); - $this->githubActionOutput->group($entry->label, $writeDetachedOutput); + $this->githubActionOutput->group($entry->label, $renderDetachedOutput); } $this->runningDetachedProcesses = $remainingDetachedProcesses; @@ -347,6 +369,44 @@ private function writeDetachedOutput(string $standardOutput, string $errorOutput return true; } + /** + * Runs a callback inside a local Symfony-style section when output supports it. + * + * GitHub Actions already receives native log groups, while non-ANSI outputs + * and JSON buffers should remain free of extra presentation chrome. + * + * @template TResult + * + * @param string $label the human-readable process label + * @param OutputInterface $output the output that MAY receive section chrome + * @param Closure(): TResult $callback the callback to run + * + * @return TResult + */ + private function runInOutputSection(string $label, OutputInterface $output, Closure $callback): mixed + { + if (! $this->shouldRenderLocalSection($output)) { + return $callback(); + } + + (new SymfonyStyle(new ArrayInput([]), $output))->section($label); + + return $callback(); + } + + /** + * Determines whether local Symfony section chrome should be emitted. + * + * @param OutputInterface $output the parent process output + * + * @return bool true when a local section should be rendered + */ + private function shouldRenderLocalSection(OutputInterface $output): bool + { + return $this->outputCapabilityDetector->supportsAnsi($output) + && null === $this->environment->get('GITHUB_ACTIONS'); + } + /** * Resolves the label used when presenting queued process output. * diff --git a/src/Process/XdebugDisablingProcessEnvironmentConfigurator.php b/src/Process/XdebugDisablingProcessEnvironmentConfigurator.php new file mode 100644 index 000000000..f3a09ffa7 --- /dev/null +++ b/src/Process/XdebugDisablingProcessEnvironmentConfigurator.php @@ -0,0 +1,159 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Process; + +use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Php\ExtensionInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +use function Safe\preg_match; + +/** + * Disables Xdebug for child processes unless coverage still needs it. + */ +final readonly class XdebugDisablingProcessEnvironmentConfigurator implements ProcessEnvironmentConfiguratorInterface +{ + /** + * @var list + */ + private const array COVERAGE_ARGUMENT_PATTERNS = [ + '--coverage', + '--coverage-clover', + '--coverage-cobertura', + '--coverage-crap4j', + '--coverage-html', + '--coverage-php', + '--coverage-text', + '--coverage-xml', + '--min-coverage', + ]; + + /** + * @param EnvironmentInterface $environment reads parent process environment variables + * @param ExtensionInterface $extension checks PHP extension availability + */ + public function __construct( + private EnvironmentInterface $environment, + private ExtensionInterface $extension, + ) {} + + /** + * Configures Xdebug-related environment variables for nested commands. + * + * @param Process $process the queued process that will be started + * @param OutputInterface $output the parent output used to infer console capabilities + */ + public function configure(Process $process, OutputInterface $output): void + { + unset($output); + + if (! $this->shouldDisableXdebug($process)) { + return; + } + + $env = $process->getEnv(); + + if (\array_key_exists('XDEBUG_MODE', $env)) { + return; + } + + $env['XDEBUG_MODE'] = 'off'; + $process->setEnv($env); + } + + /** + * Determines whether Xdebug can be disabled for the child process. + * + * @param Process $process the queued process that will be started + * + * @return bool true when Xdebug should be disabled for the child process + */ + private function shouldDisableXdebug(Process $process): bool + { + if (! $this->extension->isLoaded('xdebug')) { + return false; + } + + if ($this->isTruthyEnvironmentFlag('COMPOSER_ALLOW_XDEBUG')) { + return false; + } + + if (null !== $this->environment->get('XDEBUG_MODE')) { + return false; + } + + if (! $this->requiresCoverage($process)) { + return true; + } + + return $this->extension->isLoaded('pcov'); + } + + /** + * Determines whether the child process command line requests coverage. + * + * @param Process $process the queued process that will be started + * + * @return bool true when coverage arguments are present + */ + private function requiresCoverage(Process $process): bool + { + $commandLine = $process->getCommandLine(); + + foreach (self::COVERAGE_ARGUMENT_PATTERNS as $argument) { + if ($this->containsCommandLineArgument($commandLine, $argument)) { + return true; + } + } + + return false; + } + + /** + * Determines whether a command line contains an exact long option. + * + * @param string $commandLine the shell-escaped command line + * @param string $argument the long option to find + * + * @return bool true when the exact option is present + */ + private function containsCommandLineArgument(string $commandLine, string $argument): bool + { + return 1 === preg_match( + \sprintf('/(?:^|[\\s\'"])%s(?:=|[\\s\'"]|$)/', preg_quote($argument, '/')), + $commandLine + ); + } + + /** + * Determines whether an environment flag is set to a truthy value. + * + * @param string $name the environment variable name + * + * @return bool true when the environment variable is truthy + */ + private function isTruthyEnvironmentFlag(string $name): bool + { + $value = $this->environment->get($name, ''); + + return null !== $value && '' !== $value && '0' !== $value; + } +} diff --git a/src/ServiceProvider/DevToolsServiceProvider.php b/src/ServiceProvider/DevToolsServiceProvider.php index 38e163a3f..4dbf8017c 100644 --- a/src/ServiceProvider/DevToolsServiceProvider.php +++ b/src/ServiceProvider/DevToolsServiceProvider.php @@ -41,6 +41,10 @@ use FastForward\DevTools\Console\Logger\Processor\CompositeContextProcessor; use FastForward\DevTools\Console\Logger\Processor\ContextProcessorInterface; use FastForward\DevTools\Console\Output\GithubActionOutput; +use FastForward\DevTools\Console\Output\OutputCapabilityDetector; +use FastForward\DevTools\Console\Output\OutputCapabilityDetectorInterface; +use FastForward\DevTools\Environment\Environment; +use FastForward\DevTools\Environment\EnvironmentInterface; use FastForward\DevTools\Filesystem\FinderFactory; use FastForward\DevTools\Filesystem\FinderFactoryInterface; use FastForward\DevTools\Filesystem\Filesystem; @@ -67,12 +71,18 @@ use FastForward\DevTools\License\GeneratorInterface; use FastForward\DevTools\License\Resolver; use FastForward\DevTools\License\ResolverInterface; +use FastForward\DevTools\Php\Extension; +use FastForward\DevTools\Php\ExtensionInterface; use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoader; use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface; +use FastForward\DevTools\Process\ColorPreservingProcessEnvironmentConfigurator; +use FastForward\DevTools\Process\CompositeProcessEnvironmentConfigurator; use FastForward\DevTools\Process\ProcessBuilder; use FastForward\DevTools\Process\ProcessBuilderInterface; +use FastForward\DevTools\Process\ProcessEnvironmentConfiguratorInterface; use FastForward\DevTools\Process\ProcessQueue; use FastForward\DevTools\Process\ProcessQueueInterface; +use FastForward\DevTools\Process\XdebugDisablingProcessEnvironmentConfigurator; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\Path\WorkingProjectPathResolver; use FastForward\DevTools\Psr\Clock\SystemClock; @@ -109,7 +119,15 @@ public function getFactories(): array { return [ // Process + EnvironmentInterface::class => get(Environment::class), + ExtensionInterface::class => get(Extension::class), + OutputCapabilityDetectorInterface::class => get(OutputCapabilityDetector::class), ProcessBuilderInterface::class => get(ProcessBuilder::class), + ProcessEnvironmentConfiguratorInterface::class => create(CompositeProcessEnvironmentConfigurator::class) + ->constructor([ + get(ColorPreservingProcessEnvironmentConfigurator::class), + get(XdebugDisablingProcessEnvironmentConfigurator::class), + ]), ProcessQueueInterface::class => get(ProcessQueue::class), // Filesystem @@ -145,7 +163,8 @@ public function getFactories(): array ->method('setVerbosity', ConsoleOutputInterface::VERBOSITY_VERBOSE) ->method('setFormatter', get(LogLevelOutputFormatter::class)), GithubActionOutput::class => create(GithubActionOutput::class)->constructor( - get(ConsoleOutputInterface::class) + get(ConsoleOutputInterface::class), + get(EnvironmentInterface::class) ), ContextProcessorInterface::class => create(CompositeContextProcessor::class)->constructor([ get(CommandInputProcessor::class), diff --git a/tests/Console/Command/CodeStyleCommandTest.php b/tests/Console/Command/CodeStyleCommandTest.php index 5366bb0f7..f6763fe96 100644 --- a/tests/Console/Command/CodeStyleCommandTest.php +++ b/tests/Console/Command/CodeStyleCommandTest.php @@ -94,7 +94,7 @@ protected function setUp(): void $this->processBuilder->reveal() ); $this->processBuilder->build(Argument::any())->willReturn($this->process->reveal()); - $this->processQueue->add($this->process->reveal()) + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalledTimes(3); $this->command = new CodeStyleCommand( diff --git a/tests/Console/Command/DependenciesCommandTest.php b/tests/Console/Command/DependenciesCommandTest.php index 9bf384d74..42c26e184 100644 --- a/tests/Console/Command/DependenciesCommandTest.php +++ b/tests/Console/Command/DependenciesCommandTest.php @@ -108,8 +108,7 @@ protected function setUp(): void #[Test] public function executeWillReturnSuccessWhenPreviewAndAnalyzersSucceed(): void { - $this->processQueue->add(Argument::type(Process::class))->shouldBeCalledTimes(3); - $this->processQueue->add(Argument::type(Process::class), false)->shouldBeCalledOnce(); + $this->processQueue->add(Argument::type(Process::class), false, Argument::cetera())->shouldBeCalledTimes(4); $this->processQueue->run(Argument::type('object')) ->willReturn(DependenciesCommand::SUCCESS) ->shouldBeCalledOnce(); @@ -152,8 +151,8 @@ public function executeWillIgnoreJackFailuresWhenMaxOutdatedIsDisabled(): void { $this->input->getOption('max-outdated') ->willReturn('-1'); - $this->processQueue->add(Argument::type(Process::class))->shouldBeCalledTimes(3); - $this->processQueue->add(Argument::type(Process::class), true)->shouldBeCalledOnce(); + $this->processQueue->add(Argument::type(Process::class), false, Argument::cetera())->shouldBeCalledTimes(3); + $this->processQueue->add(Argument::type(Process::class), true, Argument::cetera())->shouldBeCalledOnce(); $this->processQueue->run(Argument::type('object')) ->willReturn(DependenciesCommand::SUCCESS) ->shouldBeCalledOnce(); diff --git a/tests/Console/Command/DocsCommandTest.php b/tests/Console/Command/DocsCommandTest.php index 67eec5b72..1ea196cc1 100644 --- a/tests/Console/Command/DocsCommandTest.php +++ b/tests/Console/Command/DocsCommandTest.php @@ -178,7 +178,7 @@ public function executeWillReturnSuccessWhenProcessQueueSucceeds(): void $this->processBuilder->withArgument('--cache-folder', '/repo/.dev-tools/cache/phpdoc') ->willReturn($this->processBuilder->reveal()) ->shouldBeCalled(); - $this->processQueue->add($this->process->reveal()) + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(DocsCommand::SUCCESS) @@ -209,7 +209,7 @@ public function executeWithNoCacheWillSkipPhpDocumentorCacheFolder(): void ->shouldBeCalled(); $this->processBuilder->withArgument('--cache-folder', Argument::cetera()) ->shouldNotBeCalled(); - $this->processQueue->add($this->process->reveal()) + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(DocsCommand::SUCCESS) diff --git a/tests/Console/Command/FundingCommandTest.php b/tests/Console/Command/FundingCommandTest.php index 63274d432..c7810af63 100644 --- a/tests/Console/Command/FundingCommandTest.php +++ b/tests/Console/Command/FundingCommandTest.php @@ -181,7 +181,7 @@ public function executeWillCreateComposerFundingFromFundingYaml(): void $fundingYaml, 'Updating managed file .github/FUNDING.yml from generated funding metadata synchronization.', )->willReturn(new FileDiff(FileDiff::STATUS_UNCHANGED, 'Funding unchanged'))->shouldBeCalledOnce(); - $this->processQueue->add($this->normalizeProcess->reveal()) + $this->processQueue->add($this->normalizeProcess->reveal(), Argument::cetera()) ->shouldBeCalledOnce(); $this->processQueue->run($this->output->reveal()) ->willReturn(ProcessQueueInterface::SUCCESS)->shouldBeCalledOnce(); @@ -286,7 +286,7 @@ public function executeWillMergeBothSourcesWithoutDuplicatingEntries(): void $fundingYaml, 'Updating managed file .github/FUNDING.yml from generated funding metadata synchronization.', )->willReturn(new FileDiff(FileDiff::STATUS_CHANGED, 'Funding changed'))->shouldBeCalledOnce(); - $this->processQueue->add($this->normalizeProcess->reveal()) + $this->processQueue->add($this->normalizeProcess->reveal(), Argument::cetera()) ->shouldBeCalledOnce(); $this->processQueue->run($this->output->reveal()) ->willReturn(ProcessQueueInterface::SUCCESS)->shouldBeCalledOnce(); @@ -536,7 +536,7 @@ public function executeWillReturnFailureWhenComposerNormalizeFails(): void $fundingYaml, 'Updating managed file .github/FUNDING.yml from generated funding metadata synchronization.', )->willReturn(new FileDiff(FileDiff::STATUS_UNCHANGED, 'Funding unchanged'))->shouldBeCalledOnce(); - $this->processQueue->add($this->normalizeProcess->reveal()) + $this->processQueue->add($this->normalizeProcess->reveal(), Argument::cetera()) ->shouldBeCalledOnce(); $this->processQueue->run($this->output->reveal()) ->willReturn(ProcessQueueInterface::FAILURE)->shouldBeCalledOnce(); @@ -596,7 +596,7 @@ public function executeWillPassWorkingDirectoryAndAlternateManifestToComposerNor $fundingYaml, 'Updating managed file .github/FUNDING.yml from generated funding metadata synchronization.', )->willReturn(new FileDiff(FileDiff::STATUS_UNCHANGED, 'Funding unchanged'))->shouldBeCalledOnce(); - $this->processQueue->add($this->normalizeProcess->reveal()) + $this->processQueue->add($this->normalizeProcess->reveal(), Argument::cetera()) ->shouldBeCalledOnce(); $this->processQueue->run($this->output->reveal()) ->willReturn(ProcessQueueInterface::SUCCESS)->shouldBeCalledOnce(); @@ -682,7 +682,7 @@ public function privateHelpersWillPromptAndNormalizeComposerFileArguments(): voi $this->processBuilder->withArgument('--file', 'composer.alt.json') ->willReturn($this->processBuilder->reveal()) ->shouldBeCalledOnce(); - $this->processQueue->add($this->normalizeProcess->reveal()) + $this->processQueue->add($this->normalizeProcess->reveal(), Argument::cetera()) ->shouldBeCalledOnce(); $this->processQueue->run($this->output->reveal()) ->willReturn(ProcessQueueInterface::SUCCESS) diff --git a/tests/Console/Command/MetricsCommandTest.php b/tests/Console/Command/MetricsCommandTest.php index a5f3d4351..b7b5775fb 100644 --- a/tests/Console/Command/MetricsCommandTest.php +++ b/tests/Console/Command/MetricsCommandTest.php @@ -73,7 +73,7 @@ protected function setUp(): void $this->process = $this->prophesize(Process::class); $this->input->getOption('exclude') - ->willReturn('vendor,tests'); + ->willReturn('vendor,tests/Fixtures'); $this->input->getOption('target') ->willReturn(ManagedWorkspace::getOutputDirectory(ManagedWorkspace::METRICS)); $this->input->getOption('junit') @@ -94,8 +94,11 @@ protected function setUp(): void $this->processBuilder->withArgument(Argument::any(), Argument::any())->willReturn( $this->processBuilder->reveal() ); - $this->processBuilder->build(Argument::any())->willReturn($this->process->reveal()); - $this->processQueue->add($this->process->reveal()) + $this->processBuilder->build(Argument::that(static fn(array $command): bool => \PHP_BINARY === $command[0] + && str_starts_with((string) $command[1], '-derror_reporting=') + && '-ddefault_socket_timeout=1' === $command[2] + && 'vendor/bin/phpmetrics' === $command[3]))->willReturn($this->process->reveal()); + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalled(); $this->command = new MetricsCommand( diff --git a/tests/Console/Command/PhpDocCommandTest.php b/tests/Console/Command/PhpDocCommandTest.php index ef6ce3c25..b02f17240 100644 --- a/tests/Console/Command/PhpDocCommandTest.php +++ b/tests/Console/Command/PhpDocCommandTest.php @@ -98,6 +98,10 @@ protected function setUp(): void $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); $this->process = $this->prophesize(Process::class); + $this->process->getEnv() + ->willReturn([]); + $this->process->setEnv(Argument::type('array')) + ->willReturn($this->process->reveal()); $this->input->getOption('fix') ->willReturn(false); @@ -150,7 +154,7 @@ protected function setUp(): void $this->processBuilder->reveal() ); $this->processBuilder->build(Argument::any())->willReturn($this->process->reveal()); - $this->processQueue->add($this->process->reveal()) + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalledTimes(2); $this->command = new PhpDocCommand( diff --git a/tests/Console/Command/RefactorCommandTest.php b/tests/Console/Command/RefactorCommandTest.php index ba39b9220..79c96733b 100644 --- a/tests/Console/Command/RefactorCommandTest.php +++ b/tests/Console/Command/RefactorCommandTest.php @@ -91,7 +91,7 @@ protected function setUp(): void $this->processBuilder->withArgument(Argument::any())->willReturn($this->processBuilder->reveal()); $this->processBuilder->build(Argument::any())->willReturn($this->process->reveal()); - $this->processQueue->add($this->process->reveal()) + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalled(); $this->command = new RefactorCommand( diff --git a/tests/Console/Command/SyncCommandTest.php b/tests/Console/Command/SyncCommandTest.php index 79a575ccb..a7f695b53 100644 --- a/tests/Console/Command/SyncCommandTest.php +++ b/tests/Console/Command/SyncCommandTest.php @@ -85,8 +85,12 @@ protected function setUp(): void #[Test] public function executeWillQueueDedicatedSynchronizationCommands(): void { - $this->processQueue->add(Argument::type(Process::class), false, false)->shouldBeCalledTimes(2); - $this->processQueue->add(Argument::type(Process::class), false, true)->shouldBeCalledTimes(11); + $this->processQueue->add(Argument::type(Process::class), false, false, Argument::cetera())->shouldBeCalledTimes( + 2 + ); + $this->processQueue->add(Argument::type(Process::class), false, true, Argument::cetera())->shouldBeCalledTimes( + 11 + ); $this->processQueue->run($this->output->reveal()) ->willReturn(SyncCommand::SUCCESS) ->shouldBeCalledOnce(); @@ -113,8 +117,10 @@ public function executeWillDisableDetachedModeWhenCheckingDrift(): void { $this->input->getOption('check') ->willReturn(true); - $this->processQueue->add(Argument::type(Process::class), false, false)->shouldBeCalledTimes(10); - $this->processQueue->add(Argument::type(Process::class), false, true)->shouldNotBeCalled(); + $this->processQueue->add(Argument::type(Process::class), false, false, Argument::cetera())->shouldBeCalledTimes( + 10 + ); + $this->processQueue->add(Argument::type(Process::class), false, true, Argument::cetera())->shouldNotBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(SyncCommand::FAILURE) ->shouldBeCalledOnce(); @@ -146,11 +152,14 @@ public function executeWillPropagateJsonOutputFormatToSubCommands(): void ->willReturn(true); $this->input->getOption('pretty-json') ->willReturn(false); - $this->processQueue->add(Argument::type(Process::class), false, false)->shouldBeCalledTimes(2); + $this->processQueue->add(Argument::type(Process::class), false, false, Argument::cetera())->shouldBeCalledTimes( + 2 + ); $this->processQueue->add( Argument::that(static fn(Process $process): bool => str_contains($process->getCommandLine(), '--json')), false, true, + Argument::cetera() )->shouldBeCalledTimes(11); $this->processQueue->run(Argument::type('object')) ->willReturn(SyncCommand::SUCCESS) diff --git a/tests/Console/Command/TestsCommandTest.php b/tests/Console/Command/TestsCommandTest.php index daeaf13c7..0c6b43548 100644 --- a/tests/Console/Command/TestsCommandTest.php +++ b/tests/Console/Command/TestsCommandTest.php @@ -131,13 +131,18 @@ protected function setUp(): void #[Test] public function executeWillRunPhpUnitProcessWithConfigFile(): void { - $this->processQueue->add(Argument::that(static fn(Process $process): bool => str_contains( - $process->getCommandLine(), - '--configuration=' . getcwd() . '/' . TestsCommand::CONFIG, - ) && str_contains($process->getCommandLine(), '--cache-result') && str_contains( - $process->getCommandLine(), - '--cache-directory=' . getcwd() . '/.dev-tools/cache/phpunit', - )))->shouldBeCalled(); + $this->processQueue->add( + Argument::that(static fn(Process $process): bool => str_contains( + $process->getCommandLine(), + '--configuration=' . getcwd() . '/' . TestsCommand::CONFIG, + ) && str_contains($process->getCommandLine(), '--cache-result') && str_contains( + $process->getCommandLine(), + '--cache-directory=' . getcwd() . '/.dev-tools/cache/phpunit', + ) && str_contains($process->getCommandLine(), '--colors=always')), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(TestsCommand::SUCCESS)->shouldBeCalled(); $this->logger->info('Running PHPUnit tests...', Argument::that( @@ -163,10 +168,15 @@ public function executeWithNoCacheWillDisablePhpUnitResultCache(): void $this->input->getOption('no-cache') ->willReturn(true); - $this->processQueue->add(Argument::that(static fn(Process $process): bool => str_contains( - $process->getCommandLine(), - '--do-not-cache-result', - ) && ! str_contains($process->getCommandLine(), '--cache-directory=')))->shouldBeCalled(); + $this->processQueue->add( + Argument::that(static fn(Process $process): bool => str_contains( + $process->getCommandLine(), + '--do-not-cache-result', + ) && ! str_contains($process->getCommandLine(), '--cache-directory=')), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(TestsCommand::SUCCESS)->shouldBeCalled(); @@ -184,10 +194,15 @@ public function executeWillDisablePhpUnitProgressWhenJsonIsRequested(): void $this->input->getOption('pretty-json') ->willReturn(false); - $this->processQueue->add(Argument::that(static fn(Process $process): bool => str_contains( - $process->getCommandLine(), - '--no-progress', - )))->shouldBeCalled(); + $this->processQueue->add( + Argument::that(static fn(Process $process): bool => str_contains( + $process->getCommandLine(), + '--no-progress', + ) && ! str_contains($process->getCommandLine(), '--colors=always')), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); $this->processQueue->run(Argument::type(OutputInterface::class)) ->willReturn(TestsCommand::SUCCESS)->shouldBeCalled(); $this->logger->info('Running PHPUnit tests...', Argument::that( @@ -212,10 +227,15 @@ public function executeWillEnablePhpUnitProgressWhenRequested(): void $this->input->getOption('progress') ->willReturn(true); - $this->processQueue->add(Argument::that(static fn(Process $process): bool => ! str_contains( - $process->getCommandLine(), - '--no-progress', - )))->shouldBeCalled(); + $this->processQueue->add( + Argument::that(static fn(Process $process): bool => ! str_contains( + $process->getCommandLine(), + '--no-progress', + )), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(TestsCommand::SUCCESS)->shouldBeCalled(); @@ -256,7 +276,12 @@ public function executeWithCoverageBelowMinimumWillReturnFailure(): void ->willReturn('80'); $this->coverageSummaryLoader->load($coverageReportPath) ->willReturn(new CoverageSummary(75, 100)); - $this->processQueue->add(Argument::type(Process::class))->shouldBeCalled(); + $this->processQueue->add( + Argument::type(Process::class), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(TestsCommand::SUCCESS)->shouldBeCalled(); $this->logger->info('Running PHPUnit tests...', Argument::that( @@ -287,7 +312,12 @@ public function executeWillReturnFailureWhenCoverageSummaryCannotBeLoaded(): voi ->willReturn('80'); $this->coverageSummaryLoader->load($coverageReportPath) ->willThrow(new RuntimeException('Coverage summary could not be loaded.')); - $this->processQueue->add(Argument::type(Process::class))->shouldBeCalled(); + $this->processQueue->add( + Argument::type(Process::class), + false, + false, + 'Running PHPUnit Tests' + )->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(TestsCommand::SUCCESS)->shouldBeCalled(); $this->logger->info('Running PHPUnit tests...', Argument::that( diff --git a/tests/Console/Command/WikiCommandTest.php b/tests/Console/Command/WikiCommandTest.php index 05ea89bc1..69030c563 100644 --- a/tests/Console/Command/WikiCommandTest.php +++ b/tests/Console/Command/WikiCommandTest.php @@ -131,7 +131,7 @@ public function executeWillReturnSuccessWhenProcessQueueSucceeds(): void ) ->willReturn($this->processBuilder->reveal()) ->shouldBeCalled(); - $this->processQueue->add($this->process->reveal()) + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(WikiCommand::SUCCESS) @@ -160,7 +160,7 @@ public function executeWithNoCacheWillSkipPhpDocumentorCacheFolder(): void ->willReturn(true); $this->processBuilder->withArgument('--cache-folder', Argument::cetera()) ->shouldNotBeCalled(); - $this->processQueue->add($this->process->reveal()) + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalled(); $this->processQueue->run($this->output->reveal()) ->willReturn(WikiCommand::SUCCESS) diff --git a/tests/Console/Logger/OutputFormatLoggerTest.php b/tests/Console/Logger/OutputFormatLoggerTest.php index 3ebbbb45f..851f8cc50 100644 --- a/tests/Console/Logger/OutputFormatLoggerTest.php +++ b/tests/Console/Logger/OutputFormatLoggerTest.php @@ -27,6 +27,7 @@ use FastForward\DevTools\Console\Logger\Processor\CommandOutputProcessor; use FastForward\DevTools\Console\Logger\Processor\CompositeContextProcessor; use FastForward\DevTools\Console\Output\GithubActionOutput; +use FastForward\DevTools\Environment\EnvironmentInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -57,6 +58,11 @@ final class OutputFormatLoggerTest extends TestCase private ObjectProphecy $clock; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $environment; + /** * @var array */ @@ -77,6 +83,9 @@ protected function setUp(): void $this->output = $this->prophesize(ConsoleOutputInterface::class); $this->errorOutput = $this->prophesize(OutputInterface::class); $this->clock = $this->prophesize(ClockInterface::class); + $this->environment = $this->prophesize(EnvironmentInterface::class); + $this->environment->get(Argument::type('string'), Argument::cetera()) + ->willReturn(null); $this->output->getErrorOutput() ->willReturn($this->errorOutput->reveal()); @@ -96,7 +105,7 @@ public function logWillWriteInterpolatedInfoMessagesToStandardOutput(): void $this->clock->reveal(), new Detector(), new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), - new GithubActionOutput($this->output->reveal()), + $this->createGithubActionOutput(), ); $this->output->writeln( @@ -132,7 +141,7 @@ public function logWillWriteErrorMessagesToErrorOutput(): void $this->clock->reveal(), new Detector(), new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), - new GithubActionOutput($this->output->reveal()), + $this->createGithubActionOutput(), ); $this->output->writeln(Argument::type('string')) @@ -161,7 +170,7 @@ public function logWillWriteStructuredJsonWhenJsonOutputIsRequested(): void $this->clock->reveal(), new Detector(), new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), - new GithubActionOutput($this->output->reveal()), + $this->createGithubActionOutput(), ); $this->output->writeln( @@ -188,7 +197,7 @@ public function logWillWritePrettyPrintedJsonWhenPrettyJsonOutputIsRequested(): $this->clock->reveal(), new Detector(), new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), - new GithubActionOutput($this->output->reveal()), + $this->createGithubActionOutput(), ); $this->output->writeln( @@ -217,7 +226,7 @@ public function logWillWriteStructuredJsonWhenAgentEnvironmentIsDetected(): void $this->clock->reveal(), new Detector(), new CompositeContextProcessor([new CommandInputProcessor(), new CommandOutputProcessor()]), - new GithubActionOutput($this->output->reveal()), + $this->createGithubActionOutput(), ); $this->output->writeln( @@ -246,4 +255,12 @@ protected function tearDown(): void putenv('COMPOSER_TESTS_ARE_RUNNING=' . $this->composerTestsAreRunningEnv); } + + /** + * @return GithubActionOutput + */ + private function createGithubActionOutput(): GithubActionOutput + { + return new GithubActionOutput($this->output->reveal(), $this->environment->reveal()); + } } diff --git a/tests/Console/Output/OutputCapabilityDetectorTest.php b/tests/Console/Output/OutputCapabilityDetectorTest.php new file mode 100644 index 000000000..51260d6c9 --- /dev/null +++ b/tests/Console/Output/OutputCapabilityDetectorTest.php @@ -0,0 +1,82 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Console\Output; + +use FastForward\DevTools\Console\Output\OutputCapabilityDetector; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Output\StreamOutput; + +use function Safe\fopen; +use function Safe\fclose; + +#[CoversClass(OutputCapabilityDetector::class)] +final class OutputCapabilityDetectorTest extends TestCase +{ + use ProphecyTrait; + + /** + * @return void + */ + #[Test] + public function supportsAnsiWhenOutputIsDecorated(): void + { + $output = $this->prophesize(OutputInterface::class); + $output->isDecorated() + ->willReturn(true); + + self::assertTrue((new OutputCapabilityDetector())->supportsAnsi($output->reveal())); + } + + /** + * @return void + */ + #[Test] + public function doesNotSupportAnsiForPlainNonStreamOutput(): void + { + $output = $this->prophesize(OutputInterface::class); + $output->isDecorated() + ->willReturn(false); + + self::assertFalse((new OutputCapabilityDetector())->supportsAnsi($output->reveal())); + } + + /** + * @return void + */ + #[Test] + public function doesNotSupportAnsiForPlainNonTtyStreamOutput(): void + { + $stream = fopen('php://memory', 'w'); + + self::assertIsResource($stream); + + try { + self::assertFalse( + (new OutputCapabilityDetector())->supportsAnsi(new StreamOutput($stream, decorated: false)) + ); + } finally { + fclose($stream); + } + } +} diff --git a/tests/Environment/EnvironmentTest.php b/tests/Environment/EnvironmentTest.php new file mode 100644 index 000000000..36ec719f4 --- /dev/null +++ b/tests/Environment/EnvironmentTest.php @@ -0,0 +1,88 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Environment; + +use FastForward\DevTools\Environment\Environment; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +use function Safe\putenv; + +#[CoversClass(Environment::class)] +final class EnvironmentTest extends TestCase +{ + private Environment $environment; + + private string|false $previousValue; + + /** + * @return void + */ + protected function setUp(): void + { + $this->environment = new Environment(); + $this->previousValue = getenv('DEV_TOOLS_ENVIRONMENT_READER_TEST'); + putenv('DEV_TOOLS_ENVIRONMENT_READER_TEST'); + } + + /** + * @return void + */ + #[Test] + public function getReturnsNullForMissingEnvironmentVariable(): void + { + self::assertNull($this->environment->get('DEV_TOOLS_ENVIRONMENT_READER_TEST')); + } + + /** + * @return void + */ + #[Test] + public function getReturnsDefaultForMissingEnvironmentVariable(): void + { + self::assertSame('fallback', $this->environment->get('DEV_TOOLS_ENVIRONMENT_READER_TEST', 'fallback')); + } + + /** + * @return void + */ + #[Test] + public function getReturnsEnvironmentVariableValue(): void + { + putenv('DEV_TOOLS_ENVIRONMENT_READER_TEST=enabled'); + + self::assertSame('enabled', $this->environment->get('DEV_TOOLS_ENVIRONMENT_READER_TEST')); + } + + /** + * @return void + */ + protected function tearDown(): void + { + if (false === $this->previousValue) { + putenv('DEV_TOOLS_ENVIRONMENT_READER_TEST'); + + return; + } + + putenv('DEV_TOOLS_ENVIRONMENT_READER_TEST=' . $this->previousValue); + } +} diff --git a/tests/Git/GitClientTest.php b/tests/Git/GitClientTest.php index 453fae501..9f175c543 100644 --- a/tests/Git/GitClientTest.php +++ b/tests/Git/GitClientTest.php @@ -25,6 +25,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use RuntimeException; @@ -84,7 +85,7 @@ public function getConfigWillBuildAndRunTheExpectedGitCommand(): void $this->process->setWorkingDirectory('/repo') ->willReturn($this->process->reveal()) ->shouldBeCalledOnce(); - $this->processQueue->add($this->process->reveal()) + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalledOnce(); $this->processQueue->run() ->willReturn(ProcessQueueInterface::SUCCESS) @@ -117,7 +118,7 @@ public function showWillRelativizeAbsolutePathsWithinTheRepository(): void $this->process->setWorkingDirectory('/repo') ->willReturn($this->process->reveal()) ->shouldBeCalledOnce(); - $this->processQueue->add($this->process->reveal()) + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalledOnce(); $this->processQueue->run() ->willReturn(ProcessQueueInterface::SUCCESS) @@ -150,7 +151,7 @@ public function showWillKeepPathsThatDoNotBelongToTheRepositoryUnchanged(): void $this->process->setWorkingDirectory('/repo') ->willReturn($this->process->reveal()) ->shouldBeCalledOnce(); - $this->processQueue->add($this->process->reveal()) + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalledOnce(); $this->processQueue->run() ->willReturn(ProcessQueueInterface::SUCCESS) @@ -186,7 +187,7 @@ public function getConfigWillThrowTheTrimmedErrorOutputWhenTheQueueFails(): void $this->process->setWorkingDirectory('/repo') ->willReturn($this->process->reveal()) ->shouldBeCalledOnce(); - $this->processQueue->add($this->process->reveal()) + $this->processQueue->add($this->process->reveal(), Argument::cetera()) ->shouldBeCalledOnce(); $this->processQueue->run() ->willReturn(ProcessQueueInterface::FAILURE) diff --git a/tests/Php/ExtensionTest.php b/tests/Php/ExtensionTest.php new file mode 100644 index 000000000..368c8cff4 --- /dev/null +++ b/tests/Php/ExtensionTest.php @@ -0,0 +1,41 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Php; + +use FastForward\DevTools\Php\Extension; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; + +#[CoversClass(Extension::class)] +final class ExtensionTest extends TestCase +{ + /** + * @return void + */ + #[Test] + public function isLoadedReturnsNativeExtensionStatus(): void + { + $extension = new Extension(); + + self::assertTrue($extension->isLoaded('json')); + self::assertFalse($extension->isLoaded('dev_tools_missing_extension')); + } +} diff --git a/tests/Process/ColorPreservingProcessEnvironmentConfiguratorTest.php b/tests/Process/ColorPreservingProcessEnvironmentConfiguratorTest.php new file mode 100644 index 000000000..75397c678 --- /dev/null +++ b/tests/Process/ColorPreservingProcessEnvironmentConfiguratorTest.php @@ -0,0 +1,146 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Process; + +use FastForward\DevTools\Console\Output\OutputCapabilityDetectorInterface; +use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Process\ColorPreservingProcessEnvironmentConfigurator; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +#[CoversClass(ColorPreservingProcessEnvironmentConfigurator::class)] +final class ColorPreservingProcessEnvironmentConfiguratorTest extends TestCase +{ + use ProphecyTrait; + + private ColorPreservingProcessEnvironmentConfigurator $configurator; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $environment; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $outputCapabilityDetector; + + /** + * @return void + */ + protected function setUp(): void + { + $this->environment = $this->prophesize(EnvironmentInterface::class); + $this->environment->get(Argument::type('string'), Argument::cetera()) + ->willReturn(null); + $this->outputCapabilityDetector = $this->prophesize(OutputCapabilityDetectorInterface::class); + $this->outputCapabilityDetector->supportsAnsi(Argument::type(OutputInterface::class)) + ->willReturn(false); + $this->configurator = new ColorPreservingProcessEnvironmentConfigurator( + $this->environment->reveal(), + $this->outputCapabilityDetector->reveal() + ); + } + + /** + * @return void + */ + #[Test] + public function configureAddsColorEnvironmentForDecoratedOutput(): void + { + $process = $this->prophesize(Process::class); + $output = $this->prophesize(OutputInterface::class); + $this->outputCapabilityDetector->supportsAnsi($output->reveal()) + ->willReturn(true); + $process->getEnv() + ->willReturn([ + 'EXISTING_ENV' => 'kept', + ]); + $process->setEnv(Argument::that(static fn(array $env): bool => 'kept' === $env['EXISTING_ENV'] + && '1' === $env['FORCE_COLOR'] + && '1' === $env['CLICOLOR_FORCE'])) + ->willReturn($process->reveal()) + ->shouldBeCalledOnce(); + + $this->configurator->configure($process->reveal(), $output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function configureDoesNotOverrideNoColorOptOut(): void + { + $process = $this->prophesize(Process::class); + $output = $this->prophesize(OutputInterface::class); + $this->outputCapabilityDetector->supportsAnsi($output->reveal()) + ->willReturn(true); + $process->getEnv() + ->willReturn([ + 'NO_COLOR' => '1', + ]); + $process->setEnv(Argument::any()) + ->shouldNotBeCalled(); + + $this->configurator->configure($process->reveal(), $output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function configureDoesNothingWhenColorIsNotRequested(): void + { + $process = $this->prophesize(Process::class); + $output = $this->prophesize(OutputInterface::class); + $process->getEnv() + ->shouldNotBeCalled(); + $process->setEnv(Argument::any()) + ->shouldNotBeCalled(); + + $this->configurator->configure($process->reveal(), $output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function configureHonorsParentForceColorForPlainOutput(): void + { + $process = $this->prophesize(Process::class); + $output = $this->prophesize(OutputInterface::class); + $this->environment->get('FORCE_COLOR', '') + ->willReturn('1'); + $process->getEnv() + ->willReturn([]); + $process->setEnv(Argument::that(static fn(array $env): bool => '1' === $env['FORCE_COLOR'] + && '1' === $env['CLICOLOR_FORCE'])) + ->willReturn($process->reveal()) + ->shouldBeCalledOnce(); + + $this->configurator->configure($process->reveal(), $output->reveal()); + } +} diff --git a/tests/Process/CompositeProcessEnvironmentConfiguratorTest.php b/tests/Process/CompositeProcessEnvironmentConfiguratorTest.php new file mode 100644 index 000000000..f72a8a8fc --- /dev/null +++ b/tests/Process/CompositeProcessEnvironmentConfiguratorTest.php @@ -0,0 +1,70 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Process; + +use FastForward\DevTools\Process\CompositeProcessEnvironmentConfigurator; +use FastForward\DevTools\Process\ProcessEnvironmentConfiguratorInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +#[CoversClass(CompositeProcessEnvironmentConfigurator::class)] +final class CompositeProcessEnvironmentConfiguratorTest extends TestCase +{ + use ProphecyTrait; + + /** + * @return void + */ + #[Test] + public function configureDelegatesToEveryConfigurator(): void + { + $process = $this->prophesize(Process::class); + $output = $this->prophesize(OutputInterface::class); + $firstConfigurator = $this->createConfiguratorMock($process, $output); + $secondConfigurator = $this->createConfiguratorMock($process, $output); + + $configurator = new CompositeProcessEnvironmentConfigurator([ + $firstConfigurator->reveal(), + $secondConfigurator->reveal(), + ]); + + $configurator->configure($process->reveal(), $output->reveal()); + } + + /** + * @param ObjectProphecy $process + * @param ObjectProphecy $output + * + * @return ObjectProphecy + */ + private function createConfiguratorMock(ObjectProphecy $process, ObjectProphecy $output): ObjectProphecy + { + $configurator = $this->prophesize(ProcessEnvironmentConfiguratorInterface::class); + $configurator->configure($process->reveal(), $output->reveal()) + ->shouldBeCalledOnce(); + + return $configurator; + } +} diff --git a/tests/Process/ProcessQueueTest.php b/tests/Process/ProcessQueueTest.php index abe9022f6..92488dd85 100644 --- a/tests/Process/ProcessQueueTest.php +++ b/tests/Process/ProcessQueueTest.php @@ -21,6 +21,9 @@ use Closure; use FastForward\DevTools\Console\Output\GithubActionOutput; +use FastForward\DevTools\Console\Output\OutputCapabilityDetectorInterface; +use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Process\ProcessEnvironmentConfiguratorInterface; use FastForward\DevTools\Process\ProcessQueue; use FastForward\DevTools\Process\ProcessQueueInterface; use PHPUnit\Framework\Attributes\CoversClass; @@ -30,11 +33,14 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Console\Formatter\OutputFormatterInterface; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Exception\ProcessStartFailedException; use Symfony\Component\Process\Process; +use function Safe\preg_replace; + #[CoversClass(ProcessQueue::class)] #[UsesClass(GithubActionOutput::class)] final class ProcessQueueTest extends TestCase @@ -51,11 +57,31 @@ final class ProcessQueueTest extends TestCase */ private ObjectProphecy $errorOutput; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $outputFormatter; + /** * @var ObjectProphecy */ private ObjectProphecy $githubActionOutput; + /** + * @var ObjectProphecy + */ + private ObjectProphecy $environmentConfigurator; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $environment; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $outputCapabilityDetector; + private ProcessQueue $queue; /** @@ -65,14 +91,41 @@ protected function setUp(): void { $this->output = $this->prophesize(ConsoleOutputInterface::class); $this->errorOutput = $this->prophesize(OutputInterface::class); + $this->outputFormatter = $this->prophesize(OutputFormatterInterface::class); $this->output->getErrorOutput() ->willReturn($this->errorOutput->reveal()); + $this->output->getVerbosity() + ->willReturn(OutputInterface::VERBOSITY_NORMAL); + $this->output->getFormatter() + ->willReturn($this->outputFormatter->reveal()); + $this->output->write("\n"); + $this->output->writeln(Argument::type('string'), Argument::type('int')); + $this->outputFormatter->isDecorated() + ->willReturn(true); + $this->outputFormatter->setDecorated(Argument::type('bool')); + $this->outputFormatter->format(Argument::type('string')) + ->will(static fn(array $arguments): string => preg_replace('/<[^>]+>/', '', $arguments[0])); + + $this->environmentConfigurator = $this->prophesize(ProcessEnvironmentConfiguratorInterface::class); + + $this->environment = $this->prophesize(EnvironmentInterface::class); + $this->environment->get('GITHUB_ACTIONS') + ->willReturn(null); + + $this->outputCapabilityDetector = $this->prophesize(OutputCapabilityDetectorInterface::class); + $this->outputCapabilityDetector->supportsAnsi(Argument::type(OutputInterface::class)) + ->willReturn(false); $this->githubActionOutput = $this->prophesize(GithubActionOutput::class); $this->githubActionOutput->group(Argument::type('string'), Argument::type(Closure::class)) ->will(static fn(array $arguments): mixed => $arguments[1]()); - $this->queue = new ProcessQueue($this->githubActionOutput->reveal()); + $this->queue = new ProcessQueue( + $this->githubActionOutput->reveal(), + $this->environmentConfigurator->reveal(), + $this->environment->reveal(), + $this->outputCapabilityDetector->reveal() + ); } /** @@ -147,6 +200,26 @@ private function createDetachedProcessMock(bool ...$runningSequence): ObjectProp return $process; } + /** + * Creates a matcher for SymfonyStyle output blocks. + * + * @param string $expected the expected line fragment + * + * @return callable(mixed):bool the prophecy matcher callback + */ + private function containsOutputLine(string $expected): callable + { + return static function (mixed $messages) use ($expected): bool { + foreach ((array) $messages as $message) { + if (\is_string($message) && str_contains($message, $expected)) { + return true; + } + } + + return false; + }; + } + /** * @return void */ @@ -169,6 +242,41 @@ public function runWithSuccessfulBlockingProcessReturnsSuccess(): void self::assertSame(ProcessQueueInterface::SUCCESS, $this->queue->run($this->output->reveal())); } + /** + * @return void + */ + #[Test] + public function runConfiguresBlockingProcessEnvironmentBeforeExecution(): void + { + $process = $this->createBlockingProcessMock(); + $this->environmentConfigurator->configure($process->reveal(), $this->output->reveal()) + ->shouldBeCalledOnce(); + + $this->queue->add($process->reveal()); + + self::assertSame(ProcessQueueInterface::SUCCESS, $this->queue->run($this->output->reveal())); + } + + /** + * @return void + */ + #[Test] + public function runWrapsBlockingProcessOutputInLocalSection(): void + { + $process = $this->createBlockingProcessMock(); + $this->outputCapabilityDetector->supportsAnsi($this->output->reveal()) + ->willReturn(true); + $this->output->writeln( + Argument::that($this->containsOutputLine('Custom nested command')), + OutputInterface::OUTPUT_NORMAL + ) + ->shouldBeCalled(); + + $this->queue->add(process: $process->reveal(), label: 'Custom nested command'); + + self::assertSame(ProcessQueueInterface::SUCCESS, $this->queue->run($this->output->reveal())); + } + /** * @return void */ @@ -237,8 +345,12 @@ public function runDetachedProcessStartsWithoutBlockingAndWaitsAtTheEnd(): void { $detachedProcess = $this->createDetachedProcessMock(true, false); $blockingProcess = $this->createBlockingProcessMock(); + $this->environmentConfigurator->configure($detachedProcess->reveal(), $this->output->reveal()) + ->shouldBeCalledOnce(); + $this->environmentConfigurator->configure($blockingProcess->reveal(), $this->output->reveal()) + ->shouldBeCalledOnce(); - $this->queue->add($detachedProcess->reveal(), detached: true); + $this->queue->add(process: $detachedProcess->reveal(), detached: true); $this->queue->add($blockingProcess->reveal()); self::assertSame(ProcessQueueInterface::SUCCESS, $this->queue->run($this->output->reveal())); @@ -260,7 +372,7 @@ public function runDetachedProcessStartFailureReturnsFailure(): void $process->start(Argument::type(Closure::class)) ->willThrow(new ProcessStartFailedException($process->reveal(), 'Failed')); - $this->queue->add($process->reveal(), detached: true); + $this->queue->add(process: $process->reveal(), detached: true); self::assertSame(ProcessQueueInterface::FAILURE, $this->queue->run($this->output->reveal())); } @@ -281,7 +393,7 @@ public function runDetachedProcessStartFailureWithIgnoreFailureReturnsSuccess(): $process->start(Argument::type(Closure::class)) ->willThrow(new ProcessStartFailedException($process->reveal(), 'Failed')); - $this->queue->add($process->reveal(), ignoreFailure: true, detached: true); + $this->queue->add(process: $process->reveal(), ignoreFailure: true, detached: true); self::assertSame(ProcessQueueInterface::SUCCESS, $this->queue->run($this->output->reveal())); } @@ -375,9 +487,21 @@ public function waitWillFlushFinishedDetachedOutputWithoutWaitingForEveryProcess ->shouldBeCalled(); $this->output->write('second output') ->shouldBeCalled(); + $this->outputCapabilityDetector->supportsAnsi($this->output->reveal()) + ->willReturn(true); + $this->output->writeln( + Argument::that($this->containsOutputLine('Running first-command')), + OutputInterface::OUTPUT_NORMAL + ) + ->shouldBeCalled(); + $this->output->writeln( + Argument::that($this->containsOutputLine('Running second-command')), + OutputInterface::OUTPUT_NORMAL + ) + ->shouldBeCalled(); - $this->queue->add($firstProcess->reveal(), detached: true); - $this->queue->add($secondProcess->reveal(), detached: true); + $this->queue->add(process: $firstProcess->reveal(), detached: true, label: 'Running first-command'); + $this->queue->add(process: $secondProcess->reveal(), detached: true, label: 'Running second-command'); self::assertSame(ProcessQueueInterface::SUCCESS, $this->queue->run($this->output->reveal())); } diff --git a/tests/Process/XdebugDisablingProcessEnvironmentConfiguratorTest.php b/tests/Process/XdebugDisablingProcessEnvironmentConfiguratorTest.php new file mode 100644 index 000000000..147941375 --- /dev/null +++ b/tests/Process/XdebugDisablingProcessEnvironmentConfiguratorTest.php @@ -0,0 +1,232 @@ + + * @license https://opensource.org/licenses/MIT MIT License + * + * @see https://github.com/php-fast-forward/ + * @see https://github.com/php-fast-forward/dev-tools + * @see https://github.com/php-fast-forward/dev-tools/issues + * @see https://php-fast-forward.github.io/dev-tools/ + * @see https://datatracker.ietf.org/doc/html/rfc2119 + */ + +namespace FastForward\DevTools\Tests\Process; + +use FastForward\DevTools\Environment\EnvironmentInterface; +use FastForward\DevTools\Php\ExtensionInterface; +use FastForward\DevTools\Process\XdebugDisablingProcessEnvironmentConfigurator; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; + +#[CoversClass(XdebugDisablingProcessEnvironmentConfigurator::class)] +final class XdebugDisablingProcessEnvironmentConfiguratorTest extends TestCase +{ + use ProphecyTrait; + + private XdebugDisablingProcessEnvironmentConfigurator $configurator; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $environment; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $extension; + + /** + * @var ObjectProphecy + */ + private ObjectProphecy $output; + + /** + * @return void + */ + protected function setUp(): void + { + $this->environment = $this->prophesize(EnvironmentInterface::class); + $this->environment->get(Argument::type('string'), Argument::cetera()) + ->willReturn(null); + $this->extension = $this->prophesize(ExtensionInterface::class); + $this->extension->isLoaded('xdebug') + ->willReturn(true); + $this->extension->isLoaded('pcov') + ->willReturn(false); + $this->output = $this->prophesize(OutputInterface::class); + $this->configurator = new XdebugDisablingProcessEnvironmentConfigurator( + $this->environment->reveal(), + $this->extension->reveal() + ); + } + + /** + * @return void + */ + #[Test] + public function configureDisablesXdebugForNonCoverageProcess(): void + { + $process = $this->createProcessMock( + commandLine: "'composer' 'normalize'", + env: [ + 'EXISTING_ENV' => 'kept', + ] + ); + $process->setEnv(Argument::that(static fn(array $env): bool => 'kept' === $env['EXISTING_ENV'] + && 'off' === $env['XDEBUG_MODE'])) + ->willReturn($process->reveal()) + ->shouldBeCalledOnce(); + + $this->configurator->configure($process->reveal(), $this->output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function configureDoesNotOverrideProcessXdebugMode(): void + { + $process = $this->createProcessMock( + commandLine: "'composer' 'normalize'", + env: [ + 'XDEBUG_MODE' => 'debug', + ] + ); + $process->setEnv(Argument::any()) + ->shouldNotBeCalled(); + + $this->configurator->configure($process->reveal(), $this->output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function configureDoesNotDisableXdebugWhenParentAllowsComposerXdebug(): void + { + $this->environment->get('COMPOSER_ALLOW_XDEBUG', '') + ->willReturn('1'); + $process = $this->createProcessMock(commandLine: "'composer' 'normalize'"); + $process->getEnv() + ->shouldNotBeCalled(); + $process->setEnv(Argument::any()) + ->shouldNotBeCalled(); + + $this->configurator->configure($process->reveal(), $this->output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function configureDoesNotDisableXdebugWhenParentAlreadyConfiguredXdebugMode(): void + { + $this->environment->get('XDEBUG_MODE') + ->willReturn('debug'); + $process = $this->createProcessMock(commandLine: "'composer' 'normalize'"); + $process->getEnv() + ->shouldNotBeCalled(); + $process->setEnv(Argument::any()) + ->shouldNotBeCalled(); + + $this->configurator->configure($process->reveal(), $this->output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function configureDoesNotDisableXdebugForCoverageWhenPcovIsUnavailable(): void + { + $process = $this->createProcessMock( + commandLine: "'composer' 'dev-tools' 'tests' '--' '--coverage' '.dev-tools/coverage'" + ); + $process->getEnv() + ->shouldNotBeCalled(); + $process->setEnv(Argument::any()) + ->shouldNotBeCalled(); + + $this->configurator->configure($process->reveal(), $this->output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function configureDisablesXdebugForCoverageWhenPcovIsAvailable(): void + { + $this->extension->isLoaded('pcov') + ->willReturn(true); + $process = $this->createProcessMock( + commandLine: "'vendor/bin/phpunit' '--coverage-html' '.dev-tools/coverage'" + ); + $process->setEnv(Argument::that(static fn(array $env): bool => 'off' === $env['XDEBUG_MODE'])) + ->willReturn($process->reveal()) + ->shouldBeCalledOnce(); + + $this->configurator->configure($process->reveal(), $this->output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function configureDoesNotTreatCoverageSummaryAsCoverageDriverRequirement(): void + { + $process = $this->createProcessMock( + commandLine: "'composer' 'dev-tools' 'tests' '--' '--coverage-summary'" + ); + $process->setEnv(Argument::that(static fn(array $env): bool => 'off' === $env['XDEBUG_MODE'])) + ->willReturn($process->reveal()) + ->shouldBeCalledOnce(); + + $this->configurator->configure($process->reveal(), $this->output->reveal()); + } + + /** + * @return void + */ + #[Test] + public function configureDoesNothingWhenXdebugIsNotLoaded(): void + { + $this->extension->isLoaded('xdebug') + ->willReturn(false); + $process = $this->createProcessMock(commandLine: "'composer' 'normalize'"); + $process->getEnv() + ->shouldNotBeCalled(); + $process->setEnv(Argument::any()) + ->shouldNotBeCalled(); + + $this->configurator->configure($process->reveal(), $this->output->reveal()); + } + + /** + * @param string $commandLine + * @param array $env + * + * @return ObjectProphecy + */ + private function createProcessMock(string $commandLine, array $env = []): ObjectProphecy + { + $process = $this->prophesize(Process::class); + $process->getCommandLine() + ->willReturn($commandLine); + $process->getEnv() + ->willReturn($env); + + return $process; + } +}