Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 104 additions & 17 deletions src/Plugins/Tia/CoverageMerger.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand All @@ -63,13 +55,108 @@ public static function applyIfMarked(string $reportPath): void

$cached->merge($current);

$serialised = serialize($cached);

@file_put_contents(
$reportPath,
'<?php return unserialize('.var_export($serialised, true).");\n",
'<?php return unserialize('.var_export(serialize($cached), true).");\n",
);
$state->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
Expand Down
192 changes: 192 additions & 0 deletions tests/Unit/Plugins/Tia/CoverageMergerPortability.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
<?php

declare(strict_types=1);

use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Plugins\Tia\CoverageMerger;
use Pest\Plugins\Tia\FileState;
use Pest\Support\Container;
use Pest\TestSuite;
use SebastianBergmann\CodeCoverage\CodeCoverage;
use SebastianBergmann\CodeCoverage\Data\ProcessedCodeCoverageData;
use SebastianBergmann\CodeCoverage\Data\RawCodeCoverageData;
use SebastianBergmann\CodeCoverage\Driver\Driver;
use SebastianBergmann\CodeCoverage\Filter;

final class CoverageMergerPortabilityFakeDriver extends Driver
{
public function name(): string
{
return 'fake';
}

public function version(): string
{
return '1.0.0';
}

public function start(): void {}

public function stop(): RawCodeCoverageData
{
return RawCodeCoverageData::fromXdebugWithoutPathCoverage([]);
}
}

/**
* @param array<string, array<int, array<int, string>>> $lineCoverage
* @param array<string, array{size: string, status: string}> $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,
'<?php return unserialize('.var_export(serialize($coverage), true).");\n",
);
}

beforeEach(function (): void {
$this->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']);
});