diff --git a/src/Plugins/Coverage.php b/src/Plugins/Coverage.php index ed7b12141..d4c56666a 100644 --- a/src/Plugins/Coverage.php +++ b/src/Plugins/Coverage.php @@ -7,6 +7,7 @@ use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Support\Str; +use Pest\TestSuite; use Symfony\Component\Console\Input\ArgvInput; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputOption; @@ -25,6 +26,8 @@ final class Coverage implements AddsOutput, HandlesArguments private const string ONLY_COVERED_OPTION = 'only-covered'; + private const string ONLY_CHANGED_OPTION = 'only-changed'; + /** * Whether it should show the coverage or not. */ @@ -50,6 +53,11 @@ final class Coverage implements AddsOutput, HandlesArguments */ public bool $showOnlyCovered = false; + /** + * Whether coverage should be limited to files changed on the current branch. + */ + public bool $onlyChanged = false; + /** * Creates a new Plugin instance. */ @@ -64,7 +72,7 @@ public function __construct(private readonly OutputInterface $output) public function handleArguments(array $originals): array { $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) { + foreach ([self::COVERAGE_OPTION, self::MIN_OPTION, self::EXACTLY_OPTION, self::ONLY_COVERED_OPTION, self::ONLY_CHANGED_OPTION] as $option) { if ($original === sprintf('--%s', $option)) { return true; } @@ -88,6 +96,7 @@ public function handleArguments(array $originals): array $inputs[] = new InputOption(self::MIN_OPTION, null, InputOption::VALUE_REQUIRED); $inputs[] = new InputOption(self::EXACTLY_OPTION, null, InputOption::VALUE_REQUIRED); $inputs[] = new InputOption(self::ONLY_COVERED_OPTION, null, InputOption::VALUE_NONE); + $inputs[] = new InputOption(self::ONLY_CHANGED_OPTION, null, InputOption::VALUE_NONE); $input = new ArgvInput($arguments, new InputDefinition($inputs)); if ((bool) $input->getOption(self::COVERAGE_OPTION)) { @@ -132,6 +141,10 @@ public function handleArguments(array $originals): array $this->showOnlyCovered = true; } + if ((bool) $input->getOption(self::ONLY_CHANGED_OPTION)) { + $this->onlyChanged = true; + } + if ($_SERVER['COLLISION_PRINTER_COMPACT'] ?? false) { $this->compact = true; } @@ -156,7 +169,30 @@ public function addOutput(int $exitCode): int exit(1); } - $coverage = \Pest\Support\Coverage::report($this->output, $this->compact, $this->showOnlyCovered); + $onlyChangedPaths = null; + $onlyChangedLineSetsByNormalizedPath = null; + + if ($this->onlyChanged) { + $changed = \Pest\Support\Coverage::resolveBranchChangedPhpPaths(TestSuite::getInstance()->rootPath); + + if (! $changed['ok']) { + if (file_exists($path = \Pest\Support\Coverage::getPath())) { + @unlink($path); + } + + $this->output->writeln([ + '', + ' ERROR '.($changed['errorMessage'] ?? 'Branch-scoped coverage failed.'), + '', + ]); + exit(1); + } + + $onlyChangedPaths = $changed['absolutePhpPaths']; + $onlyChangedLineSetsByNormalizedPath = $changed['lineSetsByNormalizedPath']; + } + + $coverage = \Pest\Support\Coverage::report($this->output, $this->compact, $this->showOnlyCovered, $onlyChangedPaths, $onlyChangedLineSetsByNormalizedPath); $exitCode = (int) ($coverage < $this->coverageMin); if ($exitCode === 0 && $this->coverageExactly !== null) { diff --git a/src/Plugins/Help.php b/src/Plugins/Help.php index 12d03532c..792a9807f 100644 --- a/src/Plugins/Help.php +++ b/src/Plugins/Help.php @@ -180,6 +180,9 @@ private function getContent(): array ], [ 'arg' => '--coverage --only-covered', 'desc' => 'Hide files with 0% coverage from the code coverage report', + ], [ + 'arg' => '--coverage --only-changed', + 'desc' => 'Generate code coverage report and output to standard output for changed PHP files', ], ...$content['Code Coverage']]; $content['Mutation Testing'] = [[ diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php index 370e492a6..9e8a9fab7 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -9,8 +9,10 @@ use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Node\Directory; use SebastianBergmann\CodeCoverage\Node\File; +use SebastianBergmann\CodeCoverage\Util\Percentage; use SebastianBergmann\Environment\Runtime; use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Process\Process; use function Termwind\render; use function Termwind\renderUsing; @@ -71,11 +73,418 @@ public static function usingXdebug(): bool return (new Runtime)->hasXdebug(); } + /** + * Resolve changed PHP files for branch-scoped coverage (working tree vs merge-base with upstream or a fallback mainline ref). + * + * `lineSetsByNormalizedPath` maps normalized paths to 1-based lines from `git diff -U0 ` (additions). `null` means show all uncovered lines in that file (e.g. path from index/status only). + * + * @return array{ok: bool, absolutePhpPaths: list, lineSetsByNormalizedPath: array|null>, errorMessage: ?string} + */ + public static function resolveBranchChangedPhpPaths(string $projectRoot): array + { + if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'.git') && ! is_file($projectRoot.DIRECTORY_SEPARATOR.'.git')) { + return [ + 'ok' => false, + 'absolutePhpPaths' => [], + 'lineSetsByNormalizedPath' => [], + 'errorMessage' => 'Branch-scoped coverage requires a Git repository.', + ]; + } + + $mergeBaseSha = self::coverageMergeBaseSha($projectRoot); + + if ($mergeBaseSha === null) { + return [ + 'ok' => false, + 'absolutePhpPaths' => [], + 'lineSetsByNormalizedPath' => [], + 'errorMessage' => 'Branch-scoped coverage could not resolve a base: set an upstream for this branch (e.g. git branch -u origin/feature-1) or ensure origin/main, origin/master, main, or master exists.', + ]; + } + + $diffText = self::coverageGitDiffUnifiedZeroFromMergeBase($projectRoot, $mergeBaseSha); + $linesByRelativePosix = self::coverageParseUnifiedDiffZero($diffText); + + $paths = array_merge( + array_keys($linesByRelativePosix), + self::coverageDiffNameOnlyMergeBase($projectRoot, $mergeBaseSha), + self::coverageDiffCachedNameOnly($projectRoot), + self::coverageWorkingTreeChanges($projectRoot), + ); + + $unique = []; + + foreach ($paths as $file) { + if ($file !== '') { + $unique[$file] = true; + } + } + + $candidates = array_keys(self::coverageFilterIgnored($projectRoot, $unique)); + + $absolutePhpPaths = []; + $lineSetsByNormalizedPath = []; + + foreach ($candidates as $relative) { + if (! str_ends_with(strtolower($relative), '.php')) { + continue; + } + + $absolute = $projectRoot.DIRECTORY_SEPARATOR.$relative; + + if (! is_file($absolute)) { + continue; + } + + $real = realpath($absolute); + + if ($real === false) { + continue; + } + + $norm = self::normalizePathForCoverageLookup($real); + $absolutePhpPaths[$norm] = true; + + $relPosix = str_replace('\\', '/', $relative); + $lineSet = $linesByRelativePosix[$relPosix] ?? []; + + if ($lineSet === []) { + $lineSetsByNormalizedPath[$norm] = null; + } else { + $lineSetsByNormalizedPath[$norm] = $lineSet; + } + } + + return [ + 'ok' => true, + 'absolutePhpPaths' => array_keys($absolutePhpPaths), + 'lineSetsByNormalizedPath' => $lineSetsByNormalizedPath, + 'errorMessage' => null, + ]; + } + + /** + * Normalizes paths so Git-derived paths and php-code-coverage {@see File} paths compare reliably. + */ + private static function normalizePathForCoverageLookup(string $path): string + { + $resolved = realpath($path); + + if ($resolved !== false) { + $path = $resolved; + } + + return str_replace('\\', '/', $path); + } + + private static function coverageHasUpstream(string $projectRoot): bool + { + $upstreamCheck = new Process( + ['git', 'rev-parse', '--verify', '@{upstream}^{commit}'], + $projectRoot, + ); + $upstreamCheck->run(); + + return $upstreamCheck->isSuccessful(); + } + + /** + * Merge-base of HEAD with upstream when set, otherwise with the first resolvable heuristic mainline ref. + */ + private static function coverageMergeBaseSha(string $projectRoot): ?string + { + if (self::coverageHasUpstream($projectRoot)) { + $mergeBaseProcess = new Process( + ['git', 'merge-base', '@{upstream}', 'HEAD'], + $projectRoot, + ); + $mergeBaseProcess->run(); + + if (! $mergeBaseProcess->isSuccessful()) { + return null; + } + + $sha = trim($mergeBaseProcess->getOutput()); + + return $sha !== '' ? $sha : null; + } + + $mainlineRefName = self::coverageResolveMainlineRefName($projectRoot); + + if ($mainlineRefName === null) { + return null; + } + + $mergeBaseProcess = new Process( + ['git', 'merge-base', 'HEAD', $mainlineRefName], + $projectRoot, + ); + $mergeBaseProcess->run(); + + if (! $mergeBaseProcess->isSuccessful()) { + return null; + } + + $sha = trim($mergeBaseProcess->getOutput()); + + return $sha !== '' ? $sha : null; + } + + /** + * @return array + */ + private static function coverageDiffNameOnlyMergeBase(string $projectRoot, string $mergeBaseSha): array + { + $process = new Process( + ['git', 'diff', '--name-only', $mergeBaseSha], + $projectRoot, + ); + $process->run(); + + if (! $process->isSuccessful()) { + return []; + } + + $out = trim($process->getOutput()); + + if ($out === '') { + return []; + } + + $lines = preg_split('/\R+/', $out, flags: PREG_SPLIT_NO_EMPTY); + + return $lines === false ? [] : $lines; + } + + /** + * Working tree vs merge-base: raw unified diff with zero context lines (for touched line numbers). + */ + private static function coverageGitDiffUnifiedZeroFromMergeBase(string $projectRoot, string $mergeBaseSha): string + { + $process = new Process( + ['git', 'diff', '-U0', $mergeBaseSha], + $projectRoot, + ); + $process->run(); + + return $process->getOutput(); + } + + /** + * @return array> project-relative paths using forward slashes + */ + private static function coverageParseUnifiedDiffZero(string $diff): array + { + /** @var array> $byPath */ + $byPath = []; + $lines = preg_split('/\R/', $diff) ?: []; + + $file = null; + $newLine = null; + + foreach ($lines as $raw) { + if (str_starts_with($raw, 'diff --git ')) { + $file = null; + $newLine = null; + + if (preg_match('#^diff --git a/\S+ b/(.+)$#', $raw, $m) === 1) { + $file = str_replace('\\', '/', $m[1]); + $byPath[$file] ??= []; + } + + continue; + } + + if ($file === null) { + continue; + } + + if (preg_match('/^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/', $raw, $m) === 1) { + $newLine = (int) $m[1]; + + continue; + } + + if ($newLine === null) { + continue; + } + + if ($raw === '' || str_starts_with($raw, 'Binary files ') || str_starts_with($raw, 'new file mode') || str_starts_with($raw, 'deleted file mode') || str_starts_with($raw, 'similarity index ')) { + continue; + } + + if (str_starts_with($raw, '--- ') || str_starts_with($raw, '+++ ') || str_starts_with($raw, 'index ')) { + continue; + } + + if (preg_match('/^\\\ No newline/', $raw) === 1) { + continue; + } + + $c = $raw[0] ?? ''; + + if ($c === ' ') { + $newLine++; + + continue; + } + + if ($c === '-') { + continue; + } + + if ($c === '+') { + $byPath[$file][$newLine] = true; + $newLine++; + + continue; + } + } + + return $byPath; + } + + private static function coverageResolveMainlineRefName(string $projectRoot): ?string + { + foreach (['origin/main', 'origin/master', 'main', 'master'] as $candidate) { + $process = new Process( + ['git', 'rev-parse', '--verify', $candidate.'^{commit}'], + $projectRoot, + ); + $process->run(); + + if ($process->isSuccessful()) { + return $candidate; + } + } + + return null; + } + + /** + * @return array + */ + private static function coverageDiffCachedNameOnly(string $projectRoot): array + { + $process = new Process( + ['git', 'diff', '--cached', '--name-only'], + $projectRoot, + ); + $process->run(); + + if (! $process->isSuccessful()) { + return []; + } + + $out = trim($process->getOutput()); + + if ($out === '') { + return []; + } + + $lines = preg_split('/\R+/', $out, flags: PREG_SPLIT_NO_EMPTY); + + return $lines === false ? [] : $lines; + } + + /** + * @return array + */ + private static function coverageWorkingTreeChanges(string $projectRoot): array + { + $process = new Process( + ['git', 'status', '--porcelain', '-z', '--untracked-files=all'], + $projectRoot, + ); + $process->run(); + + if (! $process->isSuccessful()) { + return []; + } + + $output = $process->getOutput(); + + if ($output === '') { + return []; + } + + $records = explode("\x00", rtrim($output, "\x00")); + $files = []; + $count = count($records); + + for ($i = 0; $i < $count; $i++) { + $record = $records[$i]; + + if (strlen($record) < 4) { + continue; + } + + $status = substr($record, 0, 2); + $path = substr($record, 3); + + if ($status[0] === 'R' || $status[0] === 'C') { + $files[] = $path; + + if (isset($records[$i + 1]) && $records[$i + 1] !== '') { + $files[] = $records[$i + 1]; + $i++; + } + + continue; + } + + $files[] = $path; + } + + return $files; + } + + /** + * @param array $candidates + * @return array + */ + private static function coverageFilterIgnored(string $projectRoot, array $candidates): array + { + if ($candidates === []) { + return $candidates; + } + + $process = new Process( + ['git', 'check-ignore', '--no-index', '-z', '--stdin'], + $projectRoot, + ); + $process->setInput(implode("\x00", array_keys($candidates))); + $process->run(); + + $exitCode = $process->getExitCode(); + + if ($exitCode !== 0 && $exitCode !== 1) { + return $candidates; + } + + $output = $process->getOutput(); + + if ($output === '') { + return $candidates; + } + + foreach (explode("\x00", rtrim($output, "\x00")) as $ignored) { + if ($ignored !== '') { + unset($candidates[$ignored]); + } + } + + return $candidates; + } + /** * Reports the code coverage report to the * console and returns the result in float. + * + * @param array|null $onlyChangedAbsolutePaths When non-null, restrict the report to these absolute file paths (branch-scoped coverage). + * @param array|null>|null $onlyChangedLineSetsByNormalizedPath When set with branch-scoped paths, limit uncovered-line hints to diff-touched lines (null per file = all uncovered lines in that file). */ - public static function report(OutputInterface $output, bool $compact = false, bool $showOnlyCovered = false): float + public static function report(OutputInterface $output, bool $compact = false, bool $showOnlyCovered = false, ?array $onlyChangedAbsolutePaths = null, ?array $onlyChangedLineSetsByNormalizedPath = null): float { if (! file_exists($reportPath = self::getPath())) { if (self::usingXdebug()) { @@ -95,15 +504,57 @@ public static function report(OutputInterface $output, bool $compact = false, bo $codeCoverage = require $reportPath; unlink($reportPath); - $totalCoverage = $codeCoverage->getReport()->percentageOfExecutedLines(); - /** @var Directory $report */ $report = $codeCoverage->getReport(); + $allowSet = null; + + if ($onlyChangedAbsolutePaths !== null) { + if ($onlyChangedAbsolutePaths === []) { + $output->writeln([ + '', + ' WARN Branch-scoped coverage: no changed PHP files were found compared to the default branch.', + '', + ]); + + return 0.0; + } + + /** @var array $allowSet */ + $allowSet = []; + + foreach ($onlyChangedAbsolutePaths as $path) { + $allowSet[self::normalizePathForCoverageLookup($path)] = true; + } + } + + $subsetExecutable = 0; + $subsetExecuted = 0; + $matchedChangedInReport = 0; + foreach ($report->getIterator() as $file) { if (! $file instanceof File) { continue; } + + $filePath = self::normalizePathForCoverageLookup($file->pathAsString()); + + if ($allowSet !== null && ! isset($allowSet[$filePath])) { + continue; + } + + $changedLineSet = null; + + if ($allowSet !== null && $onlyChangedLineSetsByNormalizedPath !== null && array_key_exists($filePath, $onlyChangedLineSetsByNormalizedPath)) { + $changedLineSet = $onlyChangedLineSetsByNormalizedPath[$filePath]; + } + + if ($allowSet !== null) { + $matchedChangedInReport++; + $subsetExecutable += $file->numberOfExecutableLines(); + $subsetExecuted += $file->numberOfExecutedLines(); + } + $dirname = dirname($file->id()); $basename = basename($file->id(), '.php'); @@ -129,7 +580,14 @@ public static function report(OutputInterface $output, bool $compact = false, bo $percentageOfExecutedLinesAsString = $file->percentageOfExecutedLines()->asString(); if (! in_array($percentageOfExecutedLinesAsString, ['0.00%', '100.00%', '100.0%', ''], true)) { - $uncoveredLines = trim(implode(', ', self::getMissingCoverage($file))); + if (is_array($changedLineSet) && $changedLineSet !== []) { + $uncoveredLines = trim(implode(', ', self::getMissingCoverage($file, $changedLineSet))); + } else { + $uncoveredLines = trim(implode(', ', self::getMissingCoverage($file))); + } + } + + if ($uncoveredLines !== '') { $uncoveredLines = sprintf('%s', $uncoveredLines).' / '; } @@ -147,6 +605,22 @@ public static function report(OutputInterface $output, bool $compact = false, bo HTML); } + if ($allowSet !== null) { + if ($matchedChangedInReport === 0) { + $output->writeln([ + '', + ' WARN Branch-scoped coverage: none of the changed PHP files appear in this coverage report. Check your PHPUnit path filter and source paths.', + '', + ]); + + return 0.0; + } + + $totalCoverage = Percentage::fromFractionAndTotal((float) $subsetExecuted, (float) $subsetExecutable); + } else { + $totalCoverage = $report->percentageOfExecutedLines(); + } + $totalCoverageAsString = $totalCoverage->asFloat() === 0.0 ? '0.0' : number_format(floor($totalCoverage->asFloat() * 10) / 10, 1, '.', ''); @@ -171,11 +645,14 @@ public static function report(OutputInterface $output, bool $compact = false, bo * ['11', '20..25', '50', '60..80']; * ``` * + * When `$limitToUncoveredLines` is set, only uncovered executable lines whose number is in that map are listed, + * but line grouping follows the same rules as the full report (gaps from other uncovered lines still break ranges). * - * @param File $file + * @param mixed $file + * @param array|null $limitToUncoveredLines * @return array */ - public static function getMissingCoverage(mixed $file): array + public static function getMissingCoverage(mixed $file, ?array $limitToUncoveredLines = null): array { $shouldBeNewLine = true; @@ -209,7 +686,19 @@ public static function getMissingCoverage(mixed $file): array $array = []; foreach (array_filter($file->lineCoverageData(), is_array(...)) as $line => $tests) { - $array = $eachLine($array, $tests, $line); + if ($tests !== []) { + $array = $eachLine($array, $tests, $line); + + continue; + } + + if ($limitToUncoveredLines === null || isset($limitToUncoveredLines[$line])) { + $array = $eachLine($array, [], $line); + + continue; + } + + $array = $eachLine($array, ['__gap__'], $line); } return $array; 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..4b8f79687 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,7 @@ --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 + --coverage --only-changed Generate code coverage report and output to standard output for changed PHP files --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..5096e5366 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -77,8 +77,12 @@ WARN Tests\Features\Coverage ✓ it has plugin - it adds coverage if --coverage exist → Coverage is not available + ✓ it strips --only-changed from arguments + - it adds coverage with --only-changed → Coverage is not available ✓ it adds coverage if --min exist ✓ it generates coverage based on file input + ✓ it limits missing coverage lines without merging across gaps in the source file + ✓ it still merges consecutive uncovered lines when all are in the limit set PASS Tests\Features\Covers\ClassCoverage ✓ it uses the correct PHPUnit attribute for class @@ -1776,6 +1780,10 @@ ✓ it can resolve builtin value types ✓ it cannot resolve a parameter without type + PASS Tests\Unit\Support\CoverageChangedPaths + ✓ it fails when project is not a git repository + ✓ it parses unified diff -U0 into touched new-file line numbers + PASS Tests\Unit\Support\DatasetInfo ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/Datasets/project/tes…rs.php', true) #1 ✓ it can check if dataset is defined inside a Datasets directory with ('/var/www/Datasets/project/tes…rs.php', true) #2 @@ -1938,4 +1946,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, 36 skipped, 1334 passed (3025 assertions) \ No newline at end of file diff --git a/tests/Features/Coverage.php b/tests/Features/Coverage.php index 55ce41644..bf66138e1 100644 --- a/tests/Features/Coverage.php +++ b/tests/Features/Coverage.php @@ -19,6 +19,27 @@ ->and($plugin->coverage)->toBeTrue(); })->skip(! Coverage::isAvailable() || ! function_exists('xdebug_info') || ! in_array('coverage', xdebug_info('mode'), true), 'Coverage is not available'); +it('strips --only-changed from arguments', function () { + $plugin = new CoveragePlugin(new ConsoleOutput); + + expect($plugin->onlyChanged)->toBeFalse(); + + $arguments = $plugin->handleArguments(['pest', '--testsuite=unit', '--only-changed']); + + expect($arguments)->toEqual(['pest', '--testsuite=unit']) + ->and($plugin->onlyChanged)->toBeTrue(); +}); + +it('adds coverage with --only-changed', function () { + $plugin = new CoveragePlugin(new ConsoleOutput); + + $arguments = $plugin->handleArguments(['--coverage', '--only-changed']); + + expect($arguments)->toEqual(['--coverage-php', Coverage::getPath()]) + ->and($plugin->coverage)->toBeTrue() + ->and($plugin->onlyChanged)->toBeTrue(); +})->skip(! Coverage::isAvailable() || ! function_exists('xdebug_info') || ! in_array('coverage', xdebug_info('mode'), true), 'Coverage is not available'); + it('adds coverage if --min exist', function () { $plugin = new CoveragePlugin(new ConsoleOutput); expect($plugin->coverageMin)->toEqual(0.0) @@ -55,3 +76,38 @@ public function lineCoverageData(): array '4..6', '102', ]); }); + +it('limits missing coverage lines without merging across gaps in the source file', function () { + $file = new class + { + public function lineCoverageData(): array + { + return [ + 21 => [], + 22 => ['hit'], + 23 => ['hit'], + 24 => ['hit'], + 25 => [], + ]; + } + }; + + expect(Coverage::getMissingCoverage($file))->toEqual(['21', '25']) + ->and(Coverage::getMissingCoverage($file, [21 => true, 25 => true]))->toEqual(['21', '25']); +}); + +it('still merges consecutive uncovered lines when all are in the limit set', function () { + $file = new class + { + public function lineCoverageData(): array + { + return [ + 10 => [], + 11 => [], + 12 => [], + ]; + } + }; + + expect(Coverage::getMissingCoverage($file, [10 => true, 11 => true, 12 => true]))->toEqual(['10..12']); +}); diff --git a/tests/Unit/Support/CoverageChangedPaths.php b/tests/Unit/Support/CoverageChangedPaths.php new file mode 100644 index 000000000..cf64c07e3 --- /dev/null +++ b/tests/Unit/Support/CoverageChangedPaths.php @@ -0,0 +1,42 @@ +toBeFalse() + ->and($result['absolutePhpPaths'])->toBe([]) + ->and($result['lineSetsByNormalizedPath'])->toBe([]) + ->and($result['errorMessage'])->toContain('Git'); + + rmdir($tmp); +}); + +it('parses unified diff -U0 into touched new-file line numbers', function () { + $ref = new \ReflectionClass(Coverage::class); + $method = $ref->getMethod('coverageParseUnifiedDiffZero'); + $method->setAccessible(true); + + $diff = <<<'DIFF' +diff --git a/App/Example.php b/App/Example.php +new file mode 100644 +index 0000000..1111111 +--- /dev/null ++++ b/App/Example.php +@@ -0,0 +1,2 @@ ++> $lines */ + $lines = $method->invoke(null, $diff); + + expect($lines)->toHaveKey('App/Example.php') + ->and($lines['App/Example.php'])->toMatchArray([1 => true, 2 => true]); +}); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index 1055526b2..aa53e86ca 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, 28 skipped, 1318 passed (2974 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, 28 skipped, 1318 passed (2974 assertions)'; expect($output) ->toContain("Tests: {$expected}")