From 0ed5eb7c24ba3349d9859f517b041cf4a098083d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 13 May 2026 13:14:19 -0300 Subject: [PATCH 1/4] fix: skip reports coverage when tests unavailable --- CHANGELOG.md | 1 + src/Console/Command/ReportsCommand.php | 20 ++++-- tests/Console/Command/ReportsCommandTest.php | 70 ++++++++++++++++++- .../Processor/CommandOutputProcessorTest.php | 3 + tests/Path/WorkingProjectPathResolverTest.php | 2 + 5 files changed, 91 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b5b5aa867..e37e6fc796 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Relax workflow fallback Composer install so .dev-tools-actions bootstrap does not require composer.lock when provisioning DevTools runtime in shared GitHub Actions contexts (#342). +- Skip coverage report generation in reports command when no test surface is detectable (no default tests directory and no testable PHP source), while still generating docs and metrics. ## [1.25.4] - 2026-05-11 diff --git a/src/Console/Command/ReportsCommand.php b/src/Console/Command/ReportsCommand.php index d9658cb59e..f7db59ad4c 100644 --- a/src/Console/Command/ReportsCommand.php +++ b/src/Console/Command/ReportsCommand.php @@ -23,6 +23,7 @@ use FastForward\DevTools\Console\Input\HasCacheOption; use FastForward\DevTools\Console\Input\HasJsonOption; use FastForward\DevTools\Path\DevToolsPathResolver; +use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Path\ManagedWorkspace; @@ -52,10 +53,12 @@ final class ReportsCommand extends Command * * @param ProcessBuilderInterface $processBuilder the builder instance used to construct execution processes * @param ProcessQueueInterface $processQueue the execution queue mechanism for running sub-processes + * @param ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver the resolver for project capability detection */ public function __construct( private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, + private readonly ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver, ) { parent::__construct(); } @@ -105,8 +108,8 @@ protected function configure(): void /** * Executes the generation logic for diverse reports. * - * The method MUST run the underlying `docs` and `tests` commands. It SHALL process - * and generate the frontpage output file successfully. + * The method MUST run the underlying `docs` command and, when applicable, + * the `tests` command for coverage generation. * * @param InputInterface $input the structured inputs holding specific arguments * @param OutputInterface $output the designated output interface @@ -186,7 +189,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $coverageBuilder = $coverageBuilder->withArgument('--pretty-json'); } - $coverage = $coverageBuilder->build([DevToolsPathResolver::getBinaryPath(), 'tests']); + $projectCapabilities = $this->projectCapabilitiesResolver->resolve(); + if ($projectCapabilities->canRunTests()) { + $coverage = $coverageBuilder->build([DevToolsPathResolver::getBinaryPath(), 'tests']); + $this->processQueue->add(process: $coverage, label: 'Generating Coverage Report'); + } else { + $this->log( + 'Skipping coverage report because no tests directory or PHP source files were detected.', + $input, + logLevel: 'warning', + ); + } if ($progress) { $metricsBuilder = $metricsBuilder->withArgument('--progress'); @@ -203,7 +216,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $metrics = $metricsBuilder->build([DevToolsPathResolver::getBinaryPath(), '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/tests/Console/Command/ReportsCommandTest.php b/tests/Console/Command/ReportsCommandTest.php index b652d92af0..70046f9dc4 100644 --- a/tests/Console/Command/ReportsCommandTest.php +++ b/tests/Console/Command/ReportsCommandTest.php @@ -30,6 +30,8 @@ use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; use FastForward\DevTools\Environment\RuntimeEnvironment; +use FastForward\DevTools\Project\ProjectCapabilities; +use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -50,6 +52,8 @@ #[UsesClass(DevToolsServiceProvider::class)] #[UsesClass(DevToolsEnvironment::class)] #[UsesClass(RuntimeEnvironment::class)] +#[UsesClass(ProjectCapabilities::class)] +#[UsesClass(ProjectCapabilitiesResolverInterface::class)] #[CoversClass(ReportsCommand::class)] #[UsesClass(ManagedWorkspace::class)] #[UsesClass(DevToolsPathResolver::class)] @@ -71,6 +75,8 @@ final class ReportsCommandTest extends TestCase private ObjectProphecy $process; + private ObjectProphecy $projectCapabilitiesResolver; + private ReportsCommand $command; /** @@ -85,6 +91,7 @@ protected function setUp(): void $this->input = $this->prophesize(InputInterface::class); $this->output = $this->prophesize(OutputInterface::class); $this->process = $this->prophesize(Process::class); + $this->projectCapabilitiesResolver = $this->prophesize(ProjectCapabilitiesResolverInterface::class); $this->input->getOption('target') ->willReturn(ManagedWorkspace::getOutputDirectory()); @@ -117,8 +124,14 @@ protected function setUp(): void $this->processBuilder->reveal() ); $this->processBuilder->build(Argument::any())->willReturn($this->process->reveal()); + $this->projectCapabilitiesResolver->resolve() + ->willReturn($this->createProjectCapabilities(canRunTests: true)); - $this->command = new ReportsCommand($this->processBuilder->reveal(), $this->processQueue->reveal()); + $this->command = new ReportsCommand( + $this->processBuilder->reveal(), + $this->processQueue->reveal(), + $this->projectCapabilitiesResolver->reveal(), + ); } /** @@ -237,6 +250,44 @@ public function executeWithNoCacheWillForwardNoCacheOnlyToDocsAndTests(): void self::assertSame(ReportsCommand::SUCCESS, $this->executeCommand()); } + /** + * @return void + */ + #[Test] + public function executeWillSkipCoverageReportWhenNoTestsAvailable(): void + { + $this->projectCapabilitiesResolver->resolve() + ->willReturn($this->createProjectCapabilities(canRunTests: false)) + ->shouldBeCalledTimes(1); + + $this->processQueue->add(Argument::type(Process::class), Argument::cetera()) + ->shouldBeCalledTimes(2); + $this->processQueue->run($this->output->reveal()) + ->willReturn(ReportsCommand::SUCCESS) + ->shouldBeCalledOnce(); + + $this->logger->log( + 'info', + 'Generating frontpage for Fast Forward documentation...', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface), + )->shouldBeCalledOnce(); + $this->logger->log( + 'warning', + 'Skipping coverage report because no tests directory or PHP source files were detected.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface), + )->shouldBeCalledOnce(); + $this->logger->log( + 'info', + 'Documentation reports generated successfully.', + Argument::that( + static fn(array $context): bool => $context['input'] instanceof InputInterface + && $context['output'] instanceof OutputInterface, + ), + )->shouldBeCalledOnce(); + + self::assertSame(ReportsCommand::SUCCESS, $this->executeCommand()); + } + /** * @return int */ @@ -245,4 +296,21 @@ private function executeCommand(): int return (new ReflectionMethod($this->command, 'execute')) ->invoke($this->command, $this->input->reveal(), $this->output->reveal()); } + + /** + * @param bool $canRunTests + * + * @return ProjectCapabilities + */ + private function createProjectCapabilities(bool $canRunTests): ProjectCapabilities + { + return new ProjectCapabilities( + apiDirectories: [], + defaultPackageName: null, + hasGuideDirectory: false, + hasTestsPath: $canRunTests, + hasWikiTarget: false, + hasPhpSourceFiles: $canRunTests, + ); + } } diff --git a/tests/Console/Logger/Processor/CommandOutputProcessorTest.php b/tests/Console/Logger/Processor/CommandOutputProcessorTest.php index 8373872178..1197f9b88c 100644 --- a/tests/Console/Logger/Processor/CommandOutputProcessorTest.php +++ b/tests/Console/Logger/Processor/CommandOutputProcessorTest.php @@ -20,14 +20,17 @@ namespace FastForward\DevTools\Tests\Console\Logger\Processor; use FastForward\DevTools\Console\Logger\Processor\CommandOutputProcessor; +use FastForward\DevTools\Console\Output\GithubActionOutput; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\ConsoleOutputInterface; #[CoversClass(CommandOutputProcessor::class)] +#[UsesClass(GithubActionOutput::class)] final class CommandOutputProcessorTest extends TestCase { use ProphecyTrait; diff --git a/tests/Path/WorkingProjectPathResolverTest.php b/tests/Path/WorkingProjectPathResolverTest.php index 9ce9c5dfd9..8596b19e79 100644 --- a/tests/Path/WorkingProjectPathResolverTest.php +++ b/tests/Path/WorkingProjectPathResolverTest.php @@ -21,6 +21,7 @@ use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Path\WorkingProjectPathResolver; +use FastForward\DevTools\Console\Output\GithubActionOutput; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -37,6 +38,7 @@ use function uniqid; #[CoversClass(WorkingProjectPathResolver::class)] +#[UsesClass(GithubActionOutput::class)] #[UsesClass(ManagedWorkspace::class)] final class WorkingProjectPathResolverTest extends TestCase { From 626b33b4b6de01c1e9546bd3f4a60c7b04bf73e5 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 16:15:46 +0000 Subject: [PATCH 2/4] Update wiki submodule pointer for PR #345 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/wiki b/.github/wiki index e256370c4c..77bbd8c5da 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit e256370c4cecea70b19704d5373ab18d324de3cf +Subproject commit 77bbd8c5da294a3907ebdd1b9c591e5d8de21fdb From 5862564175486dd88329a9051095042b01679186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Felipe=20Say=C3=A3o=20Lobato=20Abreu?= Date: Wed, 13 May 2026 15:30:27 -0300 Subject: [PATCH 3/4] fix tests command env injection and coverage-safe behavior --- .../ComposerDependencyAnalyserConfig.php | 20 ++- src/Console/Command/ReportsCommand.php | 3 +- src/Console/Command/TestsCommand.php | 42 ++++-- src/Console/DevTools.php | 4 +- src/Path/ManagedWorkspace.php | 57 ++++--- src/Path/WorkingProjectPathResolver.php | 31 ++-- src/Project/ProjectCapabilities.php | 2 +- src/Project/ProjectCapabilitiesResolver.php | 49 +++++- .../DevToolsCommandProviderTest.php | 5 +- tests/Composer/PluginTest.php | 39 ----- .../ComposerDependencyAnalyserConfigTest.php | 61 +++----- tests/Config/ECSConfigTest.php | 2 + tests/Config/RectorConfigTest.php | 2 + tests/Console/Command/ReportsCommandTest.php | 18 +-- tests/Console/Command/TestsCommandTest.php | 142 +++++++++++++++--- tests/Console/DevToolsTest.php | 42 +++--- tests/Console/Input/HasJsonOptionTest.php | 126 ++++------------ tests/Environment/EnvironmentTest.php | 47 ++++-- tests/Path/ManagedWorkspaceTest.php | 95 ++++++++---- tests/Path/WorkingProjectPathResolverTest.php | 52 ++++--- .../ProjectCapabilitiesResolverTest.php | 23 +++ 21 files changed, 539 insertions(+), 323 deletions(-) diff --git a/src/Config/ComposerDependencyAnalyserConfig.php b/src/Config/ComposerDependencyAnalyserConfig.php index b1044307be..ef9c0ebaea 100644 --- a/src/Config/ComposerDependencyAnalyserConfig.php +++ b/src/Config/ComposerDependencyAnalyserConfig.php @@ -19,6 +19,8 @@ namespace FastForward\DevTools\Config; +use FastForward\DevTools\Environment\Environment; +use FastForward\DevTools\Environment\EnvironmentInterface; use FastForward\DevTools\Path\DevToolsPathResolver; use ShipMonk\ComposerDependencyAnalyser\Config\Configuration; use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType; @@ -86,14 +88,18 @@ final class ComposerDependencyAnalyserConfig * Creates the default Composer Dependency Analyser configuration. * * @param callable|null $customize optional callback to customize the configuration + * @param EnvironmentInterface|null $environment optional environment reader used to resolve overrides * * @return Configuration the configured analyser configuration */ - public static function configure(?callable $customize = null): Configuration - { + public static function configure( + ?callable $customize = null, + ?EnvironmentInterface $environment = null, + ): Configuration { + $environment ??= new Environment(); $configuration = new Configuration(); - if (! self::shouldShowShadowDependencies()) { + if (! self::shouldShowShadowDependencies($environment)) { self::applyIgnoresShadowDependencies($configuration); } @@ -128,11 +134,15 @@ public static function applyIgnoresShadowDependencies(Configuration $configurati /** * Determines whether shadow dependency reports SHOULD remain visible. * + * @param EnvironmentInterface|null $environment optional environment reader used to resolve overrides + * * @return bool */ - public static function shouldShowShadowDependencies(): bool + public static function shouldShowShadowDependencies(?EnvironmentInterface $environment = null): bool { - return '1' === getenv(self::ENV_SHOW_SHADOW_DEPENDENCIES); + $environment ??= new Environment(); + + return '1' === $environment->get(self::ENV_SHOW_SHADOW_DEPENDENCIES); } /** diff --git a/src/Console/Command/ReportsCommand.php b/src/Console/Command/ReportsCommand.php index f7db59ad4c..a241d38ddf 100644 --- a/src/Console/Command/ReportsCommand.php +++ b/src/Console/Command/ReportsCommand.php @@ -27,6 +27,7 @@ use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Path\ManagedWorkspace; +use Psr\Log\LogLevel; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; @@ -197,7 +198,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->log( 'Skipping coverage report because no tests directory or PHP source files were detected.', $input, - logLevel: 'warning', + logLevel: LogLevel::WARNING, ); } diff --git a/src/Console/Command/TestsCommand.php b/src/Console/Command/TestsCommand.php index bc034e5eab..05925c3513 100644 --- a/src/Console/Command/TestsCommand.php +++ b/src/Console/Command/TestsCommand.php @@ -24,6 +24,7 @@ use FastForward\DevTools\Console\Input\HasCacheOption; use FastForward\DevTools\Console\Input\HasJsonOption; use FastForward\DevTools\Composer\Json\ComposerJsonInterface; +use FastForward\DevTools\Environment\EnvironmentInterface; use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\Path\DevToolsPathResolver; use FastForward\DevTools\PhpUnit\Bootstrap\BootstrapShimGenerator; @@ -60,9 +61,9 @@ final class TestsCommand extends Command use HasJsonOption; use LogsCommandResults; - private const string AGENT_ENVIRONMENT_VARIABLE = 'AI_AGENT'; + public const string AGENT_ENVIRONMENT_VARIABLE = 'AI_AGENT'; - private const string AGENT_ENVIRONMENT_VALUE = 'fast-forward/dev-tools'; + public const string AGENT_ENVIRONMENT_VALUE = 'fast-forward/dev-tools'; private const string PROCESS_LABEL = 'Running PHPUnit Tests'; @@ -71,6 +72,8 @@ final class TestsCommand extends Command */ public const string CONFIG = 'phpunit.xml'; + public const string ENV_MINIMUM_COVERAGE = 'FAST_FORWARD_MIN_COVERAGE'; + /** * @param CoverageSummaryLoaderInterface $coverageSummaryLoader the loader used for `coverage-php` summaries * @param ComposerJsonInterface $composer the composer.json reader for autoload information @@ -80,6 +83,7 @@ final class TestsCommand extends Command * @param ProcessBuilderInterface $processBuilder the builder used to assemble the PHPUnit process * @param ProcessQueueInterface $processQueue the queue used to execute PHPUnit * @param ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver the project capability resolver + * @param EnvironmentInterface $environment the environment resolver for CLI-scoped flags */ public function __construct( private readonly CoverageSummaryLoaderInterface $coverageSummaryLoader, @@ -90,6 +94,7 @@ public function __construct( private readonly ProcessBuilderInterface $processBuilder, private readonly ProcessQueueInterface $processQueue, private readonly ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver, + private readonly EnvironmentInterface $environment, ) { parent::__construct(); } @@ -193,7 +198,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (! $projectCapabilities->canRunTests()) { return $this->success( - 'Skipping PHPUnit tests because no tests directory or PHP source files were detected.', + 'Skipping PHPUnit tests because no Composer-autoloaded PHP source files were detected.', $input, [ 'output' => $processOutput, @@ -249,14 +254,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $result = $this->processQueue->run($processOutput); $processResultContext = $this->resolveProcessResultContext($processOutput, $result, $structuredOutput); - if (self::SUCCESS !== $result || null === $minimumCoverage || null === $coverageReportPath) { - if (self::SUCCESS === $result) { - return $this->success('PHPUnit tests completed successfully.', $input, $processResultContext); - } - + if (self::SUCCESS !== $result) { return $this->failure('PHPUnit tests failed.', $input, $processResultContext); } + if (null === $minimumCoverage || null === $coverageReportPath) { + return $this->success('PHPUnit tests completed successfully.', $input, $processResultContext); + } + [$validationResult, $message, $coverageContext] = $this->validateMinimumCoverage( $coverageReportPath, $minimumCoverage, @@ -314,10 +319,9 @@ private function resolveProcessResultContext( private function forceAgentReporter(Process $process): void { $env = $process->getEnv(); + $parentAgentEnvironment = $this->environment->get(self::AGENT_ENVIRONMENT_VARIABLE); - if (\array_key_exists(self::AGENT_ENVIRONMENT_VARIABLE, $env) || false !== getenv( - self::AGENT_ENVIRONMENT_VARIABLE - )) { + if (\array_key_exists(self::AGENT_ENVIRONMENT_VARIABLE, $env) || null !== $parentAgentEnvironment) { return; } @@ -515,9 +519,15 @@ private function resolveMinimumCoverage(InputInterface $input): ?float $minimumCoverage = $input->getOption('min-coverage'); if (null === $minimumCoverage) { + $minimumCoverage = $this->resolveMinimumCoverageFromEnvironment(); + } + + if (false === $minimumCoverage || '' === trim((string) $minimumCoverage)) { return null; } + $minimumCoverage = trim((string) $minimumCoverage); + if (! is_numeric($minimumCoverage)) { throw new InvalidArgumentException('The --min-coverage option MUST be a numeric percentage.'); } @@ -531,6 +541,16 @@ private function resolveMinimumCoverage(InputInterface $input): ?float return $minimumCoverage; } + /** + * Resolves minimum-coverage value from injected environment abstraction. + * + * @return string|false|null the configured coverage threshold or a falsey fallback + */ + private function resolveMinimumCoverageFromEnvironment(): ?string + { + return $this->environment->get(self::ENV_MINIMUM_COVERAGE); + } + /** * @param InputInterface $input the raw parameter definitions * @param ProcessBuilderInterface $processBuilder the process builder to extend with coverage arguments diff --git a/src/Console/DevTools.php b/src/Console/DevTools.php index a5d4640b97..aab21a4d26 100644 --- a/src/Console/DevTools.php +++ b/src/Console/DevTools.php @@ -46,6 +46,8 @@ */ final class DevTools extends Application { + public const string ENV_AUTO_UPDATE = 'FAST_FORWARD_AUTO_UPDATE'; + private const string LOGO = <<<'LOGO' ____ _____ _ | _ \ _____ _|_ _|__ ___ | |___ @@ -201,7 +203,7 @@ private function configureWorkspaceDirectory(InputInterface $input): void */ private function runAutoUpdateWhenRequested(InputInterface $input, OutputInterface $output): void { - $autoUpdateMode = $this->environment->get('FAST_FORWARD_AUTO_UPDATE', ''); + $autoUpdateMode = $this->environment->get(self::ENV_AUTO_UPDATE, ''); if (! $input->hasParameterOption('--auto-update', true) && ! $this->isTruthyAutoUpdateMode($autoUpdateMode)) { return; diff --git a/src/Path/ManagedWorkspace.php b/src/Path/ManagedWorkspace.php index e733d1ea8a..b9cfdf4fd1 100644 --- a/src/Path/ManagedWorkspace.php +++ b/src/Path/ManagedWorkspace.php @@ -19,6 +19,8 @@ namespace FastForward\DevTools\Path; +use FastForward\DevTools\Environment\Environment; +use FastForward\DevTools\Environment\EnvironmentInterface; use Symfony\Component\Filesystem\Path; /** @@ -80,10 +82,14 @@ final class ManagedWorkspace * * @param string $path the optional relative segment to append under the managed output root * @param string $baseDir the optional repository root used to resolve the managed workspace path + * @param EnvironmentInterface|null $environment explicit environment reader used to resolve FAST_FORWARD_WORKSPACE_DIR */ - public static function getOutputDirectory(string $path = '', string $baseDir = ''): string - { - $baseDir = self::getWorkspaceRoot($baseDir); + public static function getOutputDirectory( + string $path = '', + string $baseDir = '', + ?EnvironmentInterface $environment = null, + ): string { + $baseDir = self::getWorkspaceRoot(baseDir: $baseDir, environment: $environment); return '' === $path ? $baseDir @@ -99,10 +105,14 @@ public static function getOutputDirectory(string $path = '', string $baseDir = ' * * @param string $path the optional relative cache segment to append under the managed cache root * @param string $baseDir the optional repository root used to resolve the managed cache path + * @param EnvironmentInterface|null $environment explicit environment reader used to resolve FAST_FORWARD_WORKSPACE_DIR */ - public static function getCacheDirectory(string $path = '', string $baseDir = ''): string - { - $baseDir = self::getOutputDirectory(self::CACHE_ROOT, $baseDir); + public static function getCacheDirectory( + string $path = '', + string $baseDir = '', + ?EnvironmentInterface $environment = null, + ): string { + $baseDir = self::getOutputDirectory(path: self::CACHE_ROOT, baseDir: $baseDir, environment: $environment); return '' === $path ? $baseDir @@ -117,12 +127,17 @@ public static function getCacheDirectory(string $path = '', string $baseDir = '' * under that base directory while absolute workspaces are used as-is. * * @param string $baseDir the optional repository root used to resolve a relative workspace + * @param EnvironmentInterface|null $environment explicit environment reader used to resolve FAST_FORWARD_WORKSPACE_DIR */ - public static function getWorkspaceRoot(string $baseDir = ''): string - { - $workspaceRoot = getenv(self::ENV_WORKSPACE_DIR); + public static function getWorkspaceRoot( + string $baseDir = '', + ?EnvironmentInterface $environment = null, + ): string { + $environment ??= new Environment(); - if (false === $workspaceRoot || '' === $workspaceRoot) { + $workspaceRoot = $environment->get(self::ENV_WORKSPACE_DIR); + + if (null === $workspaceRoot || '' === $workspaceRoot) { $workspaceRoot = self::WORKSPACE_ROOT; } @@ -138,17 +153,25 @@ public static function getWorkspaceRoot(string $baseDir = ''): string * should skip generated artifacts during source scans. * * @param string $baseDir the optional repository root used to relativize absolute workspace paths + * @param EnvironmentInterface|null $environment explicit environment reader used for tests */ - public static function getProjectRelativeWorkspaceRoot(string $baseDir = ''): ?string - { - $workspaceRoot = self::getWorkspaceRoot(); - - if (! Path::isAbsolute($workspaceRoot)) { - return $workspaceRoot; + public static function getProjectRelativeWorkspaceRoot( + string $baseDir = '', + ?EnvironmentInterface $environment = null, + ): ?string { + $environment ??= new Environment(); + $workspaceRoot = $environment->get(self::ENV_WORKSPACE_DIR); + + if (null === $workspaceRoot || '' === $workspaceRoot) { + $workspaceRoot = self::WORKSPACE_ROOT; } if ('' === $baseDir) { - return null; + return Path::isAbsolute($workspaceRoot) ? null : $workspaceRoot; + } + + if (! Path::isAbsolute($workspaceRoot)) { + return $workspaceRoot; } $baseDir = Path::canonicalize($baseDir); diff --git a/src/Path/WorkingProjectPathResolver.php b/src/Path/WorkingProjectPathResolver.php index 6145bbfd80..d8eb220f5e 100644 --- a/src/Path/WorkingProjectPathResolver.php +++ b/src/Path/WorkingProjectPathResolver.php @@ -19,6 +19,7 @@ namespace FastForward\DevTools\Path; +use FastForward\DevTools\Environment\EnvironmentInterface; use Symfony\Component\Finder\Finder; use Symfony\Component\Filesystem\Path; @@ -64,14 +65,17 @@ public static function getProjectPath(string $path = ''): string * Returns the project directories that static-analysis and coding-style tooling SHOULD skip. * * @param string $baseDir the optional repository base directory used to materialize absolute paths + * @param EnvironmentInterface|null $environment explicit environment reader used to resolve FAST_FORWARD_WORKSPACE_DIR * * @return list */ - public static function getToolingExcludedDirectories(string $baseDir = ''): array - { + public static function getToolingExcludedDirectories( + string $baseDir = '', + ?EnvironmentInterface $environment = null, + ): array { $directories = []; - foreach (self::getToolingExcludedDirectoryNames($baseDir) as $excludedDirectory) { + foreach (self::getToolingExcludedDirectoryNames($baseDir, $environment) as $excludedDirectory) { $directories[] = Path::join($baseDir, $excludedDirectory); } @@ -82,13 +86,16 @@ public static function getToolingExcludedDirectories(string $baseDir = ''): arra * Returns PHP source files that tooling SHOULD inspect without traversing generated directories. * * @param string $baseDir the optional repository base directory used to materialize absolute paths + * @param EnvironmentInterface|null $environment explicit environment reader used to resolve FAST_FORWARD_WORKSPACE_DIR * * @return list */ - public static function getToolingSourcePaths(string $baseDir = ''): array - { + public static function getToolingSourcePaths( + string $baseDir = '', + ?EnvironmentInterface $environment = null, + ): array { $workingDirectory = '' === $baseDir ? getcwd() : $baseDir; - $excludedDirectories = self::getToolingExcludedDirectoryNames($workingDirectory); + $excludedDirectories = self::getToolingExcludedDirectoryNames($workingDirectory, $environment); $finder = Finder::create() ->files() ->name('*.php') @@ -115,13 +122,19 @@ public static function getToolingSourcePaths(string $baseDir = ''): array * Returns repository-relative directories ignored by tooling. * * @param string $baseDir the optional repository base directory used to relativize a custom workspace + * @param EnvironmentInterface|null $environment explicit environment reader used to resolve FAST_FORWARD_WORKSPACE_DIR * * @return list */ - private static function getToolingExcludedDirectoryNames(string $baseDir = ''): array - { + private static function getToolingExcludedDirectoryNames( + string $baseDir = '', + ?EnvironmentInterface $environment = null, + ): array { $directories = self::TOOLING_EXCLUDED_DIRECTORIES; - $workspaceRoot = ManagedWorkspace::getProjectRelativeWorkspaceRoot($baseDir); + $workspaceRoot = ManagedWorkspace::getProjectRelativeWorkspaceRoot( + baseDir: $baseDir, + environment: $environment + ); if (null !== $workspaceRoot && ! \in_array($workspaceRoot, $directories, true)) { $directories[] = $workspaceRoot; diff --git a/src/Project/ProjectCapabilities.php b/src/Project/ProjectCapabilities.php index 428906f08a..33e33e15ec 100644 --- a/src/Project/ProjectCapabilities.php +++ b/src/Project/ProjectCapabilities.php @@ -128,6 +128,6 @@ public function canGenerateWiki(): bool */ public function canRunTests(): bool { - return $this->hasTestsPath || $this->hasPhpSourceFiles; + return $this->hasPhpSourceFiles; } } diff --git a/src/Project/ProjectCapabilitiesResolver.php b/src/Project/ProjectCapabilitiesResolver.php index acbee6e1e8..b4b9bbf7e3 100644 --- a/src/Project/ProjectCapabilitiesResolver.php +++ b/src/Project/ProjectCapabilitiesResolver.php @@ -21,6 +21,9 @@ use FastForward\DevTools\Composer\Json\ComposerJsonInterface; use FastForward\DevTools\Filesystem\FilesystemInterface; +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; use function array_key_first; use function array_values; @@ -143,8 +146,16 @@ private function resolveRelativeApiDirectory(string $path): ?string */ private function resolveHasPhpSourceFiles(array $apiDirectories): bool { - if ([] !== $apiDirectories) { - return true; + foreach ($apiDirectories as $path) { + $absolutePath = $this->filesystem->getAbsolutePath($path); + + if (! \is_string($absolutePath)) { + continue; + } + + if ($this->hasPhpSourceFileInDirectory($absolutePath)) { + return true; + } } foreach (self::API_AUTOLOAD_TYPES as $autoloadType) { @@ -155,7 +166,7 @@ private function resolveHasPhpSourceFiles(array $apiDirectories): bool continue; } - if (is_file($absolutePath) && str_ends_with(strtolower($absolutePath), '.php')) { + if ($this->hasPhpSourceFileInDirectory($absolutePath)) { return true; } } @@ -164,6 +175,38 @@ private function resolveHasPhpSourceFiles(array $apiDirectories): bool return false; } + /** + * Detects whether a Composer autoload path points to a PHP source file or contains one recursively. + * + * @param string $path an absolute composer autoload path + */ + private function hasPhpSourceFileInDirectory(string $path): bool + { + if (is_file($path)) { + return str_ends_with(strtolower($path), '.php'); + } + + if (! is_dir($path)) { + return false; + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $file) { + if (! $file->isFile()) { + continue; + } + + if (str_ends_with(strtolower((string) $file->getFilename()), '.php')) { + return true; + } + } + + return false; + } + /** * Flattens Composer autoload path definitions into a normalized list of non-empty paths. * diff --git a/tests/Composer/Capability/DevToolsCommandProviderTest.php b/tests/Composer/Capability/DevToolsCommandProviderTest.php index c7bf82a998..781484acb5 100644 --- a/tests/Composer/Capability/DevToolsCommandProviderTest.php +++ b/tests/Composer/Capability/DevToolsCommandProviderTest.php @@ -28,6 +28,7 @@ use FastForward\DevTools\Container\ContainerFactory; use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; use FastForward\DevTools\Path\DevToolsPathResolver; +use Symfony\Component\Console\Application; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; @@ -48,7 +49,7 @@ final class DevToolsCommandProviderTest extends TestCase private ObjectProphecy $plugin; /** - * @var ObjectProphecy + * @var ObjectProphecy */ private ObjectProphecy $devTools; @@ -66,7 +67,7 @@ protected function setUp(): void { ContainerFactory::reset(); $this->plugin = $this->prophesize(DevToolsPluginInterface::class); - $this->devTools = $this->prophesize(DevTools::class); + $this->devTools = $this->prophesize(Application::class); $this->plugin->isRegisteredCommand(null) ->willReturn(false); diff --git a/tests/Composer/PluginTest.php b/tests/Composer/PluginTest.php index 57aa37b487..29a075cefc 100644 --- a/tests/Composer/PluginTest.php +++ b/tests/Composer/PluginTest.php @@ -34,12 +34,6 @@ use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use function Safe\tempnam; -use function Safe\file_put_contents; -use function Safe\json_encode; -use function Safe\putenv; -use function Safe\unlink; - #[CoversClass(Plugin::class)] final class PluginTest extends TestCase { @@ -62,13 +56,6 @@ final class PluginTest extends TestCase */ private ObjectProphecy $rootPackage; - /** - * @return void - */ - private string $tempComposerFile; - - private string $originalComposerEnv; - /** * @return void */ @@ -82,32 +69,6 @@ protected function setUp(): void ->willReturn($this->rootPackage->reveal()); $this->rootPackage->getScripts() ->willReturn([]); - - $this->originalComposerEnv = (string) getenv('COMPOSER'); - $this->tempComposerFile = tempnam(sys_get_temp_dir(), 'composer_test'); - // O nome do pacote precisa ser fast-forward/dev-tools para que o método installScripts execute a lógica - file_put_contents($this->tempComposerFile, json_encode([ - 'name' => 'fast-forward/dev-tools', - 'scripts' => (object) [], - ])); - - putenv('COMPOSER=' . $this->tempComposerFile); - $_ENV['COMPOSER'] = $this->tempComposerFile; - $_SERVER['COMPOSER'] = $this->tempComposerFile; - } - - /** - * @return void - */ - protected function tearDown(): void - { - if (file_exists($this->tempComposerFile)) { - unlink($this->tempComposerFile); - } - - putenv('COMPOSER=' . $this->originalComposerEnv); - $_ENV['COMPOSER'] = $this->originalComposerEnv; - $_SERVER['COMPOSER'] = $this->originalComposerEnv; } /** diff --git a/tests/Config/ComposerDependencyAnalyserConfigTest.php b/tests/Config/ComposerDependencyAnalyserConfigTest.php index e2cbbaa1d0..1f7fc18ded 100644 --- a/tests/Config/ComposerDependencyAnalyserConfigTest.php +++ b/tests/Config/ComposerDependencyAnalyserConfigTest.php @@ -20,20 +20,24 @@ namespace FastForward\DevTools\Tests\Config; use FastForward\DevTools\Config\ComposerDependencyAnalyserConfig; +use FastForward\DevTools\Environment\Environment; use FastForward\DevTools\Path\DevToolsPathResolver; +use FastForward\DevTools\Environment\EnvironmentInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use ShipMonk\ComposerDependencyAnalyser\Config\Configuration; use ShipMonk\ComposerDependencyAnalyser\Config\ErrorType; -use function Safe\putenv; - #[CoversClass(ComposerDependencyAnalyserConfig::class)] +#[UsesClass(Environment::class)] #[UsesClass(DevToolsPathResolver::class)] final class ComposerDependencyAnalyserConfigTest extends TestCase { + use ProphecyTrait; + /** * @return void */ @@ -51,19 +55,12 @@ public function configureWillReturnConfiguration(): void #[Test] public function configureWillIgnoreShadowDependenciesByDefault(): void { - $originalValue = getenv(ComposerDependencyAnalyserConfig::ENV_SHOW_SHADOW_DEPENDENCIES); - - try { - putenv(ComposerDependencyAnalyserConfig::ENV_SHOW_SHADOW_DEPENDENCIES); - $configuration = ComposerDependencyAnalyserConfig::configure(); - - self::assertTrue( - $configuration->getIgnoreList() - ->shouldIgnoreError(ErrorType::SHADOW_DEPENDENCY, null, 'vendor/shadow-package') - ); - } finally { - $this->restoreShadowDependenciesEnvironment($originalValue); - } + $configuration = ComposerDependencyAnalyserConfig::configure(environment: $this->createEnvironment()); + + self::assertTrue( + $configuration->getIgnoreList() + ->shouldIgnoreError(ErrorType::SHADOW_DEPENDENCY, null, 'vendor/shadow-package') + ); } /** @@ -72,19 +69,12 @@ public function configureWillIgnoreShadowDependenciesByDefault(): void #[Test] public function configureWillKeepShadowDependenciesVisibleWhenRequested(): void { - $originalValue = getenv(ComposerDependencyAnalyserConfig::ENV_SHOW_SHADOW_DEPENDENCIES); - - try { - putenv(ComposerDependencyAnalyserConfig::ENV_SHOW_SHADOW_DEPENDENCIES . '=1'); - $configuration = ComposerDependencyAnalyserConfig::configure(); - - self::assertFalse( - $configuration->getIgnoreList() - ->shouldIgnoreError(ErrorType::SHADOW_DEPENDENCY, null, 'vendor/shadow-package') - ); - } finally { - $this->restoreShadowDependenciesEnvironment($originalValue); - } + $configuration = ComposerDependencyAnalyserConfig::configure(environment: $this->createEnvironment('1')); + + self::assertFalse( + $configuration->getIgnoreList() + ->shouldIgnoreError(ErrorType::SHADOW_DEPENDENCY, null, 'vendor/shadow-package') + ); } /** @@ -170,18 +160,17 @@ public function applyIgnoresShadowDependenciesWillReturnTheSameConfigurationInst } /** - * @param false|string $value + * @param string|null $value * - * @return void + * @return EnvironmentInterface */ - private function restoreShadowDependenciesEnvironment(false|string $value): void + private function createEnvironment(?string $value = null): EnvironmentInterface { - if (false === $value) { - putenv(ComposerDependencyAnalyserConfig::ENV_SHOW_SHADOW_DEPENDENCIES); + $environment = $this->prophesize(EnvironmentInterface::class); - return; - } + $environment->get(ComposerDependencyAnalyserConfig::ENV_SHOW_SHADOW_DEPENDENCIES) + ->willReturn($value); - putenv(ComposerDependencyAnalyserConfig::ENV_SHOW_SHADOW_DEPENDENCIES . '=' . $value); + return $environment->reveal(); } } diff --git a/tests/Config/ECSConfigTest.php b/tests/Config/ECSConfigTest.php index 6e6153a53e..8646f6254d 100644 --- a/tests/Config/ECSConfigTest.php +++ b/tests/Config/ECSConfigTest.php @@ -20,6 +20,7 @@ namespace FastForward\DevTools\Tests\Config; use FastForward\DevTools\Config\ECSConfig; +use FastForward\DevTools\Environment\Environment; use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Path\WorkingProjectPathResolver; use PhpCsFixer\Fixer\Import\GlobalNamespaceImportFixer; @@ -37,6 +38,7 @@ use function Safe\getcwd; #[CoversClass(ECSConfig::class)] +#[UsesClass(Environment::class)] #[UsesClass(ManagedWorkspace::class)] #[UsesClass(WorkingProjectPathResolver::class)] final class ECSConfigTest extends TestCase diff --git a/tests/Config/RectorConfigTest.php b/tests/Config/RectorConfigTest.php index 536fd4715c..43a49bef70 100644 --- a/tests/Config/RectorConfigTest.php +++ b/tests/Config/RectorConfigTest.php @@ -25,6 +25,7 @@ use Rector\DeadCode\Rector\ClassMethod\RemoveUselessReturnTagRector; use Rector\DeadCode\Rector\ClassMethod\RemoveUselessParamTagRector; use FastForward\DevTools\Rector\AddMissingMethodPhpDocRector; +use FastForward\DevTools\Environment\Environment; use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Path\WorkingProjectPathResolver; use ReflectionProperty; @@ -40,6 +41,7 @@ use function Safe\getcwd; #[CoversClass(RectorConfig::class)] +#[UsesClass(Environment::class)] #[UsesClass(ManagedWorkspace::class)] #[UsesClass(WorkingProjectPathResolver::class)] final class RectorConfigTest extends TestCase diff --git a/tests/Console/Command/ReportsCommandTest.php b/tests/Console/Command/ReportsCommandTest.php index 70046f9dc4..2384798a1d 100644 --- a/tests/Console/Command/ReportsCommandTest.php +++ b/tests/Console/Command/ReportsCommandTest.php @@ -22,6 +22,7 @@ use Symfony\Component\Console\Output\BufferedOutput; use FastForward\DevTools\Console\Command\Traits\LogsCommandResults; use FastForward\DevTools\Console\Command\ReportsCommand; +use FastForward\DevTools\Console\Output\GithubActionOutput; use FastForward\DevTools\Process\ProcessBuilderInterface; use FastForward\DevTools\Process\ProcessQueueInterface; use FastForward\DevTools\Path\ManagedWorkspace; @@ -31,6 +32,7 @@ use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; use FastForward\DevTools\Environment\RuntimeEnvironment; use FastForward\DevTools\Project\ProjectCapabilities; +use FastForward\DevTools\Project\ProjectCapabilitiesResolver; use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -53,11 +55,12 @@ #[UsesClass(DevToolsEnvironment::class)] #[UsesClass(RuntimeEnvironment::class)] #[UsesClass(ProjectCapabilities::class)] -#[UsesClass(ProjectCapabilitiesResolverInterface::class)] +#[UsesClass(ProjectCapabilitiesResolver::class)] #[CoversClass(ReportsCommand::class)] #[UsesClass(ManagedWorkspace::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesTrait(LogsCommandResults::class)] +#[UsesClass(GithubActionOutput::class)] final class ReportsCommandTest extends TestCase { use ProphecyTrait; @@ -304,13 +307,10 @@ private function executeCommand(): int */ private function createProjectCapabilities(bool $canRunTests): ProjectCapabilities { - return new ProjectCapabilities( - apiDirectories: [], - defaultPackageName: null, - hasGuideDirectory: false, - hasTestsPath: $canRunTests, - hasWikiTarget: false, - hasPhpSourceFiles: $canRunTests, - ); + $projectCapabilities = $this->prophesize(ProjectCapabilities::class); + $projectCapabilities->canRunTests() + ->willReturn($canRunTests); + + return $projectCapabilities->reveal(); } } diff --git a/tests/Console/Command/TestsCommandTest.php b/tests/Console/Command/TestsCommandTest.php index 758543a4bb..46e90bb871 100644 --- a/tests/Console/Command/TestsCommandTest.php +++ b/tests/Console/Command/TestsCommandTest.php @@ -24,6 +24,7 @@ use FastForward\DevTools\Console\Command\TestsCommand; use FastForward\DevTools\Container\ContainerFactory; use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\EnvironmentInterface; use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; use FastForward\DevTools\Filesystem\FilesystemInterface; use FastForward\DevTools\PhpUnit\Bootstrap\BootstrapShimGenerator; @@ -54,8 +55,9 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; +use function Safe\mkdir; +use function Safe\rmdir; use function Safe\getcwd; -use function Safe\putenv; #[UsesClass(DevToolsEnvironment::class)] #[UsesClass(RuntimeEnvironment::class)] @@ -74,6 +76,12 @@ final class TestsCommandTest extends TestCase { use ProphecyTrait; + private const string AGENT_ENVIRONMENT_VARIABLE = TestsCommand::AGENT_ENVIRONMENT_VARIABLE; + + private const string AGENT_ENVIRONMENT_VALUE = TestsCommand::AGENT_ENVIRONMENT_VALUE; + + private const string ENV_MINIMUM_COVERAGE = TestsCommand::ENV_MINIMUM_COVERAGE; + private ObjectProphecy $coverageSummaryLoader; private ObjectProphecy $composerJson; @@ -84,6 +92,8 @@ final class TestsCommandTest extends TestCase private ObjectProphecy $processQueue; + private ObjectProphecy $environment; + private ObjectProphecy $projectCapabilitiesResolver; private ObjectProphecy $runtimeEnvironment; @@ -96,20 +106,18 @@ final class TestsCommandTest extends TestCase private TestsCommand $command; - private string|false $agentEnvironment; - /** * @return void */ protected function setUp(): void { ContainerFactory::reset(); - $this->agentEnvironment = getenv('AI_AGENT'); $this->coverageSummaryLoader = $this->prophesize(CoverageSummaryLoaderInterface::class); $this->composerJson = $this->prophesize(ComposerJsonInterface::class); $this->filesystem = $this->prophesize(FilesystemInterface::class); $this->fileLocator = $this->prophesize(FileLocatorInterface::class); $this->processQueue = $this->prophesize(ProcessQueueInterface::class); + $this->environment = $this->prophesize(EnvironmentInterface::class); $this->projectCapabilitiesResolver = $this->prophesize(ProjectCapabilitiesResolverInterface::class); $this->runtimeEnvironment = $this->prophesize(RuntimeEnvironmentInterface::class); $this->logger = $this->prophesize(LoggerInterface::class); @@ -125,6 +133,7 @@ protected function setUp(): void new ProcessBuilder(), $this->processQueue->reveal(), $this->projectCapabilitiesResolver->reveal(), + $this->environment->reveal(), ); $this->composerJson->getAutoload('psr-4') @@ -140,10 +149,15 @@ protected function setUp(): void false, true, )); + $this->runtimeEnvironment->isAgentPresent() ->willReturn(false); $this->runtimeEnvironment->isComposerTestRun() ->willReturn(true); + $this->environment->get(self::AGENT_ENVIRONMENT_VARIABLE) + ->willReturn(null); + $this->environment->get(self::ENV_MINIMUM_COVERAGE) + ->willReturn(null); ContainerFactory::set(RuntimeEnvironmentInterface::class, $this->runtimeEnvironment->reveal()); ContainerFactory::set(LoggerInterface::class, $this->logger->reveal()); $this->fileLocator->locate(TestsCommand::CONFIG)->willReturn(getcwd() . '/' . TestsCommand::CONFIG); @@ -178,14 +192,6 @@ protected function setUp(): void protected function tearDown(): void { ContainerFactory::reset(); - - if (false === $this->agentEnvironment) { - putenv('AI_AGENT'); - - return; - } - - putenv('AI_AGENT=' . $this->agentEnvironment); } /** @@ -338,7 +344,8 @@ public function executeWillCaptureStructuredPhpUnitSummaryWhenAgentEnvironmentIs #[Test] public function executeWillPreserveAnInheritedAgentEnvironmentWhenForcingStructuredPhpUnitOutput(): void { - putenv('AI_AGENT=existing-agent'); + $this->environment->get(self::AGENT_ENVIRONMENT_VARIABLE) + ->willReturn('existing-agent'); $this->input->getOption('json') ->willReturn(true); @@ -346,7 +353,10 @@ public function executeWillPreserveAnInheritedAgentEnvironmentWhenForcingStructu ->willReturn(false); $this->processQueue->add( - Argument::that(static fn(Process $process): bool => ! \array_key_exists('AI_AGENT', $process->getEnv())), + Argument::that(static fn(Process $process): bool => ! \array_key_exists( + self::AGENT_ENVIRONMENT_VARIABLE, + $process->getEnv(), + )), false, false, 'Running PHPUnit Tests' @@ -578,7 +588,7 @@ public function executeWillSkipWhenNoTestsDirectoryOrPhpSourceExists(): void ))->shouldBeCalled(); $this->logger->log( 'warning', - 'Skipping PHPUnit tests because no tests directory or PHP source files were detected.', + 'Skipping PHPUnit tests because no Composer-autoloaded PHP source files were detected.', Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface && $context['output'] instanceof OutputInterface), )->shouldBeCalled(); @@ -586,6 +596,102 @@ public function executeWillSkipWhenNoTestsDirectoryOrPhpSourceExists(): void self::assertSame(TestsCommand::SUCCESS, $this->invokeExecute()); } + /** + * @return void + */ + #[Test] + public function executeWillSkipWhenTestsDirectoryExistsButNoPhpSourceFilesAreDetected(): void + { + $testsDirectory = getcwd() . '/.dev-tools-empty-tests-' . uniqid('', true); + mkdir($testsDirectory); + + try { + $this->input->getArgument('path') + ->willReturn($testsDirectory); + $this->projectCapabilitiesResolver->resolve(Argument::any()) + ->willReturn(new ProjectCapabilities([], null, false, true, false, false)); + $this->processQueue->add(Argument::cetera())->shouldNotBeCalled(); + $this->processQueue->run(Argument::cetera())->shouldNotBeCalled(); + $this->logger->log('info', 'Running PHPUnit tests...', Argument::that( + static fn(array $context): bool => $context['input'] instanceof InputInterface + ))->shouldBeCalled(); + $this->logger->log( + 'warning', + 'Skipping PHPUnit tests because no Composer-autoloaded PHP source files were detected.', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && $context['output'] instanceof OutputInterface), + )->shouldBeCalled(); + + self::assertSame(TestsCommand::SUCCESS, $this->invokeExecute()); + } finally { + rmdir($testsDirectory); + } + } + + /** + * @return void + */ + #[Test] + public function executeWillUseMinimumCoverageFromEnvironmentWhenNotProvided(): void + { + $coverageReportPath = getcwd() . '/.dev-tools/cache/phpunit/coverage.php'; + + $this->environment->get(self::ENV_MINIMUM_COVERAGE) + ->willReturn('80'); + $this->coverageSummaryLoader->load($coverageReportPath) + ->willReturn(new CoverageSummary(75, 100)); + $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->log('info', 'Running PHPUnit tests...', Argument::that( + static fn(array $context): bool => $context['input'] instanceof InputInterface + )) + ->shouldBeCalled(); + $this->logger->error( + 'Minimum line coverage of 80.00% was not met. Current coverage: 75.00% (75/100 lines).', + Argument::that(static fn(array $context): bool => $context['input'] instanceof InputInterface + && $context['output'] instanceof OutputInterface + && 75.0 === $context['line_coverage'] + && 75 === $context['covered_lines'] + && 100 === $context['total_lines']), + )->shouldBeCalled(); + + self::assertSame(TestsCommand::FAILURE, $this->invokeExecute()); + } + + /** + * @return void + */ + #[Test] + public function executeWillIgnoreBlankMinimumCoverageEnvironmentValue(): void + { + $this->environment->get(self::ENV_MINIMUM_COVERAGE) + ->willReturn(' '); + $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->log('info', 'Running PHPUnit tests...', Argument::that( + static fn(array $context): bool => $context['input'] instanceof InputInterface + )) + ->shouldBeCalled(); + $this->logger->log('info', 'PHPUnit tests completed successfully.', Argument::that( + static fn(array $context): bool => $context['input'] instanceof InputInterface + && $context['output'] instanceof OutputInterface, + ))->shouldBeCalled(); + + self::assertSame(TestsCommand::SUCCESS, $this->invokeExecute()); + } + /** * @return void */ @@ -758,10 +864,10 @@ private function usesStructuredPhpUnitExecution(Process $process): bool $processEnvironment = $process->getEnv(); - if (\array_key_exists('AI_AGENT', $processEnvironment)) { - return 'fast-forward/dev-tools' === $processEnvironment['AI_AGENT']; + if (\array_key_exists(self::AGENT_ENVIRONMENT_VARIABLE, $processEnvironment)) { + return self::AGENT_ENVIRONMENT_VALUE === $processEnvironment[self::AGENT_ENVIRONMENT_VARIABLE]; } - return false !== getenv('AI_AGENT'); + return self::AGENT_ENVIRONMENT_VALUE === $this->environment->get(self::AGENT_ENVIRONMENT_VARIABLE); } } diff --git a/tests/Console/DevToolsTest.php b/tests/Console/DevToolsTest.php index 1116e92315..f6f9db322b 100644 --- a/tests/Console/DevToolsTest.php +++ b/tests/Console/DevToolsTest.php @@ -26,6 +26,7 @@ use FastForward\DevTools\Console\Output\GithubActionOutput; use FastForward\DevTools\Container\ContainerFactory; use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; +use FastForward\DevTools\Environment\Environment; use FastForward\DevTools\Environment\EnvironmentInterface; use FastForward\DevTools\Environment\RuntimeEnvironment; use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; @@ -71,8 +72,6 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\BufferedOutput; -use function Safe\putenv; - #[CoversClass(DevTools::class)] #[UsesClass(DevToolsPathResolver::class)] #[UsesClass(ManagedWorkspace::class)] @@ -96,10 +95,13 @@ #[UsesClass(ComposerVersionChecker::class)] #[UsesClass(VersionCheckNotifier::class)] #[UsesClass(WorkingDirectorySwitcher::class)] +#[UsesClass(Environment::class)] final class DevToolsTest extends TestCase { use ProphecyTrait; + private const string AUTO_UPDATE_ENVIRONMENT_VARIABLE = DevTools::ENV_AUTO_UPDATE; + /** * @var ObjectProphecy */ @@ -142,7 +144,15 @@ final class DevToolsTest extends TestCase private DevTools $devTools; - private string|false $originalWorkspaceDirectoryEnv; + /** + * @var array + */ + private array $originalServerEnv; + + /** + * @var array + */ + private array $originalEnv; /** * @return void @@ -167,7 +177,8 @@ protected function setUp(): void ->willReturn('1.2.3'); $this->runtimeEnvironment->isAgentPresent() ->willReturn(false); - $this->originalWorkspaceDirectoryEnv = getenv(ManagedWorkspace::ENV_WORKSPACE_DIR); + $this->originalServerEnv = $_SERVER; + $this->originalEnv = $_ENV; $this->devTools = $this->createDevTools(); } @@ -178,13 +189,8 @@ protected function setUp(): void protected function tearDown(): void { ContainerFactory::reset(); - if (false === $this->originalWorkspaceDirectoryEnv) { - putenv(ManagedWorkspace::ENV_WORKSPACE_DIR); - - return; - } - - putenv(ManagedWorkspace::ENV_WORKSPACE_DIR . '=' . $this->originalWorkspaceDirectoryEnv); + $_SERVER = $this->originalServerEnv; + $_ENV = $this->originalEnv; } /** @@ -257,7 +263,7 @@ public function doRunWillRenderLogoUnlessNoLogoOptionIsProvided(): void $output = new BufferedOutput(); - $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + $this->environment->get(self::AUTO_UPDATE_ENVIRONMENT_VARIABLE, '') ->willReturn(''); $this->workingDirectorySwitcher->switchTo(null) ->shouldBeCalledOnce(); @@ -283,7 +289,7 @@ public function doRunWillNotRenderLogoWhenNoLogoOptionIsSet(): void $output = new BufferedOutput(); - $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + $this->environment->get(self::AUTO_UPDATE_ENVIRONMENT_VARIABLE, '') ->willReturn(''); $this->workingDirectorySwitcher->switchTo(null) ->shouldBeCalledOnce(); @@ -309,7 +315,7 @@ public function doRunWillNotRenderLogoWhenRunningInsideKnownAgentEnvironment(): $this->runtimeEnvironment->isAgentPresent() ->willReturn(true); - $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + $this->environment->get(self::AUTO_UPDATE_ENVIRONMENT_VARIABLE, '') ->willReturn(''); $this->workingDirectorySwitcher->switchTo(null) ->shouldBeCalledOnce(); @@ -361,7 +367,7 @@ protected function configure(): void $output = new BufferedOutput(); - $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + $this->environment->get(self::AUTO_UPDATE_ENVIRONMENT_VARIABLE, '') ->willReturn(''); $this->workingDirectorySwitcher->switchTo(null) ->shouldBeCalledOnce(); @@ -413,7 +419,7 @@ protected function configure(): void $output = new BufferedOutput(); - $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + $this->environment->get(self::AUTO_UPDATE_ENVIRONMENT_VARIABLE, '') ->willReturn(''); $this->workingDirectorySwitcher->switchTo(null) ->shouldBeCalledOnce(); @@ -457,7 +463,7 @@ public function __construct() $output = new BufferedOutput(); - $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + $this->environment->get(self::AUTO_UPDATE_ENVIRONMENT_VARIABLE, '') ->willReturn(''); $this->workingDirectorySwitcher->switchTo(null) ->shouldBeCalledOnce(); @@ -564,7 +570,7 @@ public function runAutoUpdateWhenRequestedWillUpdateGlobalInstallationWhenCurren $output = $this->prophesize(OutputInterface::class); $input->hasParameterOption('--auto-update', true) ->willReturn(true); - $this->environment->get('FAST_FORWARD_AUTO_UPDATE', '') + $this->environment->get(self::AUTO_UPDATE_ENVIRONMENT_VARIABLE, '') ->willReturn(''); $this->selfUpdateScopeResolver->isGlobalInstallation() ->willReturn(true); diff --git a/tests/Console/Input/HasJsonOptionTest.php b/tests/Console/Input/HasJsonOptionTest.php index 2b09fce6cf..d31911178d 100644 --- a/tests/Console/Input/HasJsonOptionTest.php +++ b/tests/Console/Input/HasJsonOptionTest.php @@ -20,76 +20,18 @@ namespace FastForward\DevTools\Tests\Console\Input; use FastForward\DevTools\Console\Input\HasJsonOption; -use FastForward\DevTools\Container\ContainerFactory; -use FastForward\DevTools\Container\ServiceProvider\DevToolsServiceProvider; -use FastForward\DevTools\Environment\Environment as DevToolsEnvironment; -use FastForward\DevTools\Environment\RuntimeEnvironment; use FastForward\DevTools\Environment\RuntimeEnvironmentInterface; -use FastForward\DevTools\Path\DevToolsPathResolver; use PHPUnit\Framework\Attributes\CoversTrait; use PHPUnit\Framework\Attributes\Test; -use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Console\Input\InputInterface; -use function Safe\putenv; - #[CoversTrait(HasJsonOption::class)] -#[UsesClass(ContainerFactory::class)] -#[UsesClass(DevToolsPathResolver::class)] -#[UsesClass(DevToolsServiceProvider::class)] -#[UsesClass(DevToolsEnvironment::class)] -#[UsesClass(RuntimeEnvironment::class)] final class HasJsonOptionTest extends TestCase { use ProphecyTrait; - /** - * @var array - */ - private array $server; - - /** - * @var array - */ - private array $environment; - - private string|false $composerTestsAreRunning; - - /** - * @return void - */ - protected function setUp(): void - { - ContainerFactory::reset(); - $this->server = $_SERVER; - $this->environment = $_ENV; - $this->composerTestsAreRunning = getenv('COMPOSER_TESTS_ARE_RUNNING'); - - $_SERVER = []; - $_ENV = []; - putenv('COMPOSER_TESTS_ARE_RUNNING'); - } - - /** - * @return void - */ - protected function tearDown(): void - { - ContainerFactory::reset(); - $_SERVER = $this->server; - $_ENV = $this->environment; - - if (false === $this->composerTestsAreRunning) { - putenv('COMPOSER_TESTS_ARE_RUNNING'); - - return; - } - - putenv('COMPOSER_TESTS_ARE_RUNNING=' . $this->composerTestsAreRunning); - } - /** * @return void */ @@ -108,26 +50,7 @@ public function isJsonOutputWillUseRuntimeEnvironmentWhenAvailable(): void $input->getOption('json') ->willReturn(false); - $command = new readonly class ($runtimeEnvironment->reveal()) { - use HasJsonOption; - - /** - * @param RuntimeEnvironmentInterface $runtimeEnvironment - */ - public function __construct( - private RuntimeEnvironmentInterface $runtimeEnvironment, - ) {} - - /** - * @param InputInterface $input - * - * @return bool - */ - public function isStructured(InputInterface $input): bool - { - return $this->isJsonOutput($input); - } - }; + $command = new HasJsonOptionAwareCommand($runtimeEnvironment->reveal()); self::assertTrue($command->isStructured($input->reveal())); } @@ -136,9 +59,13 @@ public function isStructured(InputInterface $input): bool * @return void */ #[Test] - public function isJsonOutputWillIgnoreFallbackAgentDetectionDuringPhpUnitRuns(): void + public function isJsonOutputWillIgnoreAgentOutputDuringComposerRuns(): void { - $_SERVER['CODEX_CI'] = '1'; + $runtimeEnvironment = $this->prophesize(RuntimeEnvironmentInterface::class); + $runtimeEnvironment->isAgentPresent() + ->willReturn(true); + $runtimeEnvironment->isComposerTestRun() + ->willReturn(true); $input = $this->prophesize(InputInterface::class); $input->getOption('pretty-json') @@ -146,20 +73,33 @@ public function isJsonOutputWillIgnoreFallbackAgentDetectionDuringPhpUnitRuns(): $input->getOption('json') ->willReturn(false); - $command = new class { - use HasJsonOption; - - /** - * @param InputInterface $input - * - * @return bool - */ - public function isStructured(InputInterface $input): bool - { - return $this->isJsonOutput($input); - } - }; + $command = new HasJsonOptionAwareCommand($runtimeEnvironment->reveal()); self::assertFalse($command->isStructured($input->reveal())); } } + +/** + * @internal + */ +final readonly class HasJsonOptionAwareCommand +{ + use HasJsonOption; + + /** + * @param RuntimeEnvironmentInterface $runtimeEnvironment + */ + public function __construct( + private RuntimeEnvironmentInterface $runtimeEnvironment, + ) {} + + /** + * @param InputInterface $input + * + * @return bool + */ + public function isStructured(InputInterface $input): bool + { + return $this->isJsonOutput($input); + } +} diff --git a/tests/Environment/EnvironmentTest.php b/tests/Environment/EnvironmentTest.php index 6efc644417..51179c1f21 100644 --- a/tests/Environment/EnvironmentTest.php +++ b/tests/Environment/EnvironmentTest.php @@ -29,9 +29,19 @@ #[CoversClass(Environment::class)] final class EnvironmentTest extends TestCase { + private const string ENV_READER_TEST = 'DEV_TOOLS_ENVIRONMENT_READER_TEST'; + private Environment $environment; - private string|false $previousValue; + /** + * @var array + */ + private array $originalServerEnv; + + /** + * @var array + */ + private array $originalEnv; /** * @return void @@ -39,8 +49,12 @@ final class EnvironmentTest extends TestCase protected function setUp(): void { $this->environment = new Environment(); - $this->previousValue = getenv('DEV_TOOLS_ENVIRONMENT_READER_TEST'); - putenv('DEV_TOOLS_ENVIRONMENT_READER_TEST'); + $this->originalServerEnv = $_SERVER; + $this->originalEnv = $_ENV; + + unset($_SERVER[self::ENV_READER_TEST]); + unset($_ENV[self::ENV_READER_TEST]); + putenv(self::ENV_READER_TEST); } /** @@ -49,7 +63,7 @@ protected function setUp(): void #[Test] public function getReturnsNullForMissingEnvironmentVariable(): void { - self::assertNull($this->environment->get('DEV_TOOLS_ENVIRONMENT_READER_TEST')); + self::assertNull($this->environment->get(self::ENV_READER_TEST)); } /** @@ -58,7 +72,7 @@ public function getReturnsNullForMissingEnvironmentVariable(): void #[Test] public function getReturnsDefaultForMissingEnvironmentVariable(): void { - self::assertSame('fallback', $this->environment->get('DEV_TOOLS_ENVIRONMENT_READER_TEST', 'fallback')); + self::assertSame('fallback', $this->environment->get(self::ENV_READER_TEST, 'fallback')); } /** @@ -67,9 +81,11 @@ public function getReturnsDefaultForMissingEnvironmentVariable(): void #[Test] public function getReturnsEnvironmentVariableValue(): void { - putenv('DEV_TOOLS_ENVIRONMENT_READER_TEST=enabled'); + putenv(self::ENV_READER_TEST . '=enabled'); + $_ENV[self::ENV_READER_TEST] = 'enabled'; + $_SERVER[self::ENV_READER_TEST] = 'enabled'; - self::assertSame('enabled', $this->environment->get('DEV_TOOLS_ENVIRONMENT_READER_TEST')); + self::assertSame('enabled', $this->environment->get(self::ENV_READER_TEST)); } /** @@ -78,12 +94,14 @@ public function getReturnsEnvironmentVariableValue(): void #[Test] public function getWithoutNameReturnsCurrentEnvironmentMap(): void { - putenv('DEV_TOOLS_ENVIRONMENT_READER_TEST=enabled'); + putenv(self::ENV_READER_TEST . '=enabled'); + $_ENV[self::ENV_READER_TEST] = 'enabled'; + $_SERVER[self::ENV_READER_TEST] = 'enabled'; $environment = $this->environment->get(); self::assertIsArray($environment); - self::assertSame('enabled', $environment['DEV_TOOLS_ENVIRONMENT_READER_TEST']); + self::assertSame('enabled', $environment[self::ENV_READER_TEST]); } /** @@ -91,12 +109,13 @@ public function getWithoutNameReturnsCurrentEnvironmentMap(): void */ protected function tearDown(): void { - if (false === $this->previousValue) { - putenv('DEV_TOOLS_ENVIRONMENT_READER_TEST'); - - return; + if (\array_key_exists(self::ENV_READER_TEST, $this->originalEnv)) { + putenv(self::ENV_READER_TEST . '=' . $this->originalEnv[self::ENV_READER_TEST]); + } else { + putenv(self::ENV_READER_TEST); } - putenv('DEV_TOOLS_ENVIRONMENT_READER_TEST=' . $this->previousValue); + $_SERVER = $this->originalServerEnv; + $_ENV = $this->originalEnv; } } diff --git a/tests/Path/ManagedWorkspaceTest.php b/tests/Path/ManagedWorkspaceTest.php index ab50a6763c..a368ce4a11 100644 --- a/tests/Path/ManagedWorkspaceTest.php +++ b/tests/Path/ManagedWorkspaceTest.php @@ -19,23 +19,17 @@ namespace FastForward\DevTools\Tests\Path; +use FastForward\DevTools\Environment\EnvironmentInterface; use FastForward\DevTools\Path\ManagedWorkspace; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; - -use function Safe\putenv; +use Prophecy\PhpUnit\ProphecyTrait; #[CoversClass(ManagedWorkspace::class)] final class ManagedWorkspaceTest extends TestCase { - /** - * @return void - */ - protected function tearDown(): void - { - putenv(ManagedWorkspace::ENV_WORKSPACE_DIR); - } + use ProphecyTrait; /** * @return void @@ -43,24 +37,41 @@ protected function tearDown(): void #[Test] public function itWillExposeCanonicalRepositoryManagedPaths(): void { - self::assertSame('.dev-tools', ManagedWorkspace::getOutputDirectory()); - self::assertSame('.dev-tools/coverage', ManagedWorkspace::getOutputDirectory(ManagedWorkspace::COVERAGE)); - self::assertSame('.dev-tools/metrics', ManagedWorkspace::getOutputDirectory(ManagedWorkspace::METRICS)); + $environment = $this->createEnvironment(); + + self::assertSame('.dev-tools', ManagedWorkspace::getOutputDirectory(environment: $environment)); + self::assertSame( + '.dev-tools/coverage', + ManagedWorkspace::getOutputDirectory(ManagedWorkspace::COVERAGE, environment: $environment), + ); + self::assertSame( + '.dev-tools/metrics', + ManagedWorkspace::getOutputDirectory(ManagedWorkspace::METRICS, environment: $environment), + ); self::assertSame( 'tmp/.dev-tools/metrics', - ManagedWorkspace::getOutputDirectory(ManagedWorkspace::METRICS, 'tmp') + ManagedWorkspace::getOutputDirectory(ManagedWorkspace::METRICS, 'tmp', $environment) + ); + self::assertSame('.dev-tools/cache', ManagedWorkspace::getCacheDirectory(environment: $environment)); + self::assertSame( + '.dev-tools/cache/phpdoc', + ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPDOC, environment: $environment) + ); + self::assertSame( + '.dev-tools/cache/phpunit', + ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPUNIT, environment: $environment) + ); + self::assertSame( + '.dev-tools/cache/rector', + ManagedWorkspace::getCacheDirectory(ManagedWorkspace::RECTOR, environment: $environment) ); - self::assertSame('.dev-tools/cache', ManagedWorkspace::getCacheDirectory()); - self::assertSame('.dev-tools/cache/phpdoc', ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPDOC)); - self::assertSame('.dev-tools/cache/phpunit', ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPUNIT)); - self::assertSame('.dev-tools/cache/rector', ManagedWorkspace::getCacheDirectory(ManagedWorkspace::RECTOR)); self::assertSame( '.dev-tools/cache/php-cs-fixer', - ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHP_CS_FIXER) + ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHP_CS_FIXER, environment: $environment) ); self::assertSame( 'tmp/.dev-tools/cache/rector', - ManagedWorkspace::getCacheDirectory(ManagedWorkspace::RECTOR, 'tmp') + ManagedWorkspace::getCacheDirectory(ManagedWorkspace::RECTOR, 'tmp', $environment) ); } @@ -70,8 +81,16 @@ public function itWillExposeCanonicalRepositoryManagedPaths(): void #[Test] public function itWillNormalizePathSeparatorsWhenJoiningManagedPaths(): void { - self::assertSame('tmp/.dev-tools/metrics', ManagedWorkspace::getOutputDirectory('/metrics', 'tmp/')); - self::assertSame('tmp/.dev-tools/cache/phpunit', ManagedWorkspace::getCacheDirectory('/phpunit', 'tmp/')); + $environment = $this->createEnvironment(); + + self::assertSame( + 'tmp/.dev-tools/metrics', + ManagedWorkspace::getOutputDirectory('/metrics', 'tmp/', $environment) + ); + self::assertSame( + 'tmp/.dev-tools/cache/phpunit', + ManagedWorkspace::getCacheDirectory('/phpunit', 'tmp/', $environment), + ); } /** @@ -80,13 +99,16 @@ public function itWillNormalizePathSeparatorsWhenJoiningManagedPaths(): void #[Test] public function itWillUseConfiguredRelativeWorkspaceRoot(): void { - putenv(ManagedWorkspace::ENV_WORKSPACE_DIR . '=.artifacts'); + $environment = $this->createEnvironment('.artifacts'); - self::assertSame('.artifacts', ManagedWorkspace::getWorkspaceRoot()); - self::assertSame('.artifacts/coverage', ManagedWorkspace::getOutputDirectory(ManagedWorkspace::COVERAGE)); + self::assertSame('.artifacts', ManagedWorkspace::getWorkspaceRoot(environment: $environment)); + self::assertSame( + '.artifacts/coverage', + ManagedWorkspace::getOutputDirectory(ManagedWorkspace::COVERAGE, '', $environment), + ); self::assertSame( 'tmp/.artifacts/cache/phpunit', - ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPUNIT, 'tmp') + ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPUNIT, 'tmp', $environment), ); } @@ -96,16 +118,31 @@ public function itWillUseConfiguredRelativeWorkspaceRoot(): void #[Test] public function itWillUseConfiguredAbsoluteWorkspaceRoot(): void { - putenv(ManagedWorkspace::ENV_WORKSPACE_DIR . '=/tmp/dev-tools-artifacts'); + $environment = $this->createEnvironment('/tmp/dev-tools-artifacts'); - self::assertSame('/tmp/dev-tools-artifacts', ManagedWorkspace::getWorkspaceRoot()); + self::assertSame('/tmp/dev-tools-artifacts', ManagedWorkspace::getWorkspaceRoot(environment: $environment)); self::assertSame( '/tmp/dev-tools-artifacts/metrics', - ManagedWorkspace::getOutputDirectory(ManagedWorkspace::METRICS, 'tmp') + ManagedWorkspace::getOutputDirectory(ManagedWorkspace::METRICS, 'tmp', $environment), ); self::assertSame( '/tmp/dev-tools-artifacts/cache/rector', - ManagedWorkspace::getCacheDirectory(ManagedWorkspace::RECTOR, 'tmp') + ManagedWorkspace::getCacheDirectory(ManagedWorkspace::RECTOR, 'tmp', $environment), ); } + + /** + * @param string|null $value + * + * @return EnvironmentInterface + */ + private function createEnvironment(?string $value = null): EnvironmentInterface + { + $environment = $this->prophesize(EnvironmentInterface::class); + + $environment->get(ManagedWorkspace::ENV_WORKSPACE_DIR) + ->willReturn($value); + + return $environment->reveal(); + } } diff --git a/tests/Path/WorkingProjectPathResolverTest.php b/tests/Path/WorkingProjectPathResolverTest.php index 8596b19e79..6879c15f63 100644 --- a/tests/Path/WorkingProjectPathResolverTest.php +++ b/tests/Path/WorkingProjectPathResolverTest.php @@ -19,6 +19,7 @@ namespace FastForward\DevTools\Tests\Path; +use FastForward\DevTools\Environment\EnvironmentInterface; use FastForward\DevTools\Path\ManagedWorkspace; use FastForward\DevTools\Path\WorkingProjectPathResolver; use FastForward\DevTools\Console\Output\GithubActionOutput; @@ -26,6 +27,7 @@ use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; use function Safe\scandir; use function Safe\rmdir; @@ -33,7 +35,6 @@ use function Safe\file_put_contents; use function Safe\getcwd; use function Safe\mkdir; -use function Safe\putenv; use function Safe\realpath; use function uniqid; @@ -42,13 +43,7 @@ #[UsesClass(ManagedWorkspace::class)] final class WorkingProjectPathResolverTest extends TestCase { - /** - * @return void - */ - protected function tearDown(): void - { - putenv(ManagedWorkspace::ENV_WORKSPACE_DIR); - } + use ProphecyTrait; /** * @return void @@ -56,6 +51,8 @@ protected function tearDown(): void #[Test] public function itWillExposeCanonicalRepositoryRootPaths(): void { + $environment = $this->createEnvironment(); + self::assertSame( [ 'repo/.dev-tools', @@ -70,7 +67,7 @@ public function itWillExposeCanonicalRepositoryRootPaths(): void 'repo/**/vendor', 'repo/**/vendor/*', ], - WorkingProjectPathResolver::getToolingExcludedDirectories('repo') + WorkingProjectPathResolver::getToolingExcludedDirectories('repo', $environment), ); } @@ -80,7 +77,7 @@ public function itWillExposeCanonicalRepositoryRootPaths(): void #[Test] public function itWillIncludeCustomRelativeWorkspaceInToolingSkipPatterns(): void { - putenv(ManagedWorkspace::ENV_WORKSPACE_DIR . '=.artifacts'); + $environment = $this->createEnvironment('.artifacts'); self::assertSame( [ @@ -97,7 +94,7 @@ public function itWillIncludeCustomRelativeWorkspaceInToolingSkipPatterns(): voi 'repo/**/vendor/*', 'repo/.artifacts', ], - WorkingProjectPathResolver::getToolingExcludedDirectories('repo') + WorkingProjectPathResolver::getToolingExcludedDirectories('repo', $environment), ); } @@ -107,6 +104,8 @@ public function itWillIncludeCustomRelativeWorkspaceInToolingSkipPatterns(): voi #[Test] public function itWillNormalizePathSeparatorsWhenJoiningProjectPaths(): void { + $environment = $this->createEnvironment(); + self::assertSame( [ 'tmp/.dev-tools', @@ -121,7 +120,7 @@ public function itWillNormalizePathSeparatorsWhenJoiningProjectPaths(): void 'tmp/**/vendor', 'tmp/**/vendor/*', ], - WorkingProjectPathResolver::getToolingExcludedDirectories('tmp/') + WorkingProjectPathResolver::getToolingExcludedDirectories('tmp/', $environment), ); } @@ -131,6 +130,8 @@ public function itWillNormalizePathSeparatorsWhenJoiningProjectPaths(): void #[Test] public function itWillExposeRelativeToolingSkipPatternsByDefault(): void { + $environment = $this->createEnvironment(); + self::assertSame( [ '.dev-tools', @@ -145,7 +146,7 @@ public function itWillExposeRelativeToolingSkipPatternsByDefault(): void '**/vendor', '**/vendor/*', ], - WorkingProjectPathResolver::getToolingExcludedDirectories() + WorkingProjectPathResolver::getToolingExcludedDirectories(environment: $environment), ); } @@ -176,7 +177,10 @@ public function itWillExposeToolingSourcePathsIgnoringExcludedDirectories(): voi realpath($fixtureDirectory) . '/src/Example.php', realpath($fixtureDirectory) . '/tests/Fixtures/Example.php', ], - WorkingProjectPathResolver::getToolingSourcePaths(realpath($fixtureDirectory)) + WorkingProjectPathResolver::getToolingSourcePaths( + realpath($fixtureDirectory), + $this->createEnvironment() + ), ); } finally { self::cleanupFixtureDirectory($fixtureDirectory); @@ -190,8 +194,7 @@ public function itWillExposeToolingSourcePathsIgnoringExcludedDirectories(): voi public function itWillIgnoreCustomWorkspaceWhenResolvingToolingSourcePaths(): void { $fixtureDirectory = \dirname(__DIR__, 2) . '/backup/dev-tools-path-resolver-' . uniqid(); - - putenv(ManagedWorkspace::ENV_WORKSPACE_DIR . '=.artifacts'); + $environment = $this->createEnvironment('.artifacts'); mkdir($fixtureDirectory . '/src', recursive: true); mkdir($fixtureDirectory . '/.artifacts/cache', recursive: true); @@ -202,13 +205,28 @@ public function itWillIgnoreCustomWorkspaceWhenResolvingToolingSourcePaths(): vo try { self::assertSame( [realpath($fixtureDirectory) . '/src/Example.php'], - WorkingProjectPathResolver::getToolingSourcePaths(realpath($fixtureDirectory)) + WorkingProjectPathResolver::getToolingSourcePaths(realpath($fixtureDirectory), $environment) ); } finally { self::cleanupFixtureDirectory($fixtureDirectory); } } + /** + * @param string|null $value + * + * @return EnvironmentInterface + */ + private function createEnvironment(?string $value = null): EnvironmentInterface + { + $environment = $this->prophesize(EnvironmentInterface::class); + + $environment->get(ManagedWorkspace::ENV_WORKSPACE_DIR) + ->willReturn($value); + + return $environment->reveal(); + } + /** * @param string $fixtureDirectory * diff --git a/tests/Project/ProjectCapabilitiesResolverTest.php b/tests/Project/ProjectCapabilitiesResolverTest.php index 8927182c45..70fc1a2d50 100644 --- a/tests/Project/ProjectCapabilitiesResolverTest.php +++ b/tests/Project/ProjectCapabilitiesResolverTest.php @@ -133,6 +133,29 @@ public function resolveWillIgnoreToolingPhpFilesWhenDetectingTestablePhpSource() self::assertTrue($capabilities->canGenerateMetrics()); } + /** + * @return void + */ + #[Test] + public function resolveWillNotDetectPhpSourceFromEmptyAutoloadDirectories(): void + { + mkdir($this->workspace . '/src', recursive: true); + + $this->composer->getAutoload('psr-4') + ->willReturn([ + 'App\\' => 'src/', + ]); + $this->composer->getAutoload('psr-0') + ->willReturn([]); + $this->composer->getAutoload('classmap') + ->willReturn([]); + + $capabilities = $this->resolver->resolve(); + + self::assertFalse($capabilities->hasPhpSourceFiles()); + self::assertFalse($capabilities->canRunTests()); + } + /** * @return void */ From 9b4f3a76d04f21d661923f7b9c62ae03677bec31 Mon Sep 17 00:00:00 2001 From: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 18:32:59 +0000 Subject: [PATCH 4/4] Update wiki submodule pointer for PR #345 --- .github/wiki | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/wiki b/.github/wiki index 77bbd8c5da..6421b6f151 160000 --- a/.github/wiki +++ b/.github/wiki @@ -1 +1 @@ -Subproject commit 77bbd8c5da294a3907ebdd1b9c591e5d8de21fdb +Subproject commit 6421b6f15141dc0a33d3ed2cba5cc11bb7c798a3