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