From c5ba8361f088dbcd7d5d5ce0b6748dbac5cebeee Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 23 Apr 2026 23:27:33 +0530 Subject: [PATCH 01/13] feat: add sharding support for coverage reports and enhance help documentation --- src/Plugins/Coverage.php | 277 ++++++++++++++++++++++++++++++++++++++- src/Plugins/Help.php | 15 +++ src/Support/Coverage.php | 8 ++ 3 files changed, 298 insertions(+), 2 deletions(-) diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 3217dc6f0..2425e3753 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -7,10 +7,15 @@ use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Support\Str; +use Pest\TestSuite; +use SebastianBergmann\CodeCoverage\CodeCoverage; +use SebastianBergmann\CodeCoverage\Report\Clover; +use SebastianBergmann\CodeCoverage\Report\Html\Facade as HtmlFacade; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +use Throwable; /** * @internal @@ -27,6 +32,21 @@ final class Coverage implements AddsOutput, HandlesArguments private const string ONLY_COVERED_OPTION = 'only-covered'; + /** + * PHPUnit coverage report flags that produce output and must be suppressed during sharded runs. + * + * @var array + */ + private const array SHARD_BLOCKED_REPORT_FLAGS = [ + '--coverage-html' => 'html', + '--coverage-clover' => 'clover', + '--coverage-text' => 'text', + '--coverage-xml' => 'xml', + '--coverage-cobertura' => 'cobertura', + '--coverage-crap4j' => 'crap4j', + '--coverage-openclover' => 'openclover', + ]; + public bool $coverage = false; public bool $compact = false; @@ -37,6 +57,16 @@ final class Coverage implements AddsOutput, HandlesArguments public bool $showOnlyCovered = false; + /** + * The shard index when running in sharded coverage mode. + */ + private ?int $shardIndex = null; + + /** + * The total number of shards when running in sharded coverage mode. + */ + private ?int $shardTotal = null; + public function __construct(private readonly OutputInterface $output) { // @@ -47,6 +77,11 @@ public function __construct(private readonly OutputInterface $output) */ public function handleArguments(array $originals): array { + if (array_key_exists(1, $originals) && $originals[1] === 'coverage:report') { + $this->handleCoverageReport($originals); + exit(0); + } + $arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool { foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION, self::ONLY_COVERED_OPTION] as $option) { if ($original === sprintf('--%s', $option)) { @@ -74,8 +109,25 @@ public function handleArguments(array $originals): array $input = new ArgvInput($arguments, new InputDefinition($inputs)); if ((bool) $input->getOption(self::COVERAGE_OPTION)) { $this->coverage = true; - $originals[] = '--coverage-php'; - $originals[] = \Pest\Support\Coverage::getPath(); + + $shard = $this->detectShard($originals); + + if ($shard !== null) { + [$this->shardIndex, $this->shardTotal] = $shard; + + $coverageDir = $this->getCoverageDir(); + if (! is_dir($coverageDir)) { + mkdir($coverageDir, 0755, true); + } + + $originals = $this->stripShardBlockedReportFlags($originals); + + $originals[] = '--coverage-php'; + $originals[] = $coverageDir.DIRECTORY_SEPARATOR.$this->shardIndex.'.cov'; + } else { + $originals[] = '--coverage-php'; + $originals[] = \Pest\Support\Coverage::getPath(); + } if (! \Pest\Support\Coverage::isAvailable()) { if (\Pest\Support\Coverage::usingXdebug()) { @@ -130,6 +182,21 @@ public function addOutput(int $exitCode): int return $exitCode; } + if ($this->shardIndex !== null) { + $this->output->writeln([ + '', + sprintf( + ' Coverage: Coverage stored for shard %d/%d.', + $this->shardIndex, + $this->shardTotal, + ), + ' Run: pest coverage:report', + '', + ]); + + return $exitCode; + } + if ($exitCode === 0 && $this->coverage) { if (! \Pest\Support\Coverage::isAvailable()) { $this->output->writeln( @@ -172,4 +239,210 @@ private function computeComparableCoverage(float $coverage): float { return floor($coverage * 10) / 10; } + + /** + * Detects --shard=X/Y in the arguments and returns [index, total], or null if not present. + * + * @param array $arguments + * @return array{int, int}|null + */ + private function detectShard(array $arguments): ?array + { + foreach ($arguments as $i => $arg) { + if (str_starts_with($arg, '--shard=')) { + $value = substr($arg, strlen('--shard=')); + } elseif ($arg === '--shard' && isset($arguments[$i + 1])) { + $value = $arguments[$i + 1]; + } else { + continue; + } + + if (preg_match('/^(\d+)\/(\d+)$/', $value, $m)) { + return [(int) $m[1], (int) $m[2]]; + } + } + + return null; + } + + /** + * Returns the path to the .pest/coverage directory. + */ + private function getCoverageDir(): string + { + return implode(DIRECTORY_SEPARATOR, [ + TestSuite::getInstance()->rootPath, + '.pest', + 'coverage', + ]); + } + + /** + * Removes PHPUnit coverage report flags from the arguments during sharded runs, + * and warns the user if any were found. + * + * @param array $arguments + * @return array + */ + private function stripShardBlockedReportFlags(array $arguments): array + { + $blockedFlags = self::SHARD_BLOCKED_REPORT_FLAGS; + $firstHint = null; + $skipNext = false; + $filtered = []; + + foreach ($arguments as $arg) { + if ($skipNext) { + $skipNext = false; + continue; + } + + $matched = false; + foreach ($blockedFlags as $flag => $hint) { + if ($arg === $flag) { + $firstHint ??= $hint; + $skipNext = true; + $matched = true; + break; + } + if (str_starts_with($arg, $flag.'=')) { + $firstHint ??= $hint; + $matched = true; + break; + } + } + + if (! $matched) { + $filtered[] = $arg; + } + } + + if ($firstHint !== null) { + $this->output->writeln([ + '', + ' WARN Coverage reports are disabled during sharded runs.', + sprintf(' Run: pest coverage:report --%s', $firstHint), + '', + ]); + } + + return $filtered; + } + + /** + * Handles the `pest coverage:report` sub-command: merges all shard .cov files and generates reports. + * + * @param array $arguments + */ + private function handleCoverageReport(array $arguments): void + { + $hasHtml = false; + $htmlPath = 'coverage-html'; + $hasClover = false; + $cloverPath = 'coverage-clover.xml'; + $hasText = false; + $clean = false; + + foreach (array_slice($arguments, 2) as $arg) { + if ($arg === '--html') { + $hasHtml = true; + } elseif (str_starts_with($arg, '--html=')) { + $hasHtml = true; + $htmlPath = substr($arg, strlen('--html=')); + } elseif ($arg === '--clover') { + $hasClover = true; + } elseif (str_starts_with($arg, '--clover=')) { + $hasClover = true; + $cloverPath = substr($arg, strlen('--clover=')); + } elseif ($arg === '--text') { + $hasText = true; + } elseif ($arg === '--clean') { + $clean = true; + } + } + + $coverageDir = $this->getCoverageDir(); + $files = glob($coverageDir.DIRECTORY_SEPARATOR.'*.cov'); + + if ($files === false || $files === []) { + $this->output->writeln([ + '', + ' ERROR No coverage files found in .pest/coverage.', + ' Run tests with --coverage first.', + '', + ]); + exit(1); + } + + $count = count($files); + $this->output->writeln([ + '', + sprintf( + ' Merging coverage from %d shard%s...', + $count, + $count === 1 ? '' : 's', + ), + ]); + + $merged = null; + foreach ($files as $file) { + try { + /** @var CodeCoverage $coverage */ + $coverage = require $file; + if ($merged === null) { + $merged = $coverage; + } else { + $merged->merge($coverage); + } + } catch (Throwable $e) { + $this->output->writeln(sprintf( + ' WARN Skipping invalid coverage file: %s (%s)', + basename($file), + $e->getMessage(), + )); + } + } + + if ($merged === null) { + $this->output->writeln([ + '', + ' ERROR No valid coverage files could be loaded.', + '', + ]); + exit(1); + } + + if (! $hasHtml && ! $hasClover) { + \Pest\Support\Coverage::render($merged, $this->output, $this->compact, $this->showOnlyCovered); + } + + if ($hasText) { + \Pest\Support\Coverage::render($merged, $this->output, $this->compact, $this->showOnlyCovered); + } + + if ($hasHtml) { + (new HtmlFacade)->process($merged, $htmlPath); + $this->output->writeln(sprintf( + ' HTML coverage report generated at: %s', + $htmlPath, + )); + } + + if ($hasClover) { + (new Clover)->process($merged, $cloverPath); + $this->output->writeln(sprintf( + ' Clover coverage report generated at: %s', + $cloverPath, + )); + } + + if ($clean) { + foreach ($files as $file) { + @unlink($file); + } + $this->output->writeln(' Coverage files cleaned.'); + } + + $this->output->writeln(''); + } } diff --git a/src/Plugins/Help.php b/src/Plugins/Help.php index 9615fa599..80595e4db 100644 --- a/src/Plugins/Help.php +++ b/src/Plugins/Help.php @@ -209,6 +209,21 @@ private function getContent(): array ], [ 'arg' => '--coverage --only-covered', 'desc' => 'Hide files with 0% coverage from the code coverage report', + ], [ + 'arg' => 'coverage:report', + 'desc' => 'Merge .cov files from .pest/coverage/ and generate a coverage report', + ], [ + 'arg' => 'coverage:report --html', + 'desc' => 'Generate an HTML coverage report (default output: coverage-html/)', + ], [ + 'arg' => 'coverage:report --clover', + 'desc' => 'Generate a Clover XML coverage report (default output: coverage-clover.xml)', + ], [ + 'arg' => 'coverage:report --text', + 'desc' => 'Output the merged coverage report to standard output', + ], [ + 'arg' => 'coverage:report --clean', + 'desc' => 'Delete .cov files after generating the report', ], ...$content['Code Coverage']]; $content['Mutation Testing'] = [[ diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php index 321ce2ce4..a31dc384a 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -83,6 +83,14 @@ public static function report(OutputInterface $output, bool $compact = false, bo $codeCoverage = require $reportPath; unlink($reportPath); + return self::render($codeCoverage, $output, $compact, $showOnlyCovered); + } + + /** + * Renders the coverage report to the console and returns the total coverage as float. + */ + public static function render(CodeCoverage $codeCoverage, OutputInterface $output, bool $compact = false, bool $showOnlyCovered = false): float + { // @phpstan-ignore-next-line if (is_array($codeCoverage)) { $facade = Facade::fromSerializedData($codeCoverage); From 8f3ab68052b0875017f1a8ed5fadb960809fef54 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 23 Apr 2026 23:39:17 +0530 Subject: [PATCH 02/13] feat: enhance coverage reporting with sharding support and update help documentation --- src/Plugins/Coverage.php | 140 +++++++++++++++++++++------------------ src/Plugins/Help.php | 17 ++--- 2 files changed, 80 insertions(+), 77 deletions(-) diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 2425e3753..898b7605c 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -9,8 +9,6 @@ use Pest\Support\Str; use Pest\TestSuite; use SebastianBergmann\CodeCoverage\CodeCoverage; -use SebastianBergmann\CodeCoverage\Report\Clover; -use SebastianBergmann\CodeCoverage\Report\Html\Facade as HtmlFacade; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputOption; @@ -32,19 +30,23 @@ final class Coverage implements AddsOutput, HandlesArguments private const string ONLY_COVERED_OPTION = 'only-covered'; + private const string SHARDS_COVERAGE_OPTION = 'shards-coverage'; + + private const string CLEAN_OPTION = 'clean'; + /** * PHPUnit coverage report flags that produce output and must be suppressed during sharded runs. * * @var array */ private const array SHARD_BLOCKED_REPORT_FLAGS = [ - '--coverage-html' => 'html', - '--coverage-clover' => 'clover', - '--coverage-text' => 'text', - '--coverage-xml' => 'xml', - '--coverage-cobertura' => 'cobertura', - '--coverage-crap4j' => 'crap4j', - '--coverage-openclover' => 'openclover', + '--coverage-html' => 'coverage-html', + '--coverage-clover' => 'coverage-clover', + '--coverage-text' => 'coverage-text', + '--coverage-xml' => 'coverage-xml', + '--coverage-cobertura' => 'coverage-cobertura', + '--coverage-crap4j' => 'coverage-crap4j', + '--coverage-openclover' => 'coverage-openclover', ]; public bool $coverage = false; @@ -67,6 +69,16 @@ final class Coverage implements AddsOutput, HandlesArguments */ private ?int $shardTotal = null; + /** + * Whether to merge shard .cov files and generate a coverage report. + */ + private bool $shardsCoverage = false; + + /** + * Whether to delete .cov files after generating the shards coverage report. + */ + private bool $shardsCoverageClean = false; + public function __construct(private readonly OutputInterface $output) { // @@ -77,9 +89,11 @@ public function __construct(private readonly OutputInterface $output) */ public function handleArguments(array $originals): array { - if (array_key_exists(1, $originals) && $originals[1] === 'coverage:report') { - $this->handleCoverageReport($originals); - exit(0); + if ($this->hasShardsCoverageFlag($originals)) { + $originals = $this->popShardsCoverageFlags($originals); + $this->shardsCoverage = true; + + return $originals; } $arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool { @@ -182,6 +196,12 @@ public function addOutput(int $exitCode): int return $exitCode; } + if ($this->shardsCoverage) { + $this->mergeAndReportShardsCoverage(); + + return $exitCode; + } + if ($this->shardIndex !== null) { $this->output->writeln([ '', @@ -190,7 +210,7 @@ public function addOutput(int $exitCode): int $this->shardIndex, $this->shardTotal, ), - ' Run: pest coverage:report', + ' Run: pest --shards-coverage', '', ]); @@ -321,7 +341,7 @@ private function stripShardBlockedReportFlags(array $arguments): array $this->output->writeln([ '', ' WARN Coverage reports are disabled during sharded runs.', - sprintf(' Run: pest coverage:report --%s', $firstHint), + sprintf(' Run: pest --shards-coverage --%s', $firstHint), '', ]); } @@ -330,37 +350,49 @@ private function stripShardBlockedReportFlags(array $arguments): array } /** - * Handles the `pest coverage:report` sub-command: merges all shard .cov files and generates reports. + * Detects whether --shards-coverage is present in the arguments. * * @param array $arguments */ - private function handleCoverageReport(array $arguments): void + private function hasShardsCoverageFlag(array $arguments): bool { - $hasHtml = false; - $htmlPath = 'coverage-html'; - $hasClover = false; - $cloverPath = 'coverage-clover.xml'; - $hasText = false; - $clean = false; - - foreach (array_slice($arguments, 2) as $arg) { - if ($arg === '--html') { - $hasHtml = true; - } elseif (str_starts_with($arg, '--html=')) { - $hasHtml = true; - $htmlPath = substr($arg, strlen('--html=')); - } elseif ($arg === '--clover') { - $hasClover = true; - } elseif (str_starts_with($arg, '--clover=')) { - $hasClover = true; - $cloverPath = substr($arg, strlen('--clover=')); - } elseif ($arg === '--text') { - $hasText = true; - } elseif ($arg === '--clean') { - $clean = true; + foreach ($arguments as $arg) { + if ($arg === '--'.self::SHARDS_COVERAGE_OPTION) { + return true; } } + return false; + } + + /** + * Removes --shards-coverage and --clean from the arguments and records --clean state. + * + * @param array $arguments + * @return array + */ + private function popShardsCoverageFlags(array $arguments): array + { + $filtered = []; + foreach ($arguments as $arg) { + if ($arg === '--'.self::SHARDS_COVERAGE_OPTION) { + continue; + } + if ($arg === '--'.self::CLEAN_OPTION) { + $this->shardsCoverageClean = true; + continue; + } + $filtered[] = $arg; + } + + return $filtered; + } + + /** + * Merges all shard .cov files and generates the requested coverage reports. + */ + private function mergeAndReportShardsCoverage(): void + { $coverageDir = $this->getCoverageDir(); $files = glob($coverageDir.DIRECTORY_SEPARATOR.'*.cov'); @@ -368,10 +400,11 @@ private function handleCoverageReport(array $arguments): void $this->output->writeln([ '', ' ERROR No coverage files found in .pest/coverage.', - ' Run tests with --coverage first.', + ' Run tests with --shard=X/Y --coverage first.', '', ]); - exit(1); + + return; } $count = count($files); @@ -409,34 +442,13 @@ private function handleCoverageReport(array $arguments): void ' ERROR No valid coverage files could be loaded.', '', ]); - exit(1); - } - if (! $hasHtml && ! $hasClover) { - \Pest\Support\Coverage::render($merged, $this->output, $this->compact, $this->showOnlyCovered); + return; } - if ($hasText) { - \Pest\Support\Coverage::render($merged, $this->output, $this->compact, $this->showOnlyCovered); - } - - if ($hasHtml) { - (new HtmlFacade)->process($merged, $htmlPath); - $this->output->writeln(sprintf( - ' HTML coverage report generated at: %s', - $htmlPath, - )); - } - - if ($hasClover) { - (new Clover)->process($merged, $cloverPath); - $this->output->writeln(sprintf( - ' Clover coverage report generated at: %s', - $cloverPath, - )); - } + \Pest\Support\Coverage::render($merged, $this->output, $this->compact, $this->showOnlyCovered); - if ($clean) { + if ($this->shardsCoverageClean) { foreach ($files as $file) { @unlink($file); } diff --git a/src/Plugins/Help.php b/src/Plugins/Help.php index 80595e4db..608f6521d 100644 --- a/src/Plugins/Help.php +++ b/src/Plugins/Help.php @@ -210,20 +210,11 @@ private function getContent(): array 'arg' => '--coverage --only-covered', 'desc' => 'Hide files with 0% coverage from the code coverage report', ], [ - 'arg' => 'coverage:report', - 'desc' => 'Merge .cov files from .pest/coverage/ and generate a coverage report', + 'arg' => '--shards-coverage', + 'desc' => 'Merge .cov files from .pest/coverage/ and generate a combined coverage report', ], [ - 'arg' => 'coverage:report --html', - 'desc' => 'Generate an HTML coverage report (default output: coverage-html/)', - ], [ - 'arg' => 'coverage:report --clover', - 'desc' => 'Generate a Clover XML coverage report (default output: coverage-clover.xml)', - ], [ - 'arg' => 'coverage:report --text', - 'desc' => 'Output the merged coverage report to standard output', - ], [ - 'arg' => 'coverage:report --clean', - 'desc' => 'Delete .cov files after generating the report', + 'arg' => '--shards-coverage --clean', + 'desc' => 'Delete .cov files after generating the combined coverage report', ], ...$content['Code Coverage']]; $content['Mutation Testing'] = [[ From b3a731c3984d2b815a942f170b2100b4168e3f2c Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 23 Apr 2026 23:40:16 +0530 Subject: [PATCH 03/13] feat: improve shard coverage handling by merging and reporting coverage directly --- src/Plugins/Coverage.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 898b7605c..928348f3c 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -90,10 +90,10 @@ public function __construct(private readonly OutputInterface $output) public function handleArguments(array $originals): array { if ($this->hasShardsCoverageFlag($originals)) { - $originals = $this->popShardsCoverageFlags($originals); - $this->shardsCoverage = true; + $this->popShardsCoverageFlags($originals); + $this->mergeAndReportShardsCoverage(); - return $originals; + exit(0); } $arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool { From 6d55dff9ff7f67ebdd30fe07ecb805c9095eff81 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 23 Apr 2026 23:43:09 +0530 Subject: [PATCH 04/13] feat: enhance shard coverage handling by refining argument processing and reporting --- src/Plugins/Coverage.php | 78 ++++++++++++++++++++++++++++++++-------- 1 file changed, 64 insertions(+), 14 deletions(-) diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 928348f3c..aae299ad9 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -90,10 +90,66 @@ public function __construct(private readonly OutputInterface $output) public function handleArguments(array $originals): array { if ($this->hasShardsCoverageFlag($originals)) { - $this->popShardsCoverageFlags($originals); - $this->mergeAndReportShardsCoverage(); + $originals = $this->popShardsCoverageFlags($originals); - exit(0); + $shardArgs = [...[''], ...array_values(array_filter($originals, function (string $original): bool { + foreach ([self::MIN_OPTION, self::EXACTLY_OPTION, self::ONLY_COVERED_OPTION] as $option) { + if ($original === sprintf('--%s', $option)) { + return true; + } + + if (Str::startsWith($original, sprintf('--%s=', $option))) { + return true; + } + } + + return false; + }))]; + + $shardInput = new ArgvInput($shardArgs, new InputDefinition([ + new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED), + new InputOption(self::EXACTLY_OPTION, null, InputOption::VALUE_REQUIRED), + new InputOption(self::ONLY_COVERED_OPTION, null, InputOption::VALUE_NONE), + ])); + + if ($shardInput->getOption(self::MIN_OPTION) !== null) { + $this->coverageMin = (float) $shardInput->getOption(self::MIN_OPTION); + } + + if ($shardInput->getOption(self::EXACTLY_OPTION) !== null) { + $this->coverageExactly = (float) $shardInput->getOption(self::EXACTLY_OPTION); + } + + if ((bool) $shardInput->getOption(self::ONLY_COVERED_OPTION)) { + $this->showOnlyCovered = true; + } + + $coverage = $this->mergeAndReportShardsCoverage(); + $exitCode = (int) ($coverage < $this->coverageMin); + + if ($exitCode === 0 && $this->coverageExactly !== null) { + $comparableCoverage = $this->computeComparableCoverage($coverage); + $comparableCoverageExactly = $this->computeComparableCoverage($this->coverageExactly); + $exitCode = $comparableCoverage === $comparableCoverageExactly ? 0 : 1; + + if ($exitCode === 1) { + $this->output->writeln(sprintf( + "\n FAIL Code coverage not exactly %s %%, currently %s %%.", + number_format($this->coverageExactly, 1), + number_format(floor($coverage * 10) / 10, 1), + )); + } + } elseif ($exitCode === 1) { + $this->output->writeln(sprintf( + "\n FAIL Code coverage below expected %s %%, currently %s %%.", + number_format($this->coverageMin, 1), + number_format(floor($coverage * 10) / 10, 1) + )); + } + + $this->output->writeln(['']); + + exit($exitCode); } $arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool { @@ -196,12 +252,6 @@ public function addOutput(int $exitCode): int return $exitCode; } - if ($this->shardsCoverage) { - $this->mergeAndReportShardsCoverage(); - - return $exitCode; - } - if ($this->shardIndex !== null) { $this->output->writeln([ '', @@ -391,7 +441,7 @@ private function popShardsCoverageFlags(array $arguments): array /** * Merges all shard .cov files and generates the requested coverage reports. */ - private function mergeAndReportShardsCoverage(): void + private function mergeAndReportShardsCoverage(): float { $coverageDir = $this->getCoverageDir(); $files = glob($coverageDir.DIRECTORY_SEPARATOR.'*.cov'); @@ -404,7 +454,7 @@ private function mergeAndReportShardsCoverage(): void '', ]); - return; + exit(1); } $count = count($files); @@ -443,10 +493,10 @@ private function mergeAndReportShardsCoverage(): void '', ]); - return; + exit(1); } - \Pest\Support\Coverage::render($merged, $this->output, $this->compact, $this->showOnlyCovered); + $result = \Pest\Support\Coverage::render($merged, $this->output, $this->compact, $this->showOnlyCovered); if ($this->shardsCoverageClean) { foreach ($files as $file) { @@ -455,6 +505,6 @@ private function mergeAndReportShardsCoverage(): void $this->output->writeln(' Coverage files cleaned.'); } - $this->output->writeln(''); + return $result; } } From be94f45144d97a9ef6faaf18162d68edd383d6d6 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 23 Apr 2026 23:48:18 +0530 Subject: [PATCH 05/13] feat: refactor shard coverage handling by simplifying argument parsing and threshold evaluation --- src/Plugins/Coverage.php | 184 +++++++++++++++++++-------------------- 1 file changed, 91 insertions(+), 93 deletions(-) diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index aae299ad9..c1251087a 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -49,14 +49,29 @@ final class Coverage implements AddsOutput, HandlesArguments '--coverage-openclover' => 'coverage-openclover', ]; + /** + * Whether it should show the coverage or not. + */ public bool $coverage = false; + /** + * Whether it should show the coverage or not. + */ public bool $compact = false; + /** + * The minimum coverage. + */ public float $coverageMin = 0.0; + /** + * The exactly coverage. + */ public ?float $coverageExactly = null; + /** + * Whether it should show only covered files. + */ public bool $showOnlyCovered = false; /** @@ -69,19 +84,17 @@ final class Coverage implements AddsOutput, HandlesArguments */ private ?int $shardTotal = null; - /** - * Whether to merge shard .cov files and generate a coverage report. - */ - private bool $shardsCoverage = false; - /** * Whether to delete .cov files after generating the shards coverage report. */ private bool $shardsCoverageClean = false; + /** + * Creates a new Plugin instance. + */ public function __construct(private readonly OutputInterface $output) { - // + // .. } /** @@ -91,61 +104,10 @@ public function handleArguments(array $originals): array { if ($this->hasShardsCoverageFlag($originals)) { $originals = $this->popShardsCoverageFlags($originals); - - $shardArgs = [...[''], ...array_values(array_filter($originals, function (string $original): bool { - foreach ([self::MIN_OPTION, self::EXACTLY_OPTION, self::ONLY_COVERED_OPTION] as $option) { - if ($original === sprintf('--%s', $option)) { - return true; - } - - if (Str::startsWith($original, sprintf('--%s=', $option))) { - return true; - } - } - - return false; - }))]; - - $shardInput = new ArgvInput($shardArgs, new InputDefinition([ - new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED), - new InputOption(self::EXACTLY_OPTION, null, InputOption::VALUE_REQUIRED), - new InputOption(self::ONLY_COVERED_OPTION, null, InputOption::VALUE_NONE), - ])); - - if ($shardInput->getOption(self::MIN_OPTION) !== null) { - $this->coverageMin = (float) $shardInput->getOption(self::MIN_OPTION); - } - - if ($shardInput->getOption(self::EXACTLY_OPTION) !== null) { - $this->coverageExactly = (float) $shardInput->getOption(self::EXACTLY_OPTION); - } - - if ((bool) $shardInput->getOption(self::ONLY_COVERED_OPTION)) { - $this->showOnlyCovered = true; - } + $this->parseThresholdOptions($originals); $coverage = $this->mergeAndReportShardsCoverage(); - $exitCode = (int) ($coverage < $this->coverageMin); - - if ($exitCode === 0 && $this->coverageExactly !== null) { - $comparableCoverage = $this->computeComparableCoverage($coverage); - $comparableCoverageExactly = $this->computeComparableCoverage($this->coverageExactly); - $exitCode = $comparableCoverage === $comparableCoverageExactly ? 0 : 1; - - if ($exitCode === 1) { - $this->output->writeln(sprintf( - "\n FAIL Code coverage not exactly %s %%, currently %s %%.", - number_format($this->coverageExactly, 1), - number_format(floor($coverage * 10) / 10, 1), - )); - } - } elseif ($exitCode === 1) { - $this->output->writeln(sprintf( - "\n FAIL Code coverage below expected %s %%, currently %s %%.", - number_format($this->coverageMin, 1), - number_format(floor($coverage * 10) / 10, 1) - )); - } + $exitCode = $this->applyThresholds($coverage); $this->output->writeln(['']); @@ -218,23 +180,7 @@ public function handleArguments(array $originals): array } } - if ($input->getOption(self::MIN_OPTION) !== null) { - /** @var int|float $minOption */ - $minOption = $input->getOption(self::MIN_OPTION); - - $this->coverageMin = (float) $minOption; - } - - if ($input->getOption(self::EXACTLY_OPTION) !== null) { - /** @var int|float $exactlyOption */ - $exactlyOption = $input->getOption(self::EXACTLY_OPTION); - - $this->coverageExactly = (float) $exactlyOption; - } - - if ((bool) $input->getOption(self::ONLY_COVERED_OPTION)) { - $this->showOnlyCovered = true; - } + $this->parseThresholdOptions($arguments); if ($_SERVER['COLLISION_PRINTER_COMPACT'] ?? false) { $this->compact = true; @@ -276,35 +222,87 @@ public function addOutput(int $exitCode): int } $coverage = \Pest\Support\Coverage::report($this->output, $this->compact, $this->showOnlyCovered); - $exitCode = (int) ($coverage < $this->coverageMin); + $exitCode = $this->applyThresholds($coverage); - if ($exitCode === 0 && $this->coverageExactly !== null) { - $comparableCoverage = $this->computeComparableCoverage($coverage); - $comparableCoverageExactly = $this->computeComparableCoverage($this->coverageExactly); + $this->output->writeln(['']); + } + + return $exitCode; + } - $exitCode = $comparableCoverage === $comparableCoverageExactly ? 0 : 1; + /** + * Parses --min, --exactly, and --only-covered from an argv-style array and sets the corresponding properties. + * + * @param array $originals + */ + private function parseThresholdOptions(array $originals): void + { + $args = [...[''], ...array_values(array_filter($originals, function (string $original): bool { + foreach ([self::MIN_OPTION, self::EXACTLY_OPTION, self::ONLY_COVERED_OPTION] as $option) { + if ($original === sprintf('--%s', $option)) { + return true; + } - if ($exitCode === 1) { - $this->output->writeln(sprintf( - "\n FAIL Code coverage not exactly %s %%, currently %s %%.", - number_format($this->coverageExactly, 1), - number_format(floor($coverage * 10) / 10, 1), - )); + if (Str::startsWith($original, sprintf('--%s=', $option))) { + return true; } - } elseif ($exitCode === 1) { + } + + return false; + }))]; + + $input = new ArgvInput($args, new InputDefinition([ + new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED), + new InputOption(self::EXACTLY_OPTION, null, InputOption::VALUE_REQUIRED), + new InputOption(self::ONLY_COVERED_OPTION, null, InputOption::VALUE_NONE), + ])); + + if ($input->getOption(self::MIN_OPTION) !== null) { + $this->coverageMin = (float) $input->getOption(self::MIN_OPTION); + } + + if ($input->getOption(self::EXACTLY_OPTION) !== null) { + $this->coverageExactly = (float) $input->getOption(self::EXACTLY_OPTION); + } + + if ((bool) $input->getOption(self::ONLY_COVERED_OPTION)) { + $this->showOnlyCovered = true; + } + } + + /** + * Evaluates coverage against --min/--exactly thresholds, writes failure messages, and returns the exit code. + */ + private function applyThresholds(float $coverage): int + { + $exitCode = (int) ($coverage < $this->coverageMin); + + if ($exitCode === 0 && $this->coverageExactly !== null) { + $comparableCoverage = $this->computeComparableCoverage($coverage); + $comparableCoverageExactly = $this->computeComparableCoverage($this->coverageExactly); + $exitCode = $comparableCoverage === $comparableCoverageExactly ? 0 : 1; + + if ($exitCode === 1) { $this->output->writeln(sprintf( - "\n FAIL Code coverage below expected %s %%, currently %s %%.", - number_format($this->coverageMin, 1), - number_format(floor($coverage * 10) / 10, 1) + "\n FAIL Code coverage not exactly %s %%, currently %s %%.", + number_format($this->coverageExactly, 1), + number_format(floor($coverage * 10) / 10, 1), )); } - - $this->output->writeln(['']); + } elseif ($exitCode === 1) { + $this->output->writeln(sprintf( + "\n FAIL Code coverage below expected %s %%, currently %s %%.", + number_format($this->coverageMin, 1), + number_format(floor($coverage * 10) / 10, 1) + )); } return $exitCode; } + /** + * Computes the comparable coverage to a percentage with one decimal. + */ private function computeComparableCoverage(float $coverage): float { return floor($coverage * 10) / 10; From aa0f5e709e0602111dfd76971639c988e055a8c2 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 24 Apr 2026 00:07:12 +0530 Subject: [PATCH 06/13] tests, lint etc --- src/Plugins/Coverage.php | 10 +- tests/Features/Coverage.php | 42 +++++++- tests/Plugins/Coverage.php | 189 +++++++++++++++++++++++++++++++++++- 3 files changed, 229 insertions(+), 12 deletions(-) diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index c1251087a..4f5504b9d 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -362,6 +362,7 @@ private function stripShardBlockedReportFlags(array $arguments): array foreach ($arguments as $arg) { if ($skipNext) { $skipNext = false; + continue; } @@ -404,13 +405,7 @@ private function stripShardBlockedReportFlags(array $arguments): array */ private function hasShardsCoverageFlag(array $arguments): bool { - foreach ($arguments as $arg) { - if ($arg === '--'.self::SHARDS_COVERAGE_OPTION) { - return true; - } - } - - return false; + return in_array('--'.self::SHARDS_COVERAGE_OPTION, $arguments, true); } /** @@ -428,6 +423,7 @@ private function popShardsCoverageFlags(array $arguments): array } if ($arg === '--'.self::CLEAN_OPTION) { $this->shardsCoverageClean = true; + continue; } $filtered[] = $arg; diff --git a/tests/Features/Coverage.php b/tests/Features/Coverage.php index 16e731bbc..15b185d87 100644 --- a/tests/Features/Coverage.php +++ b/tests/Features/Coverage.php @@ -2,6 +2,7 @@ use Pest\Plugins\Coverage as CoveragePlugin; use Pest\Support\Coverage; +use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\ConsoleOutput; it('has plugin')->assertTrue(class_exists(CoveragePlugin::class)); @@ -34,7 +35,46 @@ expect($plugin->coverageMin)->toEqual(2.4); }); -it('generates coverage based on file input', function (): void { +it('adds coverage if --exactly exist', function () { + $plugin = new CoveragePlugin(new ConsoleOutput); + + $plugin->handleArguments(['--exactly=50']); + expect($plugin->coverageExactly)->toEqual(50.0); + + $plugin->handleArguments(['--exactly=50.5']); + expect($plugin->coverageExactly)->toEqual(50.5); +}); + +it('adds coverage if --only-covered exist', function () { + $plugin = new CoveragePlugin(new ConsoleOutput); + + $plugin->handleArguments(['--only-covered']); + expect($plugin->showOnlyCovered)->toBeTrue(); +}); + +it('routes --coverage-php to .pest/coverage/{n}.cov when --shard is used', function () { + $plugin = new CoveragePlugin(new ConsoleOutput); + + $arguments = $plugin->handleArguments(['--coverage', '--shard=1/3']); + + $phpIdx = array_search('--coverage-php', $arguments, true); + expect($phpIdx)->not->toBeFalse(); + + $covPath = $arguments[$phpIdx + 1]; + expect($covPath)->toEndWith('.pest'.DIRECTORY_SEPARATOR.'coverage'.DIRECTORY_SEPARATOR.'1.cov'); +})->skip(! Coverage::isAvailable() || ! function_exists('xdebug_info') || ! in_array('coverage', xdebug_info('mode'), true), 'Coverage is not available'); + +it('strips blocked report flags and warns when --shard is used', function () { + $output = new BufferedOutput; + $plugin = new CoveragePlugin($output); + + $arguments = $plugin->handleArguments(['--coverage', '--shard=1/2', '--coverage-html=out']); + + expect($arguments)->not->toContain('--coverage-html=out') + ->and($output->fetch())->toContain('WARN'); +})->skip(! Coverage::isAvailable() || ! function_exists('xdebug_info') || ! in_array('coverage', xdebug_info('mode'), true), 'Coverage is not available'); + +it('generates coverage based on file input', function () { expect(Coverage::getMissingCoverage(new class { public function lineCoverageData(): array diff --git a/tests/Plugins/Coverage.php b/tests/Plugins/Coverage.php index a9c581efb..44ca24b81 100644 --- a/tests/Plugins/Coverage.php +++ b/tests/Plugins/Coverage.php @@ -1,12 +1,11 @@ $this->computeComparableCoverage($givenValue))->call($plugin); @@ -21,3 +20,185 @@ [32.57777771232132, 32.5], [100.0, 100.0], ]); + +test('apply thresholds', function (float $coverage, ?float $min, ?float $exactly, int $expectedExitCode) { + $output = new BufferedOutput; + $plugin = new Coverage($output); + $plugin->coverageMin = $min ?? 0.0; + $plugin->coverageExactly = $exactly; + + $exitCode = (fn () => $this->applyThresholds($coverage))->call($plugin); + + expect($exitCode)->toBe($expectedExitCode); + + if ($expectedExitCode === 1) { + expect($output->fetch())->toContain('FAIL'); + } +})->with([ + 'min pass' => [91.5, 80.0, null, 0], + 'min fail' => [91.5, 95.0, null, 1], + 'exactly pass' => [91.5, null, 91.5, 0], + 'exactly fail' => [91.5, null, 95.0, 1], +]); + +test('strip shard blocked report flags', function (array $args, array $expected, bool $expectWarn) { + $output = new BufferedOutput; + $plugin = new Coverage($output); + + $filtered = (fn () => $this->stripShardBlockedReportFlags($args))->call($plugin); + + expect($filtered)->toBe($expected); + + $expectWarn + ? expect($output->fetch())->toContain('WARN') + : expect($output->fetch())->toBe(''); +})->with([ + 'inline value flag' => [['--coverage-html=out', '--compact'], ['--compact'], true], + 'space-separated flag' => [['--compact', '--coverage-clover', 'clover.xml'], ['--compact'], true], + 'no blocked flags' => [['--compact', '--stop-on-failure'], ['--compact', '--stop-on-failure'], false], +]); + +test('apply thresholds returns 0 when coverage meets min', function () { + $plugin = new Coverage(new NullOutput); + $plugin->coverageMin = 80.0; + + $exitCode = (fn () => $this->applyThresholds(91.5))->call($plugin); + + expect($exitCode)->toBe(0); +}); + +test('apply thresholds returns 1 and writes FAIL when coverage is below min', function () { + $output = new BufferedOutput; + $plugin = new Coverage($output); + $plugin->coverageMin = 95.0; + + $exitCode = (fn () => $this->applyThresholds(91.5))->call($plugin); + + expect($exitCode)->toBe(1) + ->and($output->fetch())->toContain('95.0')->toContain('91.5'); +}); + +test('apply thresholds returns 0 when coverage matches exactly', function () { + $plugin = new Coverage(new NullOutput); + $plugin->coverageExactly = 91.5; + + $exitCode = (fn () => $this->applyThresholds(91.5))->call($plugin); + + expect($exitCode)->toBe(0); +}); + +test('apply thresholds returns 1 and writes FAIL when coverage does not match exactly', function () { + $output = new BufferedOutput; + $plugin = new Coverage($output); + $plugin->coverageExactly = 95.0; + + $exitCode = (fn () => $this->applyThresholds(91.5))->call($plugin); + + expect($exitCode)->toBe(1) + ->and($output->fetch())->toContain('95.0')->toContain('91.5'); +}); + +test('parse threshold options sets coverageMin', function () { + $plugin = new Coverage(new NullOutput); + + (fn () => $this->parseThresholdOptions(['--min=42.5']))->call($plugin); + + expect($plugin->coverageMin)->toBe(42.5); +}); + +test('parse threshold options sets coverageExactly', function () { + $plugin = new Coverage(new NullOutput); + + (fn () => $this->parseThresholdOptions(['--exactly=75.0']))->call($plugin); + + expect($plugin->coverageExactly)->toBe(75.0); +}); + +test('parse threshold options sets showOnlyCovered', function () { + $plugin = new Coverage(new NullOutput); + + (fn () => $this->parseThresholdOptions(['--only-covered']))->call($plugin); + + expect($plugin->showOnlyCovered)->toBeTrue(); +}); + +test('parse threshold options ignores unrelated flags', function () { + $plugin = new Coverage(new NullOutput); + + (fn () => $this->parseThresholdOptions(['--compact', '--verbose']))->call($plugin); + + expect($plugin->coverageMin)->toBe(0.0) + ->and($plugin->coverageExactly)->toBeNull() + ->and($plugin->showOnlyCovered)->toBeFalse(); +}); + +test('detect shard parses equals format', function () { + $plugin = new Coverage(new NullOutput); + + $result = (fn () => $this->detectShard(['--shard=2/5']))->call($plugin); + + expect($result)->toBe([2, 5]); +}); + +test('detect shard parses space format', function () { + $plugin = new Coverage(new NullOutput); + + $result = (fn () => $this->detectShard(['--shard', '3/4']))->call($plugin); + + expect($result)->toBe([3, 4]); +}); + +test('detect shard returns null when absent', function () { + $plugin = new Coverage(new NullOutput); + + $result = (fn () => $this->detectShard(['--compact', '--coverage']))->call($plugin); + + expect($result)->toBeNull(); +}); + +test('has shards coverage flag detects --shards-coverage', function () { + $plugin = new Coverage(new NullOutput); + + expect((fn () => $this->hasShardsCoverageFlag(['--shards-coverage']))->call($plugin))->toBeTrue() + ->and((fn () => $this->hasShardsCoverageFlag(['--coverage']))->call($plugin))->toBeFalse(); +}); + +test('pop shards coverage flags removes --shards-coverage and --clean', function () { + $plugin = new Coverage(new NullOutput); + + $remaining = (fn () => $this->popShardsCoverageFlags(['--shards-coverage', '--min=80', '--clean']))->call($plugin); + $isClean = (fn () => $this->shardsCoverageClean)->call($plugin); + + expect($remaining)->toBe(['--min=80']) + ->and($isClean)->toBeTrue(); +}); + +test('strip shard blocked report flags removes --coverage-html', function () { + $output = new BufferedOutput; + $plugin = new Coverage($output); + + $filtered = (fn () => $this->stripShardBlockedReportFlags(['--coverage-html=out', '--compact']))->call($plugin); + + expect($filtered)->toBe(['--compact']) + ->and($output->fetch())->toContain('WARN'); +}); + +test('strip shard blocked report flags removes --coverage-clover as separate arg', function () { + $output = new BufferedOutput; + $plugin = new Coverage($output); + + $filtered = (fn () => $this->stripShardBlockedReportFlags(['--compact', '--coverage-clover', 'clover.xml']))->call($plugin); + + expect($filtered)->toBe(['--compact']) + ->and($output->fetch())->toContain('WARN'); +}); + +test('strip shard blocked report flags keeps non-blocked flags without warning', function () { + $output = new BufferedOutput; + $plugin = new Coverage($output); + + $filtered = (fn () => $this->stripShardBlockedReportFlags(['--compact', '--stop-on-failure']))->call($plugin); + + expect($filtered)->toBe(['--compact', '--stop-on-failure']) + ->and($output->fetch())->toBe(''); +}); From b5bcd853f3667490ce20b94dceb60bfb445a356e Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 24 Apr 2026 22:55:23 +0530 Subject: [PATCH 07/13] try --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index a23d0bc14..630d67393 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,9 @@ "phpunit/phpunit": "^13.3.0", "symfony/process": "^8.1.0" }, + "replace": { + "pestphp/pest": "*" + }, "conflict": { "filp/whoops": "<2.18.3", "phpunit/phpunit": ">13.3.0", From 65c4c77daa362e0376e4a653673bd8ad4c8b8ff3 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 24 Apr 2026 22:56:38 +0530 Subject: [PATCH 08/13] Revert "try" This reverts commit 9abe892adc60e9aabeb359f5c179a4e87b80a2bd. --- composer.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/composer.json b/composer.json index 630d67393..a23d0bc14 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,6 @@ "phpunit/phpunit": "^13.3.0", "symfony/process": "^8.1.0" }, - "replace": { - "pestphp/pest": "*" - }, "conflict": { "filp/whoops": "<2.18.3", "phpunit/phpunit": ">13.3.0", From 8fb4b09c6472648ca6496cd8bac0579eec031ab2 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 24 Apr 2026 23:40:28 +0530 Subject: [PATCH 09/13] update snapshot --- .../Visual/Help/visual_snapshot_of_help_command_output.snap | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap index e1c653d43..12195c641 100644 --- a/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap +++ b/tests/.pest/snapshots/Visual/Help/visual_snapshot_of_help_command_output.snap @@ -156,6 +156,8 @@ --coverage --min Set the minimum required coverage percentage, and fail if not met --coverage --exactly Set the exact required coverage percentage, and fail if not met --coverage --only-covered Hide files with 0% coverage from the code coverage report + --shards-coverage Merge .cov files from .pest/coverage/ and generate a combined coverage report + --shards-coverage --clean Delete .cov files after generating the combined coverage report --coverage-clover [file] Write code coverage report in Clover XML format to file --coverage-openclover [file] Write code coverage report in OpenClover XML format to file --coverage-cobertura [file] Write code coverage report in Cobertura XML format to file From 46762f435730e566ea9abcd499a28f2ffd613901 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 17 Jul 2026 15:02:35 +0530 Subject: [PATCH 10/13] Refactor coverage handling and improve error reporting in Coverage plugin --- src/Plugins/Coverage.php | 60 +++++++++++++++++++++++++++++--------- tests/Plugins/Coverage.php | 18 ++++++++++++ 2 files changed, 65 insertions(+), 13 deletions(-) diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 4f5504b9d..b86c2fb30 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -106,12 +106,7 @@ public function handleArguments(array $originals): array $originals = $this->popShardsCoverageFlags($originals); $this->parseThresholdOptions($originals); - $coverage = $this->mergeAndReportShardsCoverage(); - $exitCode = $this->applyThresholds($coverage); - - $this->output->writeln(['']); - - exit($exitCode); + $this->handleShardsCoverageMerge(); } $arguments = [...[''], ...array_values(array_filter($originals, function (string $original): bool { @@ -148,8 +143,15 @@ public function handleArguments(array $originals): array [$this->shardIndex, $this->shardTotal] = $shard; $coverageDir = $this->getCoverageDir(); - if (! is_dir($coverageDir)) { - mkdir($coverageDir, 0755, true); + if (! is_dir($coverageDir) && ! mkdir($coverageDir, 0755, true) && ! is_dir($coverageDir)) { + $this->output->writeln([ + '', + sprintf( + ' WARN Could not create coverage directory: %s', + $coverageDir, + ), + '', + ]); } $originals = $this->stripShardBlockedReportFlags($originals); @@ -218,6 +220,7 @@ public function addOutput(int $exitCode): int $this->output->writeln( "\n ERROR No code coverage driver is available.", ); + exit(1); } @@ -432,23 +435,46 @@ private function popShardsCoverageFlags(array $arguments): array return $filtered; } + /** + * Handles the --shards-coverage command: merges shard .cov files, + * applies thresholds, and terminates the process with the appropriate exit code. + */ + private function handleShardsCoverageMerge(): never + { + $coverage = $this->mergeAndReportShardsCoverage(); + + if ($coverage < 0) { + exit(1); + } + + $exitCode = $this->applyThresholds($coverage); + + $this->output->writeln(['']); + + exit($exitCode); + } + /** * Merges all shard .cov files and generates the requested coverage reports. + * + * @return float The total coverage percentage, or -1.0 on failure. */ private function mergeAndReportShardsCoverage(): float { $coverageDir = $this->getCoverageDir(); - $files = glob($coverageDir.DIRECTORY_SEPARATOR.'*.cov'); + $pattern = $coverageDir.DIRECTORY_SEPARATOR.'*.cov'; + $files = glob($pattern); if ($files === false || $files === []) { $this->output->writeln([ '', - ' ERROR No coverage files found in .pest/coverage.', + ' ERROR No coverage files found.', + sprintf(' Expected .cov files in: %s', $coverageDir), ' Run tests with --shard=X/Y --coverage first.', '', ]); - exit(1); + return -1.0; } $count = count($files); @@ -461,6 +487,9 @@ private function mergeAndReportShardsCoverage(): float ), ]); + /** @var list $files */ + sort($files); + $merged = null; foreach ($files as $file) { try { @@ -487,14 +516,19 @@ private function mergeAndReportShardsCoverage(): float '', ]); - exit(1); + return -1.0; } $result = \Pest\Support\Coverage::render($merged, $this->output, $this->compact, $this->showOnlyCovered); if ($this->shardsCoverageClean) { foreach ($files as $file) { - @unlink($file); + if (file_exists($file) && ! unlink($file)) { + $this->output->writeln(sprintf( + ' WARN Could not delete coverage file: %s', + basename($file), + )); + } } $this->output->writeln(' Coverage files cleaned.'); } diff --git a/tests/Plugins/Coverage.php b/tests/Plugins/Coverage.php index 44ca24b81..b8932f1d9 100644 --- a/tests/Plugins/Coverage.php +++ b/tests/Plugins/Coverage.php @@ -202,3 +202,21 @@ expect($filtered)->toBe(['--compact', '--stop-on-failure']) ->and($output->fetch())->toBe(''); }); + +test('getCoverageDir returns path under .pest/coverage', function () { + $plugin = new Coverage(new NullOutput); + + $path = (fn () => $this->getCoverageDir())->call($plugin); + + expect($path)->toEndWith('.pest'.DIRECTORY_SEPARATOR.'coverage'); +}); + +test('mergeAndReportShardsCoverage returns -1 when coverage directory is empty', function () { + $output = new BufferedOutput; + $plugin = new Coverage($output); + + $result = (fn () => $this->mergeAndReportShardsCoverage())->call($plugin); + + expect($result)->toBe(-1.0) + ->and($output->fetch())->toContain('ERROR'); +}); From b8e8eb25545d6b4c9338a2d5cdcb87fc4f9d7122 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Sat, 18 Jul 2026 17:08:08 +0530 Subject: [PATCH 11/13] lint --- tests/Features/Coverage.php | 10 ++++----- tests/Plugins/Coverage.php | 44 ++++++++++++++++++------------------- 2 files changed, 27 insertions(+), 27 deletions(-) diff --git a/tests/Features/Coverage.php b/tests/Features/Coverage.php index 15b185d87..ba0f0ff74 100644 --- a/tests/Features/Coverage.php +++ b/tests/Features/Coverage.php @@ -35,7 +35,7 @@ expect($plugin->coverageMin)->toEqual(2.4); }); -it('adds coverage if --exactly exist', function () { +it('adds coverage if --exactly exist', function (): void { $plugin = new CoveragePlugin(new ConsoleOutput); $plugin->handleArguments(['--exactly=50']); @@ -45,26 +45,26 @@ expect($plugin->coverageExactly)->toEqual(50.5); }); -it('adds coverage if --only-covered exist', function () { +it('adds coverage if --only-covered exist', function (): void { $plugin = new CoveragePlugin(new ConsoleOutput); $plugin->handleArguments(['--only-covered']); expect($plugin->showOnlyCovered)->toBeTrue(); }); -it('routes --coverage-php to .pest/coverage/{n}.cov when --shard is used', function () { +it('routes --coverage-php to .pest/coverage/{n}.cov when --shard is used', function (): void { $plugin = new CoveragePlugin(new ConsoleOutput); $arguments = $plugin->handleArguments(['--coverage', '--shard=1/3']); $phpIdx = array_search('--coverage-php', $arguments, true); - expect($phpIdx)->not->toBeFalse(); + expect($phpIdx)->toBeTrue(); $covPath = $arguments[$phpIdx + 1]; expect($covPath)->toEndWith('.pest'.DIRECTORY_SEPARATOR.'coverage'.DIRECTORY_SEPARATOR.'1.cov'); })->skip(! Coverage::isAvailable() || ! function_exists('xdebug_info') || ! in_array('coverage', xdebug_info('mode'), true), 'Coverage is not available'); -it('strips blocked report flags and warns when --shard is used', function () { +it('strips blocked report flags and warns when --shard is used', function (): void { $output = new BufferedOutput; $plugin = new CoveragePlugin($output); diff --git a/tests/Plugins/Coverage.php b/tests/Plugins/Coverage.php index b8932f1d9..2e635de88 100644 --- a/tests/Plugins/Coverage.php +++ b/tests/Plugins/Coverage.php @@ -21,7 +21,7 @@ [100.0, 100.0], ]); -test('apply thresholds', function (float $coverage, ?float $min, ?float $exactly, int $expectedExitCode) { +test('apply thresholds', function (float $coverage, ?float $min, ?float $exactly, int $expectedExitCode): void { $output = new BufferedOutput; $plugin = new Coverage($output); $plugin->coverageMin = $min ?? 0.0; @@ -41,7 +41,7 @@ 'exactly fail' => [91.5, null, 95.0, 1], ]); -test('strip shard blocked report flags', function (array $args, array $expected, bool $expectWarn) { +test('strip shard blocked report flags', function (array $args, array $expected, bool $expectWarn): void { $output = new BufferedOutput; $plugin = new Coverage($output); @@ -51,14 +51,14 @@ $expectWarn ? expect($output->fetch())->toContain('WARN') - : expect($output->fetch())->toBe(''); + : expect($output->fetch())->toBeEmpty(); })->with([ 'inline value flag' => [['--coverage-html=out', '--compact'], ['--compact'], true], 'space-separated flag' => [['--compact', '--coverage-clover', 'clover.xml'], ['--compact'], true], 'no blocked flags' => [['--compact', '--stop-on-failure'], ['--compact', '--stop-on-failure'], false], ]); -test('apply thresholds returns 0 when coverage meets min', function () { +test('apply thresholds returns 0 when coverage meets min', function (): void { $plugin = new Coverage(new NullOutput); $plugin->coverageMin = 80.0; @@ -67,7 +67,7 @@ expect($exitCode)->toBe(0); }); -test('apply thresholds returns 1 and writes FAIL when coverage is below min', function () { +test('apply thresholds returns 1 and writes FAIL when coverage is below min', function (): void { $output = new BufferedOutput; $plugin = new Coverage($output); $plugin->coverageMin = 95.0; @@ -78,7 +78,7 @@ ->and($output->fetch())->toContain('95.0')->toContain('91.5'); }); -test('apply thresholds returns 0 when coverage matches exactly', function () { +test('apply thresholds returns 0 when coverage matches exactly', function (): void { $plugin = new Coverage(new NullOutput); $plugin->coverageExactly = 91.5; @@ -87,7 +87,7 @@ expect($exitCode)->toBe(0); }); -test('apply thresholds returns 1 and writes FAIL when coverage does not match exactly', function () { +test('apply thresholds returns 1 and writes FAIL when coverage does not match exactly', function (): void { $output = new BufferedOutput; $plugin = new Coverage($output); $plugin->coverageExactly = 95.0; @@ -98,7 +98,7 @@ ->and($output->fetch())->toContain('95.0')->toContain('91.5'); }); -test('parse threshold options sets coverageMin', function () { +test('parse threshold options sets coverageMin', function (): void { $plugin = new Coverage(new NullOutput); (fn () => $this->parseThresholdOptions(['--min=42.5']))->call($plugin); @@ -106,7 +106,7 @@ expect($plugin->coverageMin)->toBe(42.5); }); -test('parse threshold options sets coverageExactly', function () { +test('parse threshold options sets coverageExactly', function (): void { $plugin = new Coverage(new NullOutput); (fn () => $this->parseThresholdOptions(['--exactly=75.0']))->call($plugin); @@ -114,7 +114,7 @@ expect($plugin->coverageExactly)->toBe(75.0); }); -test('parse threshold options sets showOnlyCovered', function () { +test('parse threshold options sets showOnlyCovered', function (): void { $plugin = new Coverage(new NullOutput); (fn () => $this->parseThresholdOptions(['--only-covered']))->call($plugin); @@ -122,7 +122,7 @@ expect($plugin->showOnlyCovered)->toBeTrue(); }); -test('parse threshold options ignores unrelated flags', function () { +test('parse threshold options ignores unrelated flags', function (): void { $plugin = new Coverage(new NullOutput); (fn () => $this->parseThresholdOptions(['--compact', '--verbose']))->call($plugin); @@ -132,7 +132,7 @@ ->and($plugin->showOnlyCovered)->toBeFalse(); }); -test('detect shard parses equals format', function () { +test('detect shard parses equals format', function (): void { $plugin = new Coverage(new NullOutput); $result = (fn () => $this->detectShard(['--shard=2/5']))->call($plugin); @@ -140,7 +140,7 @@ expect($result)->toBe([2, 5]); }); -test('detect shard parses space format', function () { +test('detect shard parses space format', function (): void { $plugin = new Coverage(new NullOutput); $result = (fn () => $this->detectShard(['--shard', '3/4']))->call($plugin); @@ -148,7 +148,7 @@ expect($result)->toBe([3, 4]); }); -test('detect shard returns null when absent', function () { +test('detect shard returns null when absent', function (): void { $plugin = new Coverage(new NullOutput); $result = (fn () => $this->detectShard(['--compact', '--coverage']))->call($plugin); @@ -156,14 +156,14 @@ expect($result)->toBeNull(); }); -test('has shards coverage flag detects --shards-coverage', function () { +test('has shards coverage flag detects --shards-coverage', function (): void { $plugin = new Coverage(new NullOutput); expect((fn () => $this->hasShardsCoverageFlag(['--shards-coverage']))->call($plugin))->toBeTrue() ->and((fn () => $this->hasShardsCoverageFlag(['--coverage']))->call($plugin))->toBeFalse(); }); -test('pop shards coverage flags removes --shards-coverage and --clean', function () { +test('pop shards coverage flags removes --shards-coverage and --clean', function (): void { $plugin = new Coverage(new NullOutput); $remaining = (fn () => $this->popShardsCoverageFlags(['--shards-coverage', '--min=80', '--clean']))->call($plugin); @@ -173,7 +173,7 @@ ->and($isClean)->toBeTrue(); }); -test('strip shard blocked report flags removes --coverage-html', function () { +test('strip shard blocked report flags removes --coverage-html', function (): void { $output = new BufferedOutput; $plugin = new Coverage($output); @@ -183,7 +183,7 @@ ->and($output->fetch())->toContain('WARN'); }); -test('strip shard blocked report flags removes --coverage-clover as separate arg', function () { +test('strip shard blocked report flags removes --coverage-clover as separate arg', function (): void { $output = new BufferedOutput; $plugin = new Coverage($output); @@ -193,17 +193,17 @@ ->and($output->fetch())->toContain('WARN'); }); -test('strip shard blocked report flags keeps non-blocked flags without warning', function () { +test('strip shard blocked report flags keeps non-blocked flags without warning', function (): void { $output = new BufferedOutput; $plugin = new Coverage($output); $filtered = (fn () => $this->stripShardBlockedReportFlags(['--compact', '--stop-on-failure']))->call($plugin); expect($filtered)->toBe(['--compact', '--stop-on-failure']) - ->and($output->fetch())->toBe(''); + ->and($output->fetch())->toBeEmpty(); }); -test('getCoverageDir returns path under .pest/coverage', function () { +test('getCoverageDir returns path under .pest/coverage', function (): void { $plugin = new Coverage(new NullOutput); $path = (fn () => $this->getCoverageDir())->call($plugin); @@ -211,7 +211,7 @@ expect($path)->toEndWith('.pest'.DIRECTORY_SEPARATOR.'coverage'); }); -test('mergeAndReportShardsCoverage returns -1 when coverage directory is empty', function () { +test('mergeAndReportShardsCoverage returns -1 when coverage directory is empty', function (): void { $output = new BufferedOutput; $plugin = new Coverage($output); From 385f3bea1af0e54635dd8c8b5c04ea83a2461c11 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Sun, 19 Jul 2026 16:31:54 +0530 Subject: [PATCH 12/13] adjustments for phpunit 13 --- src/Plugins/Coverage.php | 29 ++++++++++++++++++++++------- src/Support/Coverage.php | 3 +-- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index b86c2fb30..b8f9e9148 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -490,15 +490,23 @@ private function mergeAndReportShardsCoverage(): float /** @var list $files */ sort($files); - $merged = null; + $mergedCoverage = null; + $mergedTestResults = []; + $mergedBuildInfo = null; + $mergedBasePath = null; foreach ($files as $file) { try { - /** @var CodeCoverage $coverage */ - $coverage = require $file; - if ($merged === null) { - $merged = $coverage; + /** @var array{buildInformation: array, basePath: string, codeCoverage: \SebastianBergmann\CodeCoverage\Data\ProcessedCodeCoverageData, testResults: array} $data */ + $data = require $file; + + if ($mergedCoverage === null) { + $mergedCoverage = clone $data['codeCoverage']; + $mergedTestResults = $data['testResults']; + $mergedBuildInfo = $data['buildInformation']; + $mergedBasePath = $data['basePath']; } else { - $merged->merge($coverage); + $mergedCoverage->merge($data['codeCoverage']); + $mergedTestResults = array_merge($mergedTestResults, $data['testResults']); } } catch (Throwable $e) { $this->output->writeln(sprintf( @@ -509,7 +517,7 @@ private function mergeAndReportShardsCoverage(): float } } - if ($merged === null) { + if ($mergedCoverage === null) { $this->output->writeln([ '', ' ERROR No valid coverage files could be loaded.', @@ -519,6 +527,13 @@ private function mergeAndReportShardsCoverage(): float return -1.0; } + $merged = [ + 'buildInformation' => $mergedBuildInfo, + 'basePath' => $mergedBasePath, + 'codeCoverage' => $mergedCoverage, + 'testResults' => $mergedTestResults, + ]; + $result = \Pest\Support\Coverage::render($merged, $this->output, $this->compact, $this->showOnlyCovered); if ($this->shardsCoverageClean) { diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php index a31dc384a..25b69d75b 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -89,9 +89,8 @@ public static function report(OutputInterface $output, bool $compact = false, bo /** * Renders the coverage report to the console and returns the total coverage as float. */ - public static function render(CodeCoverage $codeCoverage, OutputInterface $output, bool $compact = false, bool $showOnlyCovered = false): float + public static function render(CodeCoverage|array $codeCoverage, OutputInterface $output, bool $compact = false, bool $showOnlyCovered = false): float { - // @phpstan-ignore-next-line if (is_array($codeCoverage)) { $facade = Facade::fromSerializedData($codeCoverage); From fc19cda6456519d0ba955e682c9325b0a831d0d8 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 21 Aug 2026 23:15:12 +0530 Subject: [PATCH 13/13] fix: use not->toBeFalse for array_search result in shard coverage test --- tests/Features/Coverage.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Features/Coverage.php b/tests/Features/Coverage.php index ba0f0ff74..c273e7db3 100644 --- a/tests/Features/Coverage.php +++ b/tests/Features/Coverage.php @@ -58,7 +58,7 @@ $arguments = $plugin->handleArguments(['--coverage', '--shard=1/3']); $phpIdx = array_search('--coverage-php', $arguments, true); - expect($phpIdx)->toBeTrue(); + expect($phpIdx)->not->toBeFalse(); $covPath = $arguments[$phpIdx + 1]; expect($covPath)->toEndWith('.pest'.DIRECTORY_SEPARATOR.'coverage'.DIRECTORY_SEPARATOR.'1.cov');