Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.41% covered (success)
93.41%
241 / 258
62.50% covered (warning)
62.50%
10 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
TestsCommand
93.41% covered (success)
93.41%
241 / 258
62.50% covered (warning)
62.50%
10 / 16
67.25
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
97.33% covered (success)
97.33%
73 / 75
0.00% covered (danger)
0.00%
0 / 1
17
 resolveProcessResultContext
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 forceAgentReporter
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 resolveStructuredProcessResultPayload
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 withStructuredCoverageValidationContext
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
4.01
 decodeStructuredProcessOutput
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
7.77
 resolvePath
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isDefaultTestsPath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 normalizeProjectRelativePath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 resolveBootstrapPath
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 resolveMinimumCoverage
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
7.03
 resolveMinimumCoverageFromEnvironment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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 JsonException;
23use FastForward\DevTools\Console\Command\Traits\LogsCommandResults;
24use FastForward\DevTools\Console\Input\HasCacheOption;
25use FastForward\DevTools\Console\Input\HasJsonOption;
26use FastForward\DevTools\Composer\Json\ComposerJsonInterface;
27use FastForward\DevTools\Environment\EnvironmentInterface;
28use FastForward\DevTools\Filesystem\FilesystemInterface;
29use FastForward\DevTools\Path\DevToolsPathResolver;
30use FastForward\DevTools\PhpUnit\Bootstrap\BootstrapShimGenerator;
31use FastForward\DevTools\PhpUnit\Coverage\CoverageSummaryLoaderInterface;
32use FastForward\DevTools\Process\ProcessBuilderInterface;
33use FastForward\DevTools\Process\ProcessQueueInterface;
34use FastForward\DevTools\Path\ManagedWorkspace;
35use FastForward\DevTools\Project\ProjectCapabilitiesResolverInterface;
36use InvalidArgumentException;
37use Psr\Log\LogLevel;
38use RuntimeException;
39use Symfony\Component\Config\FileLocatorInterface;
40use Symfony\Component\Console\Attribute\AsCommand;
41use Symfony\Component\Console\Command\Command;
42use Symfony\Component\Console\Input\InputArgument;
43use Symfony\Component\Console\Input\InputInterface;
44use Symfony\Component\Console\Input\InputOption;
45use Symfony\Component\Console\Output\BufferedOutput;
46use Symfony\Component\Console\Output\OutputInterface;
47use Symfony\Component\Process\Process;
48
49use function is_numeric;
50use function Safe\json_decode;
51use function Safe\preg_match;
52
53/**
54 * Facilitates the execution of the PHPUnit testing framework.
55 * This class MUST NOT be overridden and SHALL configure testing parameters dynamically.
56 */
57#[AsCommand(name: 'reports:tests', description: 'Runs PHPUnit tests.', aliases: ['phpunit', 'tests'])]
58 class TestsCommand extends Command
59{
60    use HasCacheOption;
61    use HasJsonOption;
62    use LogsCommandResults;
63
64    public const string AGENT_ENVIRONMENT_VARIABLE = 'AI_AGENT';
65
66    public const string AGENT_ENVIRONMENT_VALUE = 'fast-forward/dev-tools';
67
68    private const string PROCESS_LABEL = 'Running PHPUnit Tests';
69
70    /**
71     * @var string identifies the local configuration file for PHPUnit processes
72     */
73    public const string CONFIG = 'phpunit.xml';
74
75    public const string ENV_MINIMUM_COVERAGE = 'FAST_FORWARD_MIN_COVERAGE';
76
77    /**
78     * @param CoverageSummaryLoaderInterface $coverageSummaryLoader the loader used for `coverage-php` summaries
79     * @param ComposerJsonInterface $composer the composer.json reader for autoload information
80     * @param FilesystemInterface $filesystem the filesystem utility used for path resolution
81     * @param BootstrapShimGenerator $bootstrapShimGenerator the generator used to build the PHPUnit bootstrap shim
82     * @param FileLocatorInterface $fileLocator the file locator used to resolve PHPUnit configuration
83     * @param ProcessBuilderInterface $processBuilder the builder used to assemble the PHPUnit process
84     * @param ProcessQueueInterface $processQueue the queue used to execute PHPUnit
85     * @param ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver the project capability resolver
86     * @param EnvironmentInterface $environment the environment resolver for CLI-scoped flags
87     */
88    public function __construct(
89        private  CoverageSummaryLoaderInterface $coverageSummaryLoader,
90        private  ComposerJsonInterface $composer,
91        private  FilesystemInterface $filesystem,
92        private  BootstrapShimGenerator $bootstrapShimGenerator,
93        private  FileLocatorInterface $fileLocator,
94        private  ProcessBuilderInterface $processBuilder,
95        private  ProcessQueueInterface $processQueue,
96        private  ProjectCapabilitiesResolverInterface $projectCapabilitiesResolver,
97        private  EnvironmentInterface $environment,
98    ) {
99        parent::__construct();
100    }
101
102    /**
103     * Configures the testing command input constraints.
104     *
105     * The method MUST specify valid arguments for testing paths, caching directories,
106     * bootstrap scripts, and coverage instructions. It SHALL align with robust testing standards.
107     *
108     * @return void
109     */
110    protected function configure(): void
111    {
112        $this->setHelp('This command runs PHPUnit to execute your tests.');
113        $this
114            ->addJsonOption()
115            ->addCacheOption('Whether to enable PHPUnit result caching.')
116            ->addCacheDirOption(
117                description: 'Path to the PHPUnit cache directory.',
118                default: ManagedWorkspace::getCacheDirectory(ManagedWorkspace::PHPUNIT),
119            )
120            ->addArgument(
121                name: 'path',
122                mode: InputArgument::OPTIONAL,
123                description: 'Path to the tests directory.',
124                default: ProjectCapabilitiesResolverInterface::DEFAULT_TESTS_PATH,
125            )
126            ->addOption(
127                name: 'bootstrap',
128                shortcut: 'b',
129                mode: InputOption::VALUE_OPTIONAL,
130                description: 'Path to the bootstrap file.',
131                default: './vendor/autoload.php',
132            )
133            ->addOption(
134                name: 'coverage',
135                shortcut: 'c',
136                mode: InputOption::VALUE_OPTIONAL,
137                description: 'Whether to generate code coverage reports.',
138            )
139            ->addOption(
140                name: 'coverage-summary',
141                mode: InputOption::VALUE_NONE,
142                description: 'Whether to show only the summary for text coverage output.',
143            )
144            ->addOption(
145                name: 'filter',
146                shortcut: 'f',
147                mode: InputOption::VALUE_OPTIONAL,
148                description: 'Filter which tests to run based on a pattern.',
149            )
150            ->addOption(
151                name: 'min-coverage',
152                mode: InputOption::VALUE_REQUIRED,
153                description: 'Minimum line coverage percentage required for a successful run.',
154            )
155            ->addOption(
156                name: 'progress',
157                mode: InputOption::VALUE_NONE,
158                description: 'Whether to enable progress output from PHPUnit.',
159            );
160    }
161
162    /**
163     * Triggers the PHPUnit engine based on resolved paths and settings.
164     *
165     * The method MUST assemble the necessary commands to initiate PHPUnit securely.
166     * It SHOULD optionally construct advanced configuration arguments such as caching and coverage.
167     *
168     * @param InputInterface $input the runtime instruction set from the CLI
169     * @param OutputInterface $output the console feedback relay
170     *
171     * @return int the status integer describing the termination code
172     */
173    protected function execute(InputInterface $input, OutputInterface $output): int
174    {
175        $structuredOutput = $this->isJsonOutput($input);
176        $processOutput = $structuredOutput ? new BufferedOutput() : $output;
177        $cacheEnabled = $this->isCacheEnabled($input);
178
179        $this->log('Running PHPUnit tests...', $input);
180
181        try {
182            $minimumCoverage = $this->resolveMinimumCoverage($input);
183        } catch (InvalidArgumentException $invalidArgumentException) {
184            return $this->failure($invalidArgumentException->getMessage(), $input, [
185                'output' => $processOutput,
186            ]);
187        }
188
189        $testsPath = (string) $input->getArgument('path');
190        $projectCapabilities = $this->projectCapabilitiesResolver->resolve(testsPath: $testsPath);
191
192        if (! $projectCapabilities->hasTestsPath() && ! $this->isDefaultTestsPath($testsPath)) {
193            return $this->failure('Tests path not found: {path}', $input, [
194                'output' => $processOutput,
195                'path' => $this->filesystem->getAbsolutePath($testsPath),
196            ]);
197        }
198
199        if (! $projectCapabilities->canRunTests()) {
200            return $this->success(
201                'Skipping PHPUnit tests because no Composer-autoloaded PHP source files were detected.',
202                $input,
203                [
204                    'output' => $processOutput,
205                ],
206                LogLevel::WARNING,
207            );
208        }
209
210        $processBuilder = $this->processBuilder
211            ->withArgument('--configuration', $this->fileLocator->locate(self::CONFIG))
212            ->withArgument('--bootstrap', $this->resolveBootstrapPath($input))
213            ->withArgument('--display-deprecations')
214            ->withArgument('--display-phpunit-deprecations')
215            ->withArgument('--display-incomplete')
216            ->withArgument('--display-skipped');
217
218        if (! $input->getOption('progress') || $structuredOutput) {
219            $processBuilder = $processBuilder->withArgument('--no-progress');
220        }
221
222        if (! $structuredOutput) {
223            $processBuilder = $processBuilder->withArgument('--colors=always');
224        }
225
226        if ($cacheEnabled) {
227            $processBuilder = $processBuilder->withArgument(
228                '--cache-result',
229            )->withArgument('--cache-directory', $this->resolvePath($input, 'cache-dir'));
230        } else {
231            $processBuilder = $processBuilder->withArgument('--do-not-cache-result');
232        }
233
234        [$processBuilder, $coverageReportPath] = $this->configureCoverageArguments(
235            $input,
236            $processBuilder,
237            null !== $minimumCoverage,
238        );
239
240        if ($input->getOption('filter')) {
241            $processBuilder = $processBuilder->withArgument('--filter', $input->getOption('filter'));
242        }
243
244        $process = $processBuilder
245            ->withArgument($input->getArgument('path'))
246            ->build([DevToolsPathResolver::getPreferredToolBinaryPath('phpunit')]);
247
248        if ($structuredOutput) {
249            $this->forceAgentReporter($process);
250        }
251
252        $this->processQueue->add(process: $process, label: self::PROCESS_LABEL);
253
254        $result = $this->processQueue->run($processOutput);
255        $processResultContext = $this->resolveProcessResultContext($processOutput, $result, $structuredOutput);
256
257        if (self::SUCCESS !== $result) {
258            return $this->failure('PHPUnit tests failed.', $input, $processResultContext);
259        }
260
261        if (null === $minimumCoverage || null === $coverageReportPath) {
262            return $this->success('PHPUnit tests completed successfully.', $input, $processResultContext);
263        }
264
265        [$validationResult, $message, $coverageContext] = $this->validateMinimumCoverage(
266            $coverageReportPath,
267            $minimumCoverage,
268        );
269
270        if ($structuredOutput) {
271            $processResultContext = $this->withStructuredCoverageValidationContext(
272                $processResultContext,
273                $coverageContext,
274                $minimumCoverage,
275                $validationResult,
276                $message,
277            );
278        }
279
280        if (self::SUCCESS === $validationResult) {
281            return $this->success($message, $input, [...$processResultContext, ...$coverageContext]);
282        }
283
284        return $this->failure($message, $input, [...$processResultContext, ...$coverageContext]);
285    }
286
287    /**
288     * Builds structured context for the executed PHPUnit process.
289     *
290     * @param OutputInterface $processOutput the output sink used while the process ran
291     * @param int $exitCode the exit code returned by the process queue
292     * @param bool $structuredOutput whether the command captured subprocess output for structured logging
293     *
294     * @return array<string, array<string, mixed>|OutputInterface>
295     */
296    private function resolveProcessResultContext(
297        OutputInterface $processOutput,
298        int $exitCode,
299        bool $structuredOutput,
300    ): array {
301        if ($structuredOutput) {
302            return [
303                'output' => $this->resolveStructuredProcessResultPayload($processOutput, $exitCode),
304            ];
305        }
306
307        return [
308            'output' => $processOutput,
309        ];
310    }
311
312    /**
313     * Forces the PHPUnit subprocess to expose the agent reporter payload.
314     *
315     * @param Process $process the configured PHPUnit process
316     *
317     * @return void
318     */
319    private function forceAgentReporter(Process $process): void
320    {
321        $env = $process->getEnv();
322        $parentAgentEnvironment = $this->environment->get(self::AGENT_ENVIRONMENT_VARIABLE);
323
324        if (\array_key_exists(self::AGENT_ENVIRONMENT_VARIABLE, $env) || null !== $parentAgentEnvironment) {
325            return;
326        }
327
328        // Intentionally reuse the reporter's existing agent detection path for
329        // structured DevTools output instead of maintaining a separate
330        // integration that would need to mirror the plugin behavior.
331        $env[self::AGENT_ENVIRONMENT_VARIABLE] = self::AGENT_ENVIRONMENT_VALUE;
332        $process->setEnv($env);
333    }
334
335    /**
336     * Builds the structured payload that will be emitted for agent-oriented runs.
337     *
338     * @param OutputInterface $processOutput the output sink used while the process ran
339     * @param int $exitCode the exit code returned by the process queue
340     *
341     * @return array<string, mixed> the structured process payload
342     */
343    private function resolveStructuredProcessResultPayload(OutputInterface $processOutput, int $exitCode): array
344    {
345        $payload = [
346            'result' => self::SUCCESS === $exitCode ? 'success' : 'failure',
347        ];
348
349        if (! $processOutput instanceof BufferedOutput) {
350            return $payload;
351        }
352
353        $rawOutput = trim($processOutput->fetch());
354
355        if ('' === $rawOutput) {
356            return $payload;
357        }
358
359        [$decoded, $supplementalOutput] = $this->decodeStructuredProcessOutput($rawOutput);
360
361        if (\is_array($decoded)) {
362            $payload = [
363                ...$decoded,
364                'result' => $payload['result'],
365            ];
366        }
367
368        if (null !== $supplementalOutput) {
369            $payload['raw_output'] = $supplementalOutput;
370        }
371
372        return $payload;
373    }
374
375    /**
376     * Appends minimum-coverage validation data to the structured PHPUnit output payload.
377     *
378     * @param array<string, mixed> $context the command result context
379     * @param array<string, float|int|string|null> $coverageContext structured coverage metrics
380     * @param float $minimumCoverage the required coverage percentage
381     * @param int $validationResult the post-PHPUnit validation status
382     * @param string $message the validation message
383     *
384     * @return array<string, mixed> the enriched structured command context
385     */
386    private function withStructuredCoverageValidationContext(
387        array $context,
388        array $coverageContext,
389        float $minimumCoverage,
390        int $validationResult,
391        string $message,
392    ): array {
393        if (! isset($context['output']) || ! \is_array($context['output'])) {
394            return $context;
395        }
396
397        $payload = $context['output'];
398        $payload['coverage'] = [
399            ...$coverageContext,
400            'minimum' => $minimumCoverage,
401        ];
402
403        if (self::SUCCESS !== $validationResult) {
404            $payload['message'] = $message;
405            $payload['result'] = 'failure';
406        }
407
408        $context['output'] = $payload;
409
410        return $context;
411    }
412
413    /**
414     * Attempts to decode structured PHPUnit output while preserving any
415     * non-JSON prelude that was emitted before the final reporter payload.
416     *
417     * @param string $rawOutput the captured subprocess output
418     *
419     * @return array{array<string, mixed>|null, string|null} decoded payload and preserved supplemental output
420     */
421    private function decodeStructuredProcessOutput(string $rawOutput): array
422    {
423        try {
424            $decoded = json_decode($rawOutput, true);
425
426            return [\is_array($decoded) ? $decoded : null, null];
427        } catch (JsonException) {
428        }
429
430        if (1 !== preg_match('/^(?P<prefix>.*?)(?P<payload>\{\s*"result".*)$/s', $rawOutput, $matches)) {
431            return [null, $rawOutput];
432        }
433
434        try {
435            $decoded = json_decode($matches['payload'], true);
436        } catch (JsonException) {
437            return [null, $rawOutput];
438        }
439
440        if (! \is_array($decoded)) {
441            return [null, $rawOutput];
442        }
443
444        $prefix = trim($matches['prefix']);
445
446        return [$decoded, '' === $prefix ? null : $prefix];
447    }
448
449    /**
450     * Safely constructs an absolute path tied to a defined capability option.
451     *
452     * The method MUST compute absolute properties based on the supplied input parameters.
453     * It SHALL strictly return a securely bounded path string.
454     *
455     * @param InputInterface $input the raw parameter definitions
456     * @param string $option the requested option key to resolve
457     *
458     * @return string validated absolute path string
459     */
460    private function resolvePath(InputInterface $input, string $option): string
461    {
462        return $this->filesystem->getAbsolutePath($input->getOption($option));
463    }
464
465    /**
466     * Detects whether a tests path option still points at the default project tests directory.
467     *
468     * @param string $testsPath the tests path argument received from the CLI
469     *
470     * @return bool true when the provided path is equivalent to the default tests directory
471     */
472    private function isDefaultTestsPath(string $testsPath): bool
473    {
474        return $this->normalizeProjectRelativePath($testsPath) === $this->normalizeProjectRelativePath(
475            ProjectCapabilitiesResolverInterface::DEFAULT_TESTS_PATH
476        );
477    }
478
479    /**
480     * Normalizes a project-relative path for resilient default-option comparisons.
481     *
482     * @param string $path the project-relative path to normalize
483     *
484     * @return string the normalized project-relative path
485     */
486    private function normalizeProjectRelativePath(string $path): string
487    {
488        $normalizedPath = str_replace('\\', '/', $path);
489
490        while (str_starts_with($normalizedPath, './')) {
491            $normalizedPath = substr($normalizedPath, 2);
492        }
493
494        return rtrim($normalizedPath, '/');
495    }
496
497    /**
498     * Creates the bootstrap shim path passed to PHPUnit.
499     *
500     * @param InputInterface $input the raw parameter definitions
501     *
502     * @return string the generated bootstrap shim path
503     */
504    private function resolveBootstrapPath(InputInterface $input): string
505    {
506        return $this->bootstrapShimGenerator->generate(
507            $this->resolvePath($input, 'bootstrap'),
508            $this->resolvePath($input, 'cache-dir'),
509        );
510    }
511
512    /**
513     * @param InputInterface $input the raw parameter definitions
514     *
515     * @return float|null the validated minimum coverage percentage, if configured
516     */
517    private function resolveMinimumCoverage(InputInterface $input): ?float
518    {
519        $minimumCoverage = $input->getOption('min-coverage');
520
521        if (null === $minimumCoverage) {
522            $minimumCoverage = $this->resolveMinimumCoverageFromEnvironment();
523        }
524
525        if (false === $minimumCoverage || '' === trim((string) $minimumCoverage)) {
526            return null;
527        }
528
529        $minimumCoverage = trim((string) $minimumCoverage);
530
531        if (! is_numeric($minimumCoverage)) {
532            throw new InvalidArgumentException('The --min-coverage option MUST be a numeric percentage.');
533        }
534
535        $minimumCoverage = (float) $minimumCoverage;
536
537        if (0.0 > $minimumCoverage || 100.0 < $minimumCoverage) {
538            throw new InvalidArgumentException('The --min-coverage option MUST be between 0 and 100.');
539        }
540
541        return $minimumCoverage;
542    }
543
544    /**
545     * Resolves minimum-coverage value from injected environment abstraction.
546     *
547     * @return string|false|null the configured coverage threshold or a falsey fallback
548     */
549    private function resolveMinimumCoverageFromEnvironment(): ?string
550    {
551        return $this->environment->get(self::ENV_MINIMUM_COVERAGE);
552    }
553
554    /**
555     * @param InputInterface $input the raw parameter definitions
556     * @param ProcessBuilderInterface $processBuilder the process builder to extend with coverage arguments
557     * @param bool $requiresCoverageReport indicates whether a `coverage-php` report is required
558     *
559     * @return array{ProcessBuilderInterface, string|null} the extended builder and generated `coverage-php` report path
560     */
561    private function configureCoverageArguments(
562        InputInterface $input,
563        ProcessBuilderInterface $processBuilder,
564        bool $requiresCoverageReport,
565    ): array {
566        $coverageOption = $input->getOption('coverage');
567
568        if (null === $coverageOption && ! $requiresCoverageReport) {
569            return [$processBuilder, null];
570        }
571
572        $coveragePath = null !== $coverageOption
573            ? $this->resolvePath($input, 'coverage')
574            : (
575                $this->isCacheEnabled($input)
576                    ? $this->resolvePath($input, 'cache-dir')
577                    : ManagedWorkspace::getOutputDirectory(ManagedWorkspace::COVERAGE)
578            );
579
580        foreach ($this->composer->getAutoload('psr-4') as $path) {
581            $processBuilder = $processBuilder->withArgument(
582                '--coverage-filter',
583                $this->filesystem->getAbsolutePath($path)
584            );
585        }
586
587        if (null !== $coverageOption) {
588            $processBuilder = $processBuilder
589                ->withArgument('--coverage-text')
590                ->withArgument('--coverage-html', $coveragePath)
591                ->withArgument('--testdox-html', $coveragePath . '/testdox.html')
592                ->withArgument('--coverage-clover', $coveragePath . '/clover.xml')
593                ->withArgument('--log-junit', $coveragePath . '/junit.xml');
594
595            if ($input->getOption('coverage-summary')) {
596                $processBuilder = $processBuilder->withArgument('--only-summary-for-coverage-text');
597            }
598        }
599
600        $coverageReportPath = $coveragePath . '/coverage.php';
601        $processBuilder = $processBuilder->withArgument('--coverage-php', $coverageReportPath);
602
603        return [$processBuilder, $coverageReportPath];
604    }
605
606    /**
607     * @param string $coverageReportPath the generated `coverage-php` report path
608     * @param float $minimumCoverage the required line coverage percentage
609     *
610     * @return array{int, string, array<string, float|int|string|null>} validation result, human message, and structured coverage context
611     */
612    private function validateMinimumCoverage(string $coverageReportPath, float $minimumCoverage): array
613    {
614        try {
615            $coverageSummary = $this->coverageSummaryLoader->load($coverageReportPath);
616        } catch (RuntimeException $runtimeException) {
617            return [
618                self::FAILURE,
619                $runtimeException->getMessage(),
620                [
621                    'line_coverage' => null,
622                    'covered_lines' => null,
623                    'total_lines' => null,
624                ],
625            ];
626        }
627
628        $message = \sprintf(
629            'Minimum line coverage of %01.2F%% %s. Current coverage: %s (%d/%d lines).',
630            $minimumCoverage,
631            $coverageSummary->percentage() >= $minimumCoverage ? 'satisfied' : 'was not met',
632            $coverageSummary->percentageAsString(),
633            $coverageSummary->executedLines(),
634            $coverageSummary->executableLines(),
635        );
636
637        return [
638            $coverageSummary->percentage() >= $minimumCoverage ? self::SUCCESS : self::FAILURE,
639            $message,
640            [
641                'line_coverage' => $coverageSummary->percentage(),
642                'covered_lines' => $coverageSummary->executedLines(),
643                'total_lines' => $coverageSummary->executableLines(),
644            ],
645        ];
646    }
647}