Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.83% covered (warning)
89.83%
159 / 177
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
TestsCommand
89.83% covered (warning)
89.83%
159 / 177
57.14% covered (warning)
57.14%
4 / 7
32.01
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 configure
100.00% covered (success)
100.00%
52 / 52
100.00% covered (success)
100.00%
1 / 1
1
 execute
87.30% covered (warning)
87.30%
55 / 63
0.00% covered (danger)
0.00%
0 / 1
12.29
 resolvePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 resolveMinimumCoverage
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
5.03
 configureCoverageArguments
60.87% covered (warning)
60.87%
14 / 23
0.00% covered (danger)
0.00%
0 / 1
9.94
 validateMinimumCoverage
100.00% covered (success)
100.00%
28 / 28
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2
3declare(strict_types=1);
4
5/**
6 * Fast Forward Development Tools for PHP projects.
7 *
8 * This file is part of fast-forward/dev-tools project.
9 *
10 * @author   Felipe SayĆ£o Lobato Abreu <github@mentordosnerds.com>
11 * @license  https://opensource.org/licenses/MIT MIT License
12 *
13 * @see      https://github.com/php-fast-forward/
14 * @see      https://github.com/php-fast-forward/dev-tools
15 * @see      https://github.com/php-fast-forward/dev-tools/issues
16 * @see      https://php-fast-forward.github.io/dev-tools/
17 * @see      https://datatracker.ietf.org/doc/html/rfc2119
18 */
19
20namespace FastForward\DevTools\Console\Command;
21
22use FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
23use Composer\Command\BaseCommand;
24use FastForward\DevTools\Console\Input\HasJsonOption;
25use FastForward\DevTools\Composer\Json\ComposerJsonInterface;
26use FastForward\DevTools\Filesystem\FilesystemInterface;
27use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface;
28use FastForward\DevTools\Process\ProcessBuilderInterface;
29use FastForward\DevTools\Process\ProcessQueueInterface;
30use FastForward\DevTools\Path\ManagedWorkspace;
31use InvalidArgumentException;
32use Psr\Log\LoggerInterface;
33use RuntimeException;
34use Symfony\Component\Config\FileLocatorInterface;
35use Symfony\Component\Console\Attribute\AsCommand;
36use Symfony\Component\Console\Input\InputArgument;
37use Symfony\Component\Console\Input\InputInterface;
38use Symfony\Component\Console\Input\InputOption;
39use Symfony\Component\Console\Output\BufferedOutput;
40use Symfony\Component\Console\Output\OutputInterface;
41
42use function is_numeric;
43
44/**
45 * Facilitates the execution of the PHPUnit testing framework.
46 * This class MUST NOT be overridden and SHALL configure testing parameters dynamically.
47 */
48#[AsCommand(
49    name: 'tests',
50    description: 'Runs PHPUnit tests.',
51    help: 'This command runs PHPUnit to execute your tests.'
52)]
53 class TestsCommand extends BaseCommand implements LoggerAwareCommandInterface
54{
55    use HasJsonOption;
56    use LogsCommandResults;
57
58    /**
59     * @var string identifies the local configuration file for PHPUnit processes
60     */
61    public const string CONFIG = 'phpunit.xml';
62
63    /**
64     * @param CoverageSummaryLoaderInterface $coverageSummaryLoader the loader used for `coverage-php` summaries
65     * @param ComposerJsonInterface $composer the composer.json reader for autoload information
66     * @param FilesystemInterface $filesystem the filesystem utility used for path resolution
67     * @param FileLocatorInterface $fileLocator the file locator used to resolve PHPUnit configuration
68     * @param ProcessBuilderInterface $processBuilder the builder used to assemble the PHPUnit process
69     * @param ProcessQueueInterface $processQueue the queue used to execute PHPUnit
70     * @param LoggerInterface $logger the output-aware logger
71     */
72    public function __construct(
73        private  CoverageSummaryLoaderInterface $coverageSummaryLoader,
74        private  ComposerJsonInterface $composer,
75        private  FilesystemInterface $filesystem,
76        private  FileLocatorInterface $fileLocator,
77        private  ProcessBuilderInterface $processBuilder,
78        private  ProcessQueueInterface $processQueue,
79        private  LoggerInterface $logger,
80    ) {
81        parent::__construct();
82    }
83
84    /**
85     * Configures the testing command input constraints.
86     *
87     * The method MUST specify valid arguments for testing paths, caching directories,
88     * bootstrap scripts, and coverage instructions. It SHALL align with robust testing standards.
89     *
90     * @return void
91     */
92    protected function configure(): void
93    {
94        $this->addJsonOption()
95            ->addArgument(
96                name: 'path',
97                mode: InputArgument::OPTIONAL,
98                description: 'Path to the tests directory.',
99                default: './tests',
100            )
101            ->addOption(
102                name: 'bootstrap',
103                shortcut: 'b',
104                mode: InputOption::VALUE_OPTIONAL,
105                description: 'Path to the bootstrap file.',
106                default: './vendor/autoload.php',
107            )
108            ->addOption(
109                name: 'cache-dir',
110                mode: InputOption::VALUE_OPTIONAL,
111                description: 'Path to the PHPUnit cache directory.',
112                default: ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPUNIT),
113            )
114            ->addOption(
115                name: 'no-cache',
116                mode: InputOption::VALUE_NONE,
117                description: 'Whether to disable PHPUnit caching.',
118            )
119            ->addOption(
120                name: 'coverage',
121                shortcut: 'c',
122                mode: InputOption::VALUE_OPTIONAL,
123                description: 'Whether to generate code coverage reports.',
124            )
125            ->addOption(
126                name: 'coverage-summary',
127                mode: InputOption::VALUE_NONE,
128                description: 'Whether to show only the summary for text coverage output.',
129            )
130            ->addOption(
131                name: 'filter',
132                shortcut: 'f',
133                mode: InputOption::VALUE_OPTIONAL,
134                description: 'Filter which tests to run based on a pattern.',
135            )
136            ->addOption(
137                name: 'min-coverage',
138                mode: InputOption::VALUE_REQUIRED,
139                description: 'Minimum line coverage percentage required for a successful run.',
140            )
141            ->addOption(
142                name: 'progress',
143                mode: InputOption::VALUE_NONE,
144                description: 'Whether to enable progress output from PHPUnit.',
145            );
146    }
147
148    /**
149     * Triggers the PHPUnit engine based on resolved paths and settings.
150     *
151     * The method MUST assemble the necessary commands to initiate PHPUnit securely.
152     * It SHOULD optionally construct advanced configuration arguments such as caching and coverage.
153     *
154     * @param InputInterface $input the runtime instruction set from the CLI
155     * @param OutputInterface $output the console feedback relay
156     *
157     * @return int the status integer describing the termination code
158     */
159    protected function execute(InputInterface $input, OutputInterface $output): int
160    {
161        $jsonOutput = $this->isJsonOutput($input);
162        $processOutput = $jsonOutput ? new BufferedOutput() : $output;
163
164        $this->getLogger()
165            ->info('Running PHPUnit tests...', [
166                'input' => $input,
167            ]);
168
169        try {
170            $minimumCoverage = $this->resolveMinimumCoverage($input);
171        } catch (InvalidArgumentException $invalidArgumentException) {
172            return $this->failure($invalidArgumentException->getMessage(), $input, [
173                'output' => $processOutput,
174            ]);
175        }
176
177        $processBuilder = $this->processBuilder
178            ->withArgument('--configuration', $this->fileLocator->locate(self::CONFIG))
179            ->withArgument('--bootstrap', $this->resolvePath($input, 'bootstrap'))
180            ->withArgument('--display-deprecations')
181            ->withArgument('--display-phpunit-deprecations')
182            ->withArgument('--display-incomplete')
183            ->withArgument('--display-skipped');
184
185        if (! $input->getOption('progress') || $jsonOutput) {
186            $processBuilder = $processBuilder->withArgument('--no-progress');
187        }
188
189        if (! $input->getOption('no-cache')) {
190            $processBuilder = $processBuilder->withArgument(
191                '--cache-directory',
192                $this->resolvePath($input, 'cache-dir')
193            );
194        }
195
196        [$processBuilder, $coverageReportPath] = $this->configureCoverageArguments(
197            $input,
198            $processBuilder,
199            null !== $minimumCoverage,
200        );
201
202        if ($input->getOption('filter')) {
203            $processBuilder = $processBuilder->withArgument('--filter', $input->getOption('filter'));
204        }
205
206        $this->processQueue->add(
207            $processBuilder
208                ->withArgument($input->getArgument('path'))
209                ->build('vendor/bin/phpunit')
210        );
211
212        $result = $this->processQueue->run($processOutput);
213
214        if (self::SUCCESS !== $result || null === $minimumCoverage || null === $coverageReportPath) {
215            if (self::SUCCESS === $result) {
216                return $this->success(
217                    'PHPUnit tests completed successfully.',
218                    $input,
219                    [
220                        'output' => $processOutput,
221                    ],
222                );
223            }
224
225            return $this->failure('PHPUnit tests failed.', $input, [
226                'output' => $processOutput,
227            ]);
228        }
229
230        [$validationResult, $message, $coverageContext] = $this->validateMinimumCoverage(
231            $coverageReportPath,
232            $minimumCoverage,
233        );
234
235        if (self::SUCCESS === $validationResult) {
236            return $this->success($message, $input, [
237                'output' => $processOutput,
238                ...$coverageContext,
239            ]);
240        }
241
242        return $this->failure($message, $input, [
243            'output' => $processOutput,
244            ...$coverageContext,
245        ]);
246    }
247
248    /**
249     * Safely constructs an absolute path tied to a defined capability option.
250     *
251     * The method MUST compute absolute properties based on the supplied input parameters.
252     * It SHALL strictly return a securely bounded path string.
253     *
254     * @param InputInterface $input the raw parameter definitions
255     * @param string $option the requested option key to resolve
256     *
257     * @return string validated absolute path string
258     */
259    private function resolvePath(InputInterface $input, string $option): string
260    {
261        return $this->filesystem->getAbsolutePath($input->getOption($option));
262    }
263
264    /**
265     * @param InputInterface $input the raw parameter definitions
266     *
267     * @return float|null the validated minimum coverage percentage, if configured
268     */
269    private function resolveMinimumCoverage(InputInterface $input): ?float
270    {
271        $minimumCoverage = $input->getOption('min-coverage');
272
273        if (null === $minimumCoverage) {
274            return null;
275        }
276
277        if (! is_numeric($minimumCoverage)) {
278            throw new InvalidArgumentException('The --min-coverage option MUST be a numeric percentage.');
279        }
280
281        $minimumCoverage = (float) $minimumCoverage;
282
283        if (0.0 > $minimumCoverage || 100.0 < $minimumCoverage) {
284            throw new InvalidArgumentException('The --min-coverage option MUST be between 0 and 100.');
285        }
286
287        return $minimumCoverage;
288    }
289
290    /**
291     * @param InputInterface $input the raw parameter definitions
292     * @param ProcessBuilderInterface $processBuilder the process builder to extend with coverage arguments
293     * @param bool $requiresCoverageReport indicates whether a `coverage-php` report is required
294     *
295     * @return array{ProcessBuilderInterface, string|null} the extended builder and generated `coverage-php` report path
296     */
297    private function configureCoverageArguments(
298        InputInterface $input,
299        ProcessBuilderInterface $processBuilder,
300        bool $requiresCoverageReport,
301    ): array {
302        $coverageOption = $input->getOption('coverage');
303
304        if (null === $coverageOption && ! $requiresCoverageReport) {
305            return [$processBuilder, null];
306        }
307
308        $coveragePath = null !== $coverageOption
309            ? $this->resolvePath($input, 'coverage')
310            : $this->resolvePath($input, 'cache-dir');
311
312        foreach ($this->composer->getAutoload('psr-4') as $path) {
313            $processBuilder = $processBuilder->withArgument(
314                '--coverage-filter',
315                $this->filesystem->getAbsolutePath($path)
316            );
317        }
318
319        if (null !== $coverageOption) {
320            $processBuilder = $processBuilder
321                ->withArgument('--coverage-text')
322                ->withArgument('--coverage-html', $coveragePath)
323                ->withArgument('--testdox-html', $coveragePath . '/testdox.html')
324                ->withArgument('--coverage-clover', $coveragePath . '/clover.xml')
325                ->withArgument('--log-junit', $coveragePath . '/junit.xml');
326
327            if ($input->getOption('coverage-summary')) {
328                $processBuilder = $processBuilder->withArgument('--only-summary-for-coverage-text');
329            }
330        }
331
332        $coverageReportPath = $coveragePath . '/coverage.php';
333        $processBuilder = $processBuilder->withArgument('--coverage-php', $coverageReportPath);
334
335        return [$processBuilder, $coverageReportPath];
336    }
337
338    /**
339     * @param string $coverageReportPath the generated `coverage-php` report path
340     * @param float $minimumCoverage the required line coverage percentage
341     *
342     * @return array{int, string, array<string, float|int|string|null>} validation result, human message, and structured coverage context
343     */
344    private function validateMinimumCoverage(string $coverageReportPath, float $minimumCoverage): array
345    {
346        try {
347            $coverageSummary = $this->coverageSummaryLoader->load($coverageReportPath);
348        } catch (RuntimeException $runtimeException) {
349            return [
350                self::FAILURE,
351                $runtimeException->getMessage(),
352                [
353                    'line_coverage' => null,
354                    'covered_lines' => null,
355                    'total_lines' => null,
356                ],
357            ];
358        }
359
360        $message = \sprintf(
361            'Minimum line coverage of %01.2F%% %s. Current coverage: %s (%d/%d lines).',
362            $minimumCoverage,
363            $coverageSummary->percentage() >= $minimumCoverage ? 'satisfied' : 'was not met',
364            $coverageSummary->percentageAsString(),
365            $coverageSummary->executedLines(),
366            $coverageSummary->executableLines(),
367        );
368
369        return [
370            $coverageSummary->percentage() >= $minimumCoverage ? self::SUCCESS : self::FAILURE,
371            $message,
372            [
373                'line_coverage' => $coverageSummary->percentage(),
374                'covered_lines' => $coverageSummary->executedLines(),
375                'total_lines' => $coverageSummary->executableLines(),
376            ],
377        ];
378    }
379}