From becb6e067d76a390976544dded2d9d36be5932c0 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 23 Apr 2026 23:27:33 +0530 Subject: [PATCH 01/16] 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 50bbe8e3c..4cb6f4596 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', + ]; + /** * Whether it should show the coverage or not. */ @@ -52,6 +72,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; + /** * Creates a new Plugin instance. */ @@ -65,6 +95,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)) { @@ -92,8 +127,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()) { @@ -148,6 +200,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( @@ -193,4 +260,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 12d03532c..d43822b47 100644 --- a/src/Plugins/Help.php +++ b/src/Plugins/Help.php @@ -180,6 +180,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 f3968bc95..2ed5f7f73 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -96,6 +96,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 2fb6c24b517dc9c64ffe38aae1b95cf59f2e0896 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 23 Apr 2026 23:39:17 +0530 Subject: [PATCH 02/16] 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 4cb6f4596..6d9e77359 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', ]; /** @@ -82,6 +84,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; + /** * Creates a new Plugin instance. */ @@ -95,9 +107,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 { @@ -200,6 +214,12 @@ public function addOutput(int $exitCode): int return $exitCode; } + if ($this->shardsCoverage) { + $this->mergeAndReportShardsCoverage(); + + return $exitCode; + } + if ($this->shardIndex !== null) { $this->output->writeln([ '', @@ -208,7 +228,7 @@ public function addOutput(int $exitCode): int $this->shardIndex, $this->shardTotal, ), - ' Run: pest coverage:report', + ' Run: pest --shards-coverage', '', ]); @@ -342,7 +362,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), '', ]); } @@ -351,37 +371,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'); @@ -389,10 +421,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); @@ -430,34 +463,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 d43822b47..617bab9b1 100644 --- a/src/Plugins/Help.php +++ b/src/Plugins/Help.php @@ -181,20 +181,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 b436387d6cc3b0d07bb7a7a63181998c438f85fa Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 23 Apr 2026 23:40:16 +0530 Subject: [PATCH 03/16] 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 6d9e77359..8aa6fdf7a 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -108,10 +108,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 3a2e98027ea87cbc0fbf00af3191683128028448 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 23 Apr 2026 23:43:09 +0530 Subject: [PATCH 04/16] 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 8aa6fdf7a..3145a4689 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -108,10 +108,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 { @@ -214,12 +270,6 @@ public function addOutput(int $exitCode): int return $exitCode; } - if ($this->shardsCoverage) { - $this->mergeAndReportShardsCoverage(); - - return $exitCode; - } - if ($this->shardIndex !== null) { $this->output->writeln([ '', @@ -412,7 +462,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'); @@ -425,7 +475,7 @@ private function mergeAndReportShardsCoverage(): void '', ]); - return; + exit(1); } $count = count($files); @@ -464,10 +514,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) { @@ -476,6 +526,6 @@ private function mergeAndReportShardsCoverage(): void $this->output->writeln(' Coverage files cleaned.'); } - $this->output->writeln(''); + return $result; } } From 8458bec8c5fced9115df7750c8939b6fb9bd3752 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Thu, 23 Apr 2026 23:48:18 +0530 Subject: [PATCH 05/16] feat: refactor shard coverage handling by simplifying argument parsing and threshold evaluation --- src/Plugins/Coverage.php | 161 +++++++++++++++++---------------------- 1 file changed, 69 insertions(+), 92 deletions(-) diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 3145a4689..c1251087a 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -84,11 +84,6 @@ 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. */ @@ -109,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(['']); @@ -236,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; @@ -294,30 +222,79 @@ 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(['']); + } - $exitCode = $comparableCoverage === $comparableCoverageExactly ? 0 : 1; + return $exitCode; + } - 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), - )); + /** + * 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 (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; From e58d49b3d30ac70e540c21e6a7720d09f006589f Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 24 Apr 2026 00:07:12 +0530 Subject: [PATCH 06/16] 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 c5bc077968ef01d2fafff1c26ee44e4542988857 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 24 Apr 2026 22:55:23 +0530 Subject: [PATCH 07/16] try --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index a2379b3da..08a1ff7b6 100644 --- a/composer.json +++ b/composer.json @@ -28,6 +28,9 @@ "phpunit/phpunit": "^13.2.4", "symfony/process": "^8.1.0" }, + "replace": { + "pestphp/pest": "*" + }, "conflict": { "filp/whoops": "<2.18.3", "phpunit/phpunit": ">13.2.4", From 2f187056f3b22054b5543c8c6e7e865e1dfa25dd Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 24 Apr 2026 22:56:38 +0530 Subject: [PATCH 08/16] Revert "try" This reverts commit 9abe892adc60e9aabeb359f5c179a4e87b80a2bd. --- composer.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/composer.json b/composer.json index 08a1ff7b6..a2379b3da 100644 --- a/composer.json +++ b/composer.json @@ -28,9 +28,6 @@ "phpunit/phpunit": "^13.2.4", "symfony/process": "^8.1.0" }, - "replace": { - "pestphp/pest": "*" - }, "conflict": { "filp/whoops": "<2.18.3", "phpunit/phpunit": ">13.2.4", From e50c99986fc2be16716ec7ac541abee9567fd5d1 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 24 Apr 2026 23:40:28 +0530 Subject: [PATCH 09/16] 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 5555c2b4a..ea4e21a93 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 @@ -146,6 +146,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 5c80979c206e6e08058605b1d36ba85e99bb054a Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 24 Apr 2026 23:53:06 +0530 Subject: [PATCH 10/16] update snapshot --- tests/.snapshots/success.txt | 63 +++++++++++++++++++++++++++++++++--- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 755f02248..5e17c9ec7 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -77,6 +77,10 @@ ✓ it has plugin - it adds coverage if --coverage exist → Coverage is not available ✓ it adds coverage if --min exist + ✓ it adds coverage if --exactly exist + ✓ it adds coverage if --only-covered exist + - it routes --coverage-php to .pest/coverage/{n}.cov when --shard is used → Coverage is not available + - it strips blocked report flags and warns when --shard is used → Coverage is not available ✓ it generates coverage based on file input PASS Tests\Features\Covers\ClassCoverage @@ -1688,6 +1692,29 @@ ✓ compute comparable coverage with (32.53333333333333, 32.5) ✓ compute comparable coverage with (32.57777771232132, 32.5) ✓ compute comparable coverage with (100.0, 100.0) + ✓ apply thresholds with dataset "min pass" + ✓ apply thresholds with dataset "min fail" + ✓ apply thresholds with dataset "exactly pass" + ✓ apply thresholds with dataset "exactly fail" + ✓ strip shard blocked report flags with dataset "inline value flag" + ✓ strip shard blocked report flags with dataset "space-separated flag" + ✓ strip shard blocked report flags with dataset "no blocked flags" + ✓ apply thresholds returns 0 when coverage meets min + ✓ apply thresholds returns 1 and writes FAIL when coverage is below min + ✓ apply thresholds returns 0 when coverage matches exactly + ✓ apply thresholds returns 1 and writes FAIL when coverage does not match exactly + ✓ parse threshold options sets coverageMin + ✓ parse threshold options sets coverageExactly + ✓ parse threshold options sets showOnlyCovered + ✓ parse threshold options ignores unrelated flags + ✓ detect shard parses equals format + ✓ detect shard parses space format + ✓ detect shard returns null when absent + ✓ has shards coverage flag detects --shards-coverage + ✓ pop shards coverage flags removes --shards-coverage and --clean + ✓ strip shard blocked report flags removes --coverage-html + ✓ strip shard blocked report flags removes --coverage-clover as separate arg + ✓ strip shard blocked report flags keeps non-blocked flags without warning PASS Tests\Plugins\Traits ✓ it allows global uses @@ -2098,8 +2125,8 @@ ✓ junit output ✓ junit with parallel - PASS Tests\Visual\Parallel - ✓ parallel + FAIL Tests\Visual\Parallel + ⨯ parallel ✓ a parallel test can extend another test with same name ✓ parallel reports invalid datasets as failures ✓ parallel can have multiple exclude-groups @@ -2135,5 +2162,33 @@ PASS Testsexternal\Features\Expect\toMatchSnapshot ✓ pass with dataset with ('my-datas-set-value') ✓ within describe → pass with dataset with ('my-datas-set-value') - - Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1504 passed (3281 assertions) \ No newline at end of file + ──────────────────────────────────────────────────────────────────────────── + FAILED Tests\Visual\Parallel > parallel + Expected: \n + .......ss...s...............................sssssss.s...ss..................\n + ............................................................................\n + ....................................................................s.......\n + .............................s.ssss.........!!..............................\n + ............................................................................\n + ............................................................................\n + ............................................................................\n + ............................................................................\n + ............................................................................\n + ............................................................................\n + ............................................................................\n + ...............................!!.............!!!!...!..............!!......\n + ..........s......s..........................................................\n + ............................................................................\n + ..................................................s.ssss.ssssss.ss.s..s...s.\n + ......sss.sssssssssssss.s..ssssssssss.ss!!!......ss..s......................\n + ............................................................................\n + ............................................................................\n + ..................\n + \n + Tests: 2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 29 skipped, 1303 passed (2962 assertions)\n + Duration: 2.87s\n + \n + Parallel: 3 processes\n + \n + + Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1504 passed (3281 assertions) From c2d75028285efc1670f13c599980dd18bbf62779 Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 24 Apr 2026 23:53:22 +0530 Subject: [PATCH 11/16] update snapshot --- tests/.snapshots/success.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 5e17c9ec7..8a22c998d 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -2125,8 +2125,8 @@ ✓ junit output ✓ junit with parallel - FAIL Tests\Visual\Parallel - ⨯ parallel + PASS Tests\Visual\Parallel + ✓ parallel ✓ a parallel test can extend another test with same name ✓ parallel reports invalid datasets as failures ✓ parallel can have multiple exclude-groups From a2eafaac8c3058856616c42ad0e83f56bc05ef8d Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 17 Jul 2026 15:02:35 +0530 Subject: [PATCH 12/16] Refactor coverage handling and improve error reporting in Coverage plugin --- src/Plugins/Coverage.php | 60 ++++++++++++++++++++++++++++-------- tests/.snapshots/success.txt | 7 +++-- tests/Plugins/Coverage.php | 18 +++++++++++ 3 files changed, 70 insertions(+), 15 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/.snapshots/success.txt b/tests/.snapshots/success.txt index 8a22c998d..f2a266252 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -80,8 +80,8 @@ ✓ it adds coverage if --exactly exist ✓ it adds coverage if --only-covered exist - it routes --coverage-php to .pest/coverage/{n}.cov when --shard is used → Coverage is not available - - it strips blocked report flags and warns when --shard is used → Coverage is not available - ✓ it generates coverage based on file input + - it strips blocked report flags and warns when --shard is used → Coverage is not available + ✓ it generates coverage based on file input PASS Tests\Features\Covers\ClassCoverage ✓ it uses the correct PHPUnit attribute for class @@ -1715,6 +1715,9 @@ ✓ strip shard blocked report flags removes --coverage-html ✓ strip shard blocked report flags removes --coverage-clover as separate arg ✓ strip shard blocked report flags keeps non-blocked flags without warning + ✓ coverage error flag prevents coverage from being enabled + ✓ getCoverageDir returns path under .pest/coverage + ✓ mergeAndReportShardsCoverage returns -1 when coverage directory is empty PASS Tests\Plugins\Traits ✓ it allows global uses 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 c727d8949f6a70751e25624755a3b685d9e5562f Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Fri, 17 Jul 2026 15:05:48 +0530 Subject: [PATCH 13/16] update snapshot --- tests/.snapshots/success.txt | 33 ++------------------------------- 1 file changed, 2 insertions(+), 31 deletions(-) diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index f2a266252..a2373f67c 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -80,8 +80,8 @@ ✓ it adds coverage if --exactly exist ✓ it adds coverage if --only-covered exist - it routes --coverage-php to .pest/coverage/{n}.cov when --shard is used → Coverage is not available - - it strips blocked report flags and warns when --shard is used → Coverage is not available - ✓ it generates coverage based on file input + - it strips blocked report flags and warns when --shard is used → Coverage is not available + ✓ it generates coverage based on file input PASS Tests\Features\Covers\ClassCoverage ✓ it uses the correct PHPUnit attribute for class @@ -1715,7 +1715,6 @@ ✓ strip shard blocked report flags removes --coverage-html ✓ strip shard blocked report flags removes --coverage-clover as separate arg ✓ strip shard blocked report flags keeps non-blocked flags without warning - ✓ coverage error flag prevents coverage from being enabled ✓ getCoverageDir returns path under .pest/coverage ✓ mergeAndReportShardsCoverage returns -1 when coverage directory is empty @@ -2165,33 +2164,5 @@ PASS Testsexternal\Features\Expect\toMatchSnapshot ✓ pass with dataset with ('my-datas-set-value') ✓ within describe → pass with dataset with ('my-datas-set-value') - ──────────────────────────────────────────────────────────────────────────── - FAILED Tests\Visual\Parallel > parallel - Expected: \n - .......ss...s...............................sssssss.s...ss..................\n - ............................................................................\n - ....................................................................s.......\n - .............................s.ssss.........!!..............................\n - ............................................................................\n - ............................................................................\n - ............................................................................\n - ............................................................................\n - ............................................................................\n - ............................................................................\n - ............................................................................\n - ...............................!!.............!!!!...!..............!!......\n - ..........s......s..........................................................\n - ............................................................................\n - ..................................................s.ssss.ssssss.ss.s..s...s.\n - ......sss.sssssssssssss.s..ssssssssss.ss!!!......ss..s......................\n - ............................................................................\n - ............................................................................\n - ..................\n - \n - Tests: 2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 29 skipped, 1303 passed (2962 assertions)\n - Duration: 2.87s\n - \n - Parallel: 3 processes\n - \n Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1504 passed (3281 assertions) From 36167080bbc3711d9fdef1cd560b09316291485d Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Sat, 18 Jul 2026 17:08:08 +0530 Subject: [PATCH 14/16] 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 a99c26d69ee882a91c4c0983aa97de5042b8c6ce Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Sat, 18 Jul 2026 17:11:24 +0530 Subject: [PATCH 15/16] fix snapshot --- tests/.snapshots/success.txt | 140 +++++++++++++++++------------------ tests/Visual/Parallel.php | 4 +- 2 files changed, 72 insertions(+), 72 deletions(-) diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index a2373f67c..979251815 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -1909,75 +1909,75 @@ ✓ isTestFile() → it honours a bare .php suffix ✓ isTestFile() → it does not require a dot before the suffix - PASS Tests\Unit\Plugins\Tia\ViteDepsHelper - ✓ it strips JSONC down to something JSON.parse accepts with ('plain-object') - ✓ it strips JSONC down to something JSON.parse accepts with ('plain-array') - ✓ it strips JSONC down to something JSON.parse accepts with ('nested') - ✓ it strips JSONC down to something JSON.parse accepts with ('empty-object') - ✓ it strips JSONC down to something JSON.parse accepts with ('empty-array') - ✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-own-line') - ✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-trailing') - ✓ it strips JSONC down to something JSON.parse accepts with ('leading-line-comments') - ✓ it strips JSONC down to something JSON.parse accepts with ('line-comment-eof-no-newline') - ✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-leading') - ✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-inline') - ✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-multiline') - ✓ it strips JSONC down to something JSON.parse accepts with ('block-comment-eof') - ✓ it strips JSONC down to something JSON.parse accepts with ('jsdoc-style-block') - ✓ it strips JSONC down to something JSON.parse accepts with ('block-contains-double-slash') - ✓ it strips JSONC down to something JSON.parse accepts with ('line-then-block') - ✓ it strips JSONC down to something JSON.parse accepts with ('comment-with-quotes') - ✓ it strips JSONC down to something JSON.parse accepts with ('comment-with-braces-commas') - ✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-object') - ✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-array') - ✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-nested') - ✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-newline') - ✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-then-block') - ✓ it strips JSONC down to something JSON.parse accepts with ('trailing-comma-then-line') - ✓ it strips JSONC down to something JSON.parse accepts with ('crlf-line-comment') - ✓ it strips JSONC down to something JSON.parse accepts with ('tabs-whitespace') - ✓ it strips JSONC down to something JSON.parse accepts with ('url-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('glob-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('path-alias-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('block-open-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('block-close-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('block-both-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('star-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('single-slashes-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('escaped-quote-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('escaped-backslash-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('backslash-then-slash') - ✓ it strips JSONC down to something JSON.parse accepts with ('newline-escape-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('unicode-in-string') - ✓ it strips JSONC down to something JSON.parse accepts with ('block-secret') - ✓ it never touches comment-looking sequences inside string values - ✓ it removes the comment body entirely - ✓ it builds the expected alias map from a tsconfig with ('basic') - ✓ it builds the expected alias map from a tsconfig with ('multiple-aliases') - ✓ it builds the expected alias map from a tsconfig with ('default-baseUrl') - ✓ it builds the expected alias map from a tsconfig with ('baseUrl-subdir') - ✓ it builds the expected alias map from a tsconfig with ('jsconfig-fallback') - ✓ it builds the expected alias map from a tsconfig with ('first-target-wins') - ✓ it builds the expected alias map from a tsconfig with ('multiple-keys-mixed') - ✓ it builds the expected alias map from a tsconfig with ('no-paths') - ✓ it builds the expected alias map from a tsconfig with ('no-compiler-options') - ✓ it builds the expected alias map from a tsconfig with ('key-without-glob') - ✓ it builds the expected alias map from a tsconfig with ('target-without-glob') - ✓ it builds the expected alias map from a tsconfig with ('target-not-array') - ✓ it builds the expected alias map from a tsconfig with ('target-empty-array') - ✓ it builds the expected alias map from a tsconfig with ('commented-tsconfig') - ✓ it builds the expected alias map from a tsconfig with ('trailing-comma-paths') - ✓ it builds the expected alias map from a tsconfig with ('malformed-json') - ✓ it builds the expected alias map from a tsconfig with ('missing-config') - ✓ it builds the expected alias map from a tsconfig with ('tsconfig-precedence') - ✓ it builds the expected alias map from a vite config with ('root-slash-literal') - ✓ it builds the expected alias map from a vite config with ('path-resolve') - ✓ it builds the expected alias map from a vite config with ('file-url') - ✓ it builds the expected alias map from a vite config with ('tilde-alias') - ✓ it builds the expected alias map from a vite config with ('multiple-aliases') - ✓ it builds the expected alias map from a vite config with ('laravel-plugin-default') - ✓ it builds the expected alias map from a vite config with ('no-alias-no-plugin') - ✓ it builds the expected alias map from a vite config with ('no-config') + WARN Tests\Unit\Plugins\Tia\ViteDepsHelper + - it strips JSONC down to something JSON.parse accepts with ('plain-object') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('plain-array') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('nested') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('empty-object') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('empty-array') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('line-comment-own-line') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('line-comment-trailing') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('leading-line-comments') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('line-comment-eof-no-newline') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('block-comment-leading') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('block-comment-inline') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('block-comment-multiline') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('block-comment-eof') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('jsdoc-style-block') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('block-contains-double-slash') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('line-then-block') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('comment-with-quotes') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('comment-with-braces-commas') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('trailing-comma-object') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('trailing-comma-array') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('trailing-comma-nested') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('trailing-comma-newline') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('trailing-comma-then-block') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('trailing-comma-then-line') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('crlf-line-comment') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('tabs-whitespace') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('url-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('glob-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('path-alias-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('block-open-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('block-close-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('block-both-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('star-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('single-slashes-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('escaped-quote-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('escaped-backslash-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('backslash-then-slash') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('newline-escape-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('unicode-in-string') → node is not available. + - it strips JSONC down to something JSON.parse accepts with ('block-secret') → node is not available. + - it never touches comment-looking sequences inside string values → node is not available. + - it removes the comment body entirely → node is not available. + - it builds the expected alias map from a tsconfig with ('basic') → node is not available. + - it builds the expected alias map from a tsconfig with ('multiple-aliases') → node is not available. + - it builds the expected alias map from a tsconfig with ('default-baseUrl') → node is not available. + - it builds the expected alias map from a tsconfig with ('baseUrl-subdir') → node is not available. + - it builds the expected alias map from a tsconfig with ('jsconfig-fallback') → node is not available. + - it builds the expected alias map from a tsconfig with ('first-target-wins') → node is not available. + - it builds the expected alias map from a tsconfig with ('multiple-keys-mixed') → node is not available. + - it builds the expected alias map from a tsconfig with ('no-paths') → node is not available. + - it builds the expected alias map from a tsconfig with ('no-compiler-options') → node is not available. + - it builds the expected alias map from a tsconfig with ('key-without-glob') → node is not available. + - it builds the expected alias map from a tsconfig with ('target-without-glob') → node is not available. + - it builds the expected alias map from a tsconfig with ('target-not-array') → node is not available. + - it builds the expected alias map from a tsconfig with ('target-empty-array') → node is not available. + - it builds the expected alias map from a tsconfig with ('commented-tsconfig') → node is not available. + - it builds the expected alias map from a tsconfig with ('trailing-comma-paths') → node is not available. + - it builds the expected alias map from a tsconfig with ('malformed-json') → node is not available. + - it builds the expected alias map from a tsconfig with ('missing-config') → node is not available. + - it builds the expected alias map from a tsconfig with ('tsconfig-precedence') → node is not available. + - it builds the expected alias map from a vite config with ('root-slash-literal') → node is not available. + - it builds the expected alias map from a vite config with ('path-resolve') → node is not available. + - it builds the expected alias map from a vite config with ('file-url') → node is not available. + - it builds the expected alias map from a vite config with ('tilde-alias') → node is not available. + - it builds the expected alias map from a vite config with ('multiple-aliases') → node is not available. + - it builds the expected alias map from a vite config with ('laravel-plugin-default') → node is not available. + - it builds the expected alias map from a vite config with ('no-alias-no-plugin') → node is not available. + - it builds the expected alias map from a vite config with ('no-config') → node is not available. PASS Tests\Unit\Preset ✓ preset invalid name @@ -2165,4 +2165,4 @@ ✓ pass with dataset with ('my-datas-set-value') ✓ within describe → pass with dataset with ('my-datas-set-value') - Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1504 passed (3281 assertions) + Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 105 skipped, 1463 passed (3216 assertions) \ No newline at end of file diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index 13c30522f..c5541198c 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -24,13 +24,13 @@ $file = file_get_contents(__FILE__); $file = preg_replace( '/\$expected = \'.*?\';/', - "\$expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1487 passed (3228 assertions)';", + "\$expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 97 skipped, 1446 passed (3163 assertions)';", $file, ); file_put_contents(__FILE__, $file); } - $expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1487 passed (3228 assertions)'; + $expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 97 skipped, 1446 passed (3163 assertions)'; expect($output) ->toContain("Tests: {$expected}") From beff096a64a86377d387535b49fe76f6d91f5caa Mon Sep 17 00:00:00 2001 From: Punyapal Shah Date: Sun, 19 Jul 2026 16:31:54 +0530 Subject: [PATCH 16/16] 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 2ed5f7f73..08822e708 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -102,9 +102,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);