diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 2bfd40108..95f007211 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -53,6 +53,8 @@ jobs: static-php-8.3-composer- - name: Install Dependencies + env: + COMPOSER_ROOT_VERSION: 4.x-dev run: composer update --${{ matrix.dependency-version }} --no-interaction --no-progress --ansi - name: Profanity Check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ce3b3349b..f3cfca9ca 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,12 +24,12 @@ jobs: fail-fast: true matrix: os: [ubuntu-latest, macos-latest] # windows-latest - symfony: ['7.4', '8.0'] + symfony: ['7.4.8', '8.0.8'] php: ['8.3', '8.4', '8.5'] dependency_version: [prefer-stable] exclude: - php: '8.3' - symfony: '8.0' + symfony: '8.0.8' name: PHP ${{ matrix.php }} - Symfony ^${{ matrix.symfony }} - ${{ matrix.os }} - ${{ matrix.dependency_version }} @@ -66,6 +66,8 @@ jobs: - name: Install PHP dependencies shell: bash + env: + COMPOSER_ROOT_VERSION: 4.x-dev run: composer update --${{ matrix.dependency_version }} --no-interaction --no-progress --ansi --with="symfony/console:^${{ matrix.symfony }}" - name: Unit Tests diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index ed7b12141..be8882ab4 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -7,10 +7,13 @@ use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Support\Str; +use Pest\TestSuite; +use SebastianBergmann\CodeCoverage\CodeCoverage; 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 @@ -25,6 +28,25 @@ 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' => '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', + ]; + /** * Whether it should show the coverage or not. */ @@ -50,6 +72,21 @@ 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; + + /** + * Whether to delete .cov files after generating the shards coverage report. + */ + private bool $shardsCoverageClean = false; + /** * Creates a new Plugin instance. */ @@ -63,6 +100,18 @@ public function __construct(private readonly OutputInterface $output) */ public function handleArguments(array $originals): array { + if ($this->hasShardsCoverageFlag($originals)) { + $originals = $this->popShardsCoverageFlags($originals); + $this->parseThresholdOptions($originals); + + $coverage = $this->mergeAndReportShardsCoverage(); + $exitCode = $this->applyThresholds($coverage); + + $this->output->writeln(['']); + + exit($exitCode); + } + $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 +141,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()) { @@ -114,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; @@ -148,6 +198,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 --shards-coverage', + '', + ]); + + return $exitCode; + } + if ($exitCode === 0 && $this->coverage) { if (! \Pest\Support\Coverage::isAvailable()) { $this->output->writeln( @@ -157,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; } - } elseif ($exitCode === 1) { + + if (Str::startsWith($original, sprintf('--%s=', $option))) { + return true; + } + } + + 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; @@ -193,4 +307,198 @@ 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 --shards-coverage --%s', $firstHint), + '', + ]); + } + + return $filtered; + } + + /** + * Detects whether --shards-coverage is present in the arguments. + * + * @param array $arguments + */ + private function hasShardsCoverageFlag(array $arguments): bool + { + return in_array('--'.self::SHARDS_COVERAGE_OPTION, $arguments, true); + } + + /** + * 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(): float + { + $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 --shard=X/Y --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); + } + + $result = \Pest\Support\Coverage::render($merged, $this->output, $this->compact, $this->showOnlyCovered); + + if ($this->shardsCoverageClean) { + foreach ($files as $file) { + @unlink($file); + } + $this->output->writeln(' Coverage files cleaned.'); + } + + return $result; + } } diff --git a/src/Plugins/Help.php b/src/Plugins/Help.php index 12d03532c..617bab9b1 100644 --- a/src/Plugins/Help.php +++ b/src/Plugins/Help.php @@ -180,6 +180,12 @@ private function getContent(): array ], [ 'arg' => '--coverage --only-covered', 'desc' => 'Hide files with 0% coverage from the code coverage report', + ], [ + 'arg' => '--shards-coverage', + 'desc' => 'Merge .cov files from .pest/coverage/ and generate a combined coverage report', + ], [ + 'arg' => '--shards-coverage --clean', + 'desc' => 'Delete .cov files after generating the combined coverage report', ], ...$content['Code Coverage']]; $content['Mutation Testing'] = [[ diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php index 370e492a6..e06559ff2 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -95,6 +95,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 + { $totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines(); /** @var Directory $report */ 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 fe16ce91f..0f36015c3 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 @@ -138,6 +138,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 diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 58dbeddf4..4fd6f07c4 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -78,6 +78,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 @@ -1670,6 +1674,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 @@ -1938,4 +1965,4 @@ ✓ pass with dataset with ('my-datas-set-value') ✓ within describe → pass with dataset with ('my-datas-set-value') - Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1329 passed (3010 assertions) \ No newline at end of file + Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 37 skipped, 1354 passed (3052 assertions) \ No newline at end of file diff --git a/tests/Features/Coverage.php b/tests/Features/Coverage.php index 55ce41644..480eb30a5 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,6 +35,45 @@ expect($plugin->coverageMin)->toEqual(2.4); }); +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 { diff --git a/tests/Plugins/Coverage.php b/tests/Plugins/Coverage.php index 358a9d9d6..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(''); +}); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index 1055526b2..648bad958 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 = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1313 passed (2959 assertions)';", + "\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 29 skipped, 1338 passed (3001 assertions)';", $file, ); file_put_contents(__FILE__, $file); } - $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1313 passed (2959 assertions)'; + $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 29 skipped, 1338 passed (3001 assertions)'; expect($output) ->toContain("Tests: {$expected}")