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