diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index 50bbe8e3c..b8f9e9148 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 @@ -27,6 +30,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. */ @@ -52,6 +74,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. */ @@ -65,6 +102,13 @@ public function __construct(private readonly OutputInterface $output) */ public function handleArguments(array $originals): array { + if ($this->hasShardsCoverageFlag($originals)) { + $originals = $this->popShardsCoverageFlags($originals); + $this->parseThresholdOptions($originals); + + $this->handleShardsCoverageMerge(); + } + $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 +136,32 @@ 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) && ! is_dir($coverageDir)) { + $this->output->writeln([ + '', + sprintf( + ' WARN Could not create coverage directory: %s', + $coverageDir, + ), + '', + ]); + } + + $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 +182,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,39 +200,104 @@ 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( "\n ERROR No code coverage driver is available.", ); + exit(1); } $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; @@ -193,4 +310,244 @@ 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; + } + + /** + * 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(); + $pattern = $coverageDir.DIRECTORY_SEPARATOR.'*.cov'; + $files = glob($pattern); + + if ($files === false || $files === []) { + $this->output->writeln([ + '', + ' ERROR No coverage files found.', + sprintf(' Expected .cov files in: %s', $coverageDir), + ' Run tests with --shard=X/Y --coverage first.', + '', + ]); + + return -1.0; + } + + $count = count($files); + $this->output->writeln([ + '', + sprintf( + ' Merging coverage from %d shard%s...', + $count, + $count === 1 ? '' : 's', + ), + ]); + + /** @var list $files */ + sort($files); + + $mergedCoverage = null; + $mergedTestResults = []; + $mergedBuildInfo = null; + $mergedBasePath = null; + foreach ($files as $file) { + try { + /** @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 { + $mergedCoverage->merge($data['codeCoverage']); + $mergedTestResults = array_merge($mergedTestResults, $data['testResults']); + } + } catch (Throwable $e) { + $this->output->writeln(sprintf( + ' WARN Skipping invalid coverage file: %s (%s)', + basename($file), + $e->getMessage(), + )); + } + } + + if ($mergedCoverage === null) { + $this->output->writeln([ + '', + ' ERROR No valid coverage files could be loaded.', + '', + ]); + + 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) { + foreach ($files as $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.'); + } + + 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 f3968bc95..08822e708 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -96,7 +96,14 @@ public static function report(OutputInterface $output, bool $compact = false, bo $codeCoverage = require $reportPath; unlink($reportPath); - // @phpstan-ignore-next-line + 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|array $codeCoverage, OutputInterface $output, bool $compact = false, bool $showOnlyCovered = false): float + { if (is_array($codeCoverage)) { $facade = Facade::fromSerializedData($codeCoverage); 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 diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 755f02248..979251815 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,31 @@ ✓ 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 + ✓ getCoverageDir returns path under .pest/coverage + ✓ mergeAndReportShardsCoverage returns -1 when coverage directory is empty PASS Tests\Plugins\Traits ✓ it allows global uses @@ -1880,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 @@ -2136,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) \ No newline at end of file + 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/Features/Coverage.php b/tests/Features/Coverage.php index 16e731bbc..ba0f0ff74 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 (): void { + $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 (): 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 (): void { + $plugin = new CoveragePlugin(new ConsoleOutput); + + $arguments = $plugin->handleArguments(['--coverage', '--shard=1/3']); + + $phpIdx = array_search('--coverage-php', $arguments, true); + 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 (): void { + $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..2e635de88 100644 --- a/tests/Plugins/Coverage.php +++ b/tests/Plugins/Coverage.php @@ -1,12 +1,11 @@ $this->computeComparableCoverage($givenValue))->call($plugin); @@ -21,3 +20,203 @@ [32.57777771232132, 32.5], [100.0, 100.0], ]); + +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; + $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): void { + $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())->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 (): void { + $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 (): void { + $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 (): void { + $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 (): void { + $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 (): void { + $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 (): void { + $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 (): void { + $plugin = new Coverage(new NullOutput); + + (fn () => $this->parseThresholdOptions(['--only-covered']))->call($plugin); + + expect($plugin->showOnlyCovered)->toBeTrue(); +}); + +test('parse threshold options ignores unrelated flags', function (): void { + $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 (): void { + $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 (): void { + $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 (): void { + $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 (): 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 (): void { + $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 (): void { + $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 (): void { + $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 (): 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())->toBeEmpty(); +}); + +test('getCoverageDir returns path under .pest/coverage', function (): void { + $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 (): void { + $output = new BufferedOutput; + $plugin = new Coverage($output); + + $result = (fn () => $this->mergeAndReportShardsCoverage())->call($plugin); + + expect($result)->toBe(-1.0) + ->and($output->fetch())->toContain('ERROR'); +}); 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}")