diff --git a/src/Plugins/Tia/CoverageMerger.php b/src/Plugins/Tia/CoverageMerger.php index 40ea62c09..a83a5d2f1 100644 --- a/src/Plugins/Tia/CoverageMerger.php +++ b/src/Plugins/Tia/CoverageMerger.php @@ -7,6 +7,7 @@ use Pest\Plugins\Tia; use Pest\Plugins\Tia\Contracts\State; use Pest\Support\Container; +use Pest\TestSuite; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Driver\Selector; use SebastianBergmann\CodeCoverage\Filter; @@ -28,31 +29,22 @@ public static function applyIfMarked(string $reportPath): void $state->delete(Tia::KEY_COVERAGE_MARKER); - $cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE); + $cached = self::readCache($state); - if ($cachedBytes === null) { + if (! $cached instanceof CodeCoverage) { $current = self::requireCoverage($reportPath); if ($current instanceof CodeCoverage) { self::primeUncoveredFiles($current); - $state->write(Tia::KEY_COVERAGE_CACHE, self::compress(serialize($current))); + $state->write(Tia::KEY_COVERAGE_CACHE, self::compress(self::serializeRelativeToProjectRoot($current))); } return; } - $decoded = self::decompress($cachedBytes); - - if ($decoded === null) { - $state->delete(Tia::KEY_COVERAGE_CACHE); - - return; - } - - $cached = self::unserializeCoverage($decoded); $current = self::requireCoverage($reportPath); - if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) { + if (! $current instanceof CodeCoverage) { return; } @@ -63,13 +55,108 @@ public static function applyIfMarked(string $reportPath): void $cached->merge($current); - $serialised = serialize($cached); - @file_put_contents( $reportPath, - 'write(Tia::KEY_COVERAGE_CACHE, self::compress($serialised)); + $state->write(Tia::KEY_COVERAGE_CACHE, self::compress(self::serializeRelativeToProjectRoot($cached))); + } + + /** + * Reads the cached baseline coverage and maps it onto this machine's + * project root. An unreadable cache — or one recorded on another machine + * that cannot be mapped — is dropped so the current run can seed a fresh + * one, instead of poisoning the merged report with foreign paths. + */ + private static function readCache(State $state): ?CodeCoverage + { + $bytes = $state->read(Tia::KEY_COVERAGE_CACHE); + + if ($bytes === null) { + return null; + } + + $decoded = self::decompress($bytes); + $cached = $decoded === null ? null : self::unserializeCoverage($decoded); + + if ($cached instanceof CodeCoverage) { + $cached = self::rebaseOntoProjectRoot($cached); + } + + if (! $cached instanceof CodeCoverage) { + $state->delete(Tia::KEY_COVERAGE_CACHE); + + return null; + } + + return $cached; + } + + /** + * The cache stores file paths relative to the project root so that a + * baseline recorded on one machine (e.g. CI) stays mergeable on another. + * Relative entries are mapped onto the local root here; absolute entries + * under the local root are kept (caches written by previous versions on + * this machine), while absolute entries of another machine make the whole + * cache unusable — a report built from mixed roots contains no resolvable + * file at all. + */ + private static function rebaseOntoProjectRoot(CodeCoverage $coverage): ?CodeCoverage + { + $root = self::projectRootPrefix(); + $data = $coverage->getData(true); + + foreach ($data->coveredFiles() as $file) { + if (self::isAbsolutePath($file)) { + if (! str_starts_with($file, $root)) { + return null; + } + + continue; + } + + $data->renameFile($file, $root.str_replace('/', DIRECTORY_SEPARATOR, $file)); + } + + $coverage->clearCache(); + + return $coverage; + } + + private static function serializeRelativeToProjectRoot(CodeCoverage $coverage): string + { + $root = self::projectRootPrefix(); + $data = $coverage->getData(true); + + foreach ($data->coveredFiles() as $file) { + if (! str_starts_with($file, $root)) { + continue; + } + + $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($file, strlen($root))); + + if ($relative === '') { + continue; + } + + $data->renameFile($file, $relative); + } + + $coverage->clearCache(); + + return serialize($coverage); + } + + private static function projectRootPrefix(): string + { + return rtrim(TestSuite::getInstance()->rootPath, '/\\').DIRECTORY_SEPARATOR; + } + + private static function isAbsolutePath(string $path): bool + { + return str_starts_with($path, '/') + || str_starts_with($path, '\\') + || preg_match('#^[A-Za-z]:[/\\\\]#', $path) === 1; } private static function primeUncoveredFiles(CodeCoverage $coverage): void diff --git a/tests/Unit/Plugins/Tia/CoverageMergerPortability.php b/tests/Unit/Plugins/Tia/CoverageMergerPortability.php new file mode 100644 index 000000000..d0e6735a8 --- /dev/null +++ b/tests/Unit/Plugins/Tia/CoverageMergerPortability.php @@ -0,0 +1,192 @@ +>> $lineCoverage + * @param array $tests + */ +function coverageMergerPortabilityCoverage(array $lineCoverage, array $tests = []): CodeCoverage +{ + $coverage = new CodeCoverage(new CoverageMergerPortabilityFakeDriver, new Filter); + + $data = new ProcessedCodeCoverageData; + $data->setLineCoverage($lineCoverage); + + $coverage->setData($data); + $coverage->setTests($tests); + + return $coverage; +} + +function coverageMergerPortabilityReport(string $reportPath, CodeCoverage $coverage): void +{ + file_put_contents( + $reportPath, + 'stateDir = sys_get_temp_dir().'/pest-tia-coverage-merger-'.bin2hex(random_bytes(4)); + mkdir($this->stateDir, 0755, true); + + $this->reportPath = $this->stateDir.'/coverage-report.php'; + + try { + $this->previousState = Container::getInstance()->get(State::class); + } catch (Throwable) { + $this->previousState = null; + } + + Container::getInstance()->add(State::class, new FileState($this->stateDir)); + + $this->projectRoot = rtrim(TestSuite::getInstance()->rootPath, '/\\'); +}); + +afterEach(function (): void { + if ($this->previousState instanceof State) { + Container::getInstance()->add(State::class, $this->previousState); + } + + foreach (glob($this->stateDir.'/*') ?: [] as $file) { + @unlink($file); + } + + @rmdir($this->stateDir); +}); + +it('discards a cache recorded on another machine and reseeds from the current run', function (): void { + $state = Container::getInstance()->get(State::class); + assert($state instanceof State); + + $foreign = coverageMergerPortabilityCoverage([ + '/home/runner/work/acme/acme/src/Example.php' => [10 => ['cached-test']], + ], ['cached-test' => ['size' => 'unknown', 'status' => 'success']]); + + $state->write(Tia::KEY_COVERAGE_CACHE, (string) gzencode(serialize($foreign))); + $state->write(Tia::KEY_COVERAGE_MARKER, ''); + + $localFile = $this->projectRoot.DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'Pest.php'; + + coverageMergerPortabilityReport($this->reportPath, coverageMergerPortabilityCoverage([ + $localFile => [5 => ['fresh-test']], + ], ['fresh-test' => ['size' => 'unknown', 'status' => 'success']])); + + CoverageMerger::applyIfMarked($this->reportPath); + + expect($state->exists(Tia::KEY_COVERAGE_MARKER))->toBeFalse(); + + $cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE); + expect($cachedBytes)->not->toBeNull(); + + $cache = unserialize((string) gzdecode((string) $cachedBytes)); + expect($cache)->toBeInstanceOf(CodeCoverage::class); + assert($cache instanceof CodeCoverage); + + expect($cache->getData(true)->coveredFiles())->toBe(['src/Pest.php']); +}); + +it('rebases relative cached paths onto the local project root before merging', function (): void { + $state = Container::getInstance()->get(State::class); + assert($state instanceof State); + + $cached = coverageMergerPortabilityCoverage([ + 'src/Pest.php' => [10 => ['cached-test']], + ], ['cached-test' => ['size' => 'unknown', 'status' => 'success']]); + + $state->write(Tia::KEY_COVERAGE_CACHE, (string) gzencode(serialize($cached))); + $state->write(Tia::KEY_COVERAGE_MARKER, ''); + + $cachedFile = $this->projectRoot.DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'Pest.php'; + $freshFile = $this->projectRoot.DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'Panic.php'; + + coverageMergerPortabilityReport($this->reportPath, coverageMergerPortabilityCoverage([ + $freshFile => [21 => ['fresh-test']], + ], ['fresh-test' => ['size' => 'unknown', 'status' => 'success']])); + + CoverageMerger::applyIfMarked($this->reportPath); + + $merged = require $this->reportPath; + expect($merged)->toBeInstanceOf(CodeCoverage::class); + assert($merged instanceof CodeCoverage); + + $lineCoverage = $merged->getData(true)->lineCoverage(); + expect($lineCoverage)->toHaveKeys([$cachedFile, $freshFile]) + ->and($lineCoverage[$cachedFile][10])->toBe(['cached-test']) + ->and($lineCoverage[$freshFile][21])->toBe(['fresh-test']); + + $cache = unserialize((string) gzdecode((string) $state->read(Tia::KEY_COVERAGE_CACHE))); + assert($cache instanceof CodeCoverage); + + expect($cache->getData(true)->coveredFiles())->toBe(['src/Panic.php', 'src/Pest.php']); +}); + +it('still merges a same-machine cache that stores absolute paths', function (): void { + $state = Container::getInstance()->get(State::class); + assert($state instanceof State); + + $localFile = $this->projectRoot.DIRECTORY_SEPARATOR.'src'.DIRECTORY_SEPARATOR.'Pest.php'; + + $cached = coverageMergerPortabilityCoverage([ + $localFile => [10 => ['cached-test'], 12 => ['cached-test']], + ], ['cached-test' => ['size' => 'unknown', 'status' => 'success']]); + + $state->write(Tia::KEY_COVERAGE_CACHE, (string) gzencode(serialize($cached))); + $state->write(Tia::KEY_COVERAGE_MARKER, ''); + + coverageMergerPortabilityReport($this->reportPath, coverageMergerPortabilityCoverage([ + $localFile => [11 => ['fresh-test']], + ], ['fresh-test' => ['size' => 'unknown', 'status' => 'success']])); + + CoverageMerger::applyIfMarked($this->reportPath); + + $merged = require $this->reportPath; + assert($merged instanceof CodeCoverage); + + $lines = $merged->getData(true)->lineCoverage()[$localFile]; + ksort($lines); + + expect($lines)->toBe([ + 10 => ['cached-test'], + 11 => ['fresh-test'], + 12 => ['cached-test'], + ]); + + $cache = unserialize((string) gzdecode((string) $state->read(Tia::KEY_COVERAGE_CACHE))); + assert($cache instanceof CodeCoverage); + + expect($cache->getData(true)->coveredFiles())->toBe(['src/Pest.php']); +});