mirror of
https://github.com/pestphp/pest.git
synced 2026-04-21 14:37:29 +02:00
wip
This commit is contained in:
@ -9,6 +9,7 @@ use Pest\Contracts\Plugins\HandlesArguments;
|
||||
use Pest\Contracts\Plugins\Terminable;
|
||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||
use Pest\Plugins\Tia\ChangedFiles;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Plugins\Tia\CoverageCollector;
|
||||
use Pest\Plugins\Tia\Fingerprint;
|
||||
use Pest\Plugins\Tia\Graph;
|
||||
@ -72,37 +73,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
private const string REBUILD_OPTION = '--tia-rebuild';
|
||||
|
||||
/**
|
||||
* TIA cache lives inside Pest's `.temp/` directory (same location as
|
||||
* PHPUnit's result cache). This directory is gitignored by default in
|
||||
* Pest's own `.gitignore`, so the graph is never committed.
|
||||
* State keys under which TIA persists its blobs. Kept here as constants
|
||||
* (rather than scattered strings) so the storage layout is visible in
|
||||
* one place, and so `CoverageMerger` can reference the same keys.
|
||||
*/
|
||||
private const string TEMP_DIR = __DIR__
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'.temp';
|
||||
public const string KEY_GRAPH = 'tia.json';
|
||||
|
||||
private const string CACHE_FILE = 'tia.json';
|
||||
public const string KEY_AFFECTED = 'tia-affected.json';
|
||||
|
||||
private const string AFFECTED_FILE = 'tia-affected.json';
|
||||
private const string KEY_WORKER_EDGES_PREFIX = 'tia-worker-edges-';
|
||||
|
||||
private const string KEY_WORKER_RESULTS_PREFIX = 'tia-worker-results-';
|
||||
|
||||
/**
|
||||
* Cache file holding PHPUnit's `CodeCoverage` object from the last
|
||||
* `--tia --coverage` run. When the next run replays most tests from
|
||||
* the TIA graph, only the affected tests produce fresh coverage; the
|
||||
* rest is merged in from this cache so the report stays complete.
|
||||
* Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage`
|
||||
* run. Stored as bytes so the backend stays JSON/file-agnostic — the
|
||||
* merger un/serialises rather than `require`-ing a PHP file.
|
||||
*/
|
||||
private const string COVERAGE_CACHE_FILE = 'tia-coverage.php';
|
||||
public const string KEY_COVERAGE_CACHE = 'tia-coverage.bin';
|
||||
|
||||
/**
|
||||
* Marker file dropped by `Tia` to tell `Support\Coverage` to apply the
|
||||
* Marker key dropped by `Tia` to tell `Support\Coverage` to apply the
|
||||
* merge. Absent on plain `--coverage` runs so non-TIA usage keeps its
|
||||
* current (narrow) behaviour.
|
||||
*/
|
||||
private const string COVERAGE_MARKER_FILE = 'tia-coverage.marker';
|
||||
|
||||
private const string WORKER_PREFIX = 'tia-worker-';
|
||||
|
||||
private const string WORKER_RESULTS_PREFIX = 'tia-worker-results-';
|
||||
public const string KEY_COVERAGE_MARKER = 'tia-coverage.marker';
|
||||
|
||||
/**
|
||||
* Global flag toggled by the parent process so workers know to record.
|
||||
@ -168,57 +163,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private array $affectedFiles = [];
|
||||
|
||||
private static function tempDir(): string
|
||||
private static function workerEdgesKey(string $token): string
|
||||
{
|
||||
$dir = (string) realpath(self::TEMP_DIR);
|
||||
|
||||
if ($dir === '' || $dir === '.') {
|
||||
// .temp doesn't exist yet — create it.
|
||||
@mkdir(self::TEMP_DIR, 0755, true);
|
||||
$dir = (string) realpath(self::TEMP_DIR);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
private static function cachePath(): string
|
||||
private static function workerResultsKey(string $token): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::CACHE_FILE;
|
||||
}
|
||||
|
||||
private static function affectedPath(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::AFFECTED_FILE;
|
||||
}
|
||||
|
||||
private static function workerPath(string $token): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
private static function workerGlob(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.'*.json';
|
||||
}
|
||||
|
||||
private static function workerResultsPath(string $token): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
private static function workerResultsGlob(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.'*.json';
|
||||
}
|
||||
|
||||
public static function coverageCachePath(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_CACHE_FILE;
|
||||
}
|
||||
|
||||
public static function coverageMarkerPath(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_MARKER_FILE;
|
||||
return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
/**
|
||||
@ -242,8 +194,37 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
private readonly Recorder $recorder,
|
||||
private readonly CoverageCollector $coverageCollector,
|
||||
private readonly WatchPatterns $watchPatterns,
|
||||
private readonly State $state,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Convenience wrapper: load + decode the graph, or return `null` if no
|
||||
* graph has been stored. Any call that needs to mutate + re-save the
|
||||
* graph also goes through `saveGraph()` to keep bytes flowing through
|
||||
* the `State` abstraction rather than filesystem paths.
|
||||
*/
|
||||
private function loadGraph(string $projectRoot): ?Graph
|
||||
{
|
||||
$json = $this->state->read(self::KEY_GRAPH);
|
||||
|
||||
if ($json === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Graph::decode($json, $projectRoot);
|
||||
}
|
||||
|
||||
private function saveGraph(Graph $graph): bool
|
||||
{
|
||||
$json = $graph->encode();
|
||||
|
||||
if ($json === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->state->write(self::KEY_GRAPH, $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cached result for the given test, or `null` if the test
|
||||
* must run (affected, unknown, or no replay mode active).
|
||||
@ -367,12 +348,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
// Non-parallel record path: straight into the main cache.
|
||||
$cachePath = self::cachePath();
|
||||
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$currentSha = $changedFiles->currentSha();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
|
||||
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
$graph->setRecordedAtSha($this->branch, $currentSha);
|
||||
// Snapshot whatever is currently dirty in the working tree. Without
|
||||
@ -386,17 +365,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$graph->replaceEdges($perTest);
|
||||
$graph->pruneMissingTests();
|
||||
|
||||
if (! $graph->save($cachePath)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph to '.$cachePath);
|
||||
if (! $this->saveGraph($graph)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph.');
|
||||
$recorder->reset();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> graph recorded (%d test files) at %s',
|
||||
' <fg=green>TIA</> graph recorded (%d test files).',
|
||||
count($perTest),
|
||||
self::CACHE_FILE,
|
||||
));
|
||||
|
||||
$recorder->reset();
|
||||
@ -443,18 +421,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$partials = $this->collectWorkerPartials($projectRoot);
|
||||
$partialKeys = $this->collectWorkerEdgesPartials();
|
||||
|
||||
if ($partials === []) {
|
||||
if ($partialKeys === []) {
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$cachePath = self::cachePath();
|
||||
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$currentSha = $changedFiles->currentSha();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
|
||||
$graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
$graph->setRecordedAtSha($this->branch, $currentSha);
|
||||
// Snapshot any currently-dirty files so the first replay run
|
||||
@ -466,8 +442,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
$merged = [];
|
||||
|
||||
foreach ($partials as $partialPath) {
|
||||
$data = $this->readPartial($partialPath);
|
||||
foreach ($partialKeys as $key) {
|
||||
$data = $this->readPartial($key);
|
||||
|
||||
if ($data === null) {
|
||||
continue;
|
||||
@ -483,7 +459,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
}
|
||||
|
||||
@unlink($partialPath);
|
||||
$this->state->delete($key);
|
||||
}
|
||||
|
||||
$finalised = [];
|
||||
@ -495,17 +471,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$graph->replaceEdges($finalised);
|
||||
$graph->pruneMissingTests();
|
||||
|
||||
if (! $graph->save($cachePath)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph to '.$cachePath);
|
||||
if (! $this->saveGraph($graph)) {
|
||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph.');
|
||||
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials) at %s',
|
||||
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials).',
|
||||
count($finalised),
|
||||
count($partials),
|
||||
self::CACHE_FILE,
|
||||
count($partialKeys),
|
||||
));
|
||||
|
||||
// Persist per-test results (merged from worker partials above) into
|
||||
@ -533,10 +508,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
// the implicit branch identity.
|
||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
||||
|
||||
$cachePath = self::cachePath();
|
||||
$fingerprint = Fingerprint::compute($projectRoot);
|
||||
|
||||
$graph = $forceRebuild ? null : Graph::load($projectRoot, $cachePath);
|
||||
$graph = $forceRebuild ? null : $this->loadGraph($projectRoot);
|
||||
|
||||
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
|
||||
$this->output->writeln(
|
||||
@ -563,7 +537,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
// current (narrow) coverage with the cached full-run snapshot. Plain
|
||||
// `--coverage` runs don't drop it, so their behaviour is untouched.
|
||||
if ($this->piggybackCoverage) {
|
||||
@file_put_contents(self::coverageMarkerPath(), '');
|
||||
$this->state->write(self::KEY_COVERAGE_MARKER, '');
|
||||
}
|
||||
|
||||
// First `--tia --coverage` run has nothing to merge against: if we
|
||||
// replay, the coverage driver sees only the affected tests and the
|
||||
// report collapses to near-zero coverage. Fall back to recording
|
||||
// (full suite) to seed the cache for next time.
|
||||
if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) {
|
||||
return $this->enterRecordMode($projectRoot, $arguments);
|
||||
}
|
||||
|
||||
if ($graph instanceof Graph) {
|
||||
@ -628,18 +610,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function installWorkerReplay(string $projectRoot): void
|
||||
{
|
||||
$cachePath = self::cachePath();
|
||||
$affectedPath = self::affectedPath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
$graph = $this->loadGraph($projectRoot);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($affectedPath);
|
||||
$raw = $this->state->read(self::KEY_AFFECTED);
|
||||
|
||||
if ($raw === false) {
|
||||
if ($raw === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -724,32 +703,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function persistAffectedSet(string $projectRoot, array $affected): bool
|
||||
{
|
||||
$path = self::affectedPath();
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
if (@file_put_contents($tmp, $json) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return $this->state->write(self::KEY_AFFECTED, $json);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -838,49 +798,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
private function flushWorkerPartial(string $projectRoot, array $perTest): void
|
||||
{
|
||||
$path = self::workerPath($this->workerToken());
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$json = json_encode($perTest, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
if (@file_put_contents($tmp, $json) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
}
|
||||
$this->state->write(self::workerEdgesKey($this->workerToken()), $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
* @return list<string> State keys of per-worker edges partials.
|
||||
*/
|
||||
private function collectWorkerPartials(string $projectRoot): array
|
||||
private function collectWorkerEdgesPartials(): array
|
||||
{
|
||||
$pattern = self::workerGlob();
|
||||
$matches = glob($pattern);
|
||||
|
||||
return $matches === false ? [] : $matches;
|
||||
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
|
||||
}
|
||||
|
||||
private function purgeWorkerPartials(string $projectRoot): void
|
||||
{
|
||||
foreach ($this->collectWorkerPartials($projectRoot) as $path) {
|
||||
@unlink($path);
|
||||
foreach ($this->collectWorkerEdgesPartials() as $key) {
|
||||
$this->state->delete($key);
|
||||
}
|
||||
|
||||
foreach ($this->collectWorkerReplayPartials() as $path) {
|
||||
@unlink($path);
|
||||
foreach ($this->collectWorkerReplayPartials() as $key) {
|
||||
$this->state->delete($key);
|
||||
}
|
||||
}
|
||||
|
||||
@ -900,14 +842,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
$token = $this->workerToken();
|
||||
$path = self::workerResultsPath($token);
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$json = json_encode([
|
||||
'results' => $results,
|
||||
'replayed' => $this->replayedCount,
|
||||
@ -917,25 +851,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
if (@file_put_contents($tmp, $json) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
}
|
||||
$this->state->write(self::workerResultsKey($this->workerToken()), $json);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
* @return list<string> State keys of per-worker replay partials.
|
||||
*/
|
||||
private function collectWorkerReplayPartials(): array
|
||||
{
|
||||
$matches = glob(self::workerResultsGlob());
|
||||
|
||||
return $matches === false ? [] : $matches;
|
||||
return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -948,17 +872,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
/** @var ResultCollector $collector */
|
||||
$collector = Container::getInstance()->get(ResultCollector::class);
|
||||
|
||||
foreach ($this->collectWorkerReplayPartials() as $path) {
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
@unlink($path);
|
||||
foreach ($this->collectWorkerReplayPartials() as $key) {
|
||||
$raw = $this->state->read($key);
|
||||
$this->state->delete($key);
|
||||
|
||||
if ($raw === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$decoded = json_decode($raw, true);
|
||||
@unlink($path);
|
||||
|
||||
if (! is_array($decoded)) {
|
||||
continue;
|
||||
@ -1009,11 +931,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
/**
|
||||
* @return array<string, array<int, string>>|null
|
||||
*/
|
||||
private function readPartial(string $path): ?array
|
||||
private function readPartial(string $key): ?array
|
||||
{
|
||||
$raw = @file_get_contents($path);
|
||||
$raw = $this->state->read($key);
|
||||
|
||||
if ($raw === false) {
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@ -1079,9 +1001,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
private function bumpRecordedSha(): void
|
||||
{
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$cachePath = self::cachePath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
$graph = $this->loadGraph($projectRoot);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
@ -1100,7 +1021,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$workingTreeFiles = $changedFiles->since($currentSha) ?? [];
|
||||
$graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles));
|
||||
|
||||
$graph->save($cachePath);
|
||||
$this->saveGraph($graph);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1119,10 +1040,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
$cachePath = self::cachePath();
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
$graph = $this->loadGraph($projectRoot);
|
||||
|
||||
if (! $graph instanceof Graph) {
|
||||
return;
|
||||
@ -1132,7 +1052,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$graph->setResult($this->branch, $testId, $result['status'], $result['message'], $result['time']);
|
||||
}
|
||||
|
||||
$graph->save($cachePath);
|
||||
$this->saveGraph($graph);
|
||||
$collector->reset();
|
||||
}
|
||||
|
||||
|
||||
48
src/Plugins/Tia/Contracts/State.php
Normal file
48
src/Plugins/Tia/Contracts/State.php
Normal file
@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia\Contracts;
|
||||
|
||||
/**
|
||||
* Storage contract for TIA's persistent state (graph, baselines, affected
|
||||
* set, worker partials, coverage snapshots). Modelled as a flat key/value
|
||||
* store of raw byte blobs so implementations can sit on top of whatever
|
||||
* backend fits — a directory, a shared cache, a remote object store — and
|
||||
* TIA's logic stays identical.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface State
|
||||
{
|
||||
/**
|
||||
* Returns the stored blob for `$key`, or `null` when the key is unset
|
||||
* or cannot be read.
|
||||
*/
|
||||
public function read(string $key): ?string;
|
||||
|
||||
/**
|
||||
* Atomically stores `$content` under `$key`. Existing value (if any) is
|
||||
* replaced. Implementations SHOULD guarantee that concurrent readers
|
||||
* never observe partial writes.
|
||||
*/
|
||||
public function write(string $key, string $content): bool;
|
||||
|
||||
/**
|
||||
* Removes `$key`. Returns true whether or not the key existed beforehand
|
||||
* — callers should treat a `true` result as "the key is now absent",
|
||||
* not "the key was present and has been removed."
|
||||
*/
|
||||
public function delete(string $key): bool;
|
||||
|
||||
public function exists(string $key): bool;
|
||||
|
||||
/**
|
||||
* Returns every key whose name starts with `$prefix`. Used to collect
|
||||
* paratest worker partials (`tia-worker-<token>.json`, etc.) without
|
||||
* exposing backend-specific glob semantics.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function keysWithPrefix(string $prefix): array;
|
||||
}
|
||||
@ -5,6 +5,8 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
use Pest\Support\Container;
|
||||
use SebastianBergmann\CodeCoverage\CodeCoverage;
|
||||
use Throwable;
|
||||
|
||||
@ -14,7 +16,7 @@ use Throwable;
|
||||
* executing only the affected tests.
|
||||
*
|
||||
* Invoked from `Pest\Support\Coverage::report()` right before the coverage
|
||||
* file is consumed. A marker file dropped by the `Tia` plugin gates the
|
||||
* file is consumed. A marker dropped by the `Tia` plugin gates the
|
||||
* behaviour — plain `--coverage` runs (no `--tia`) leave the marker absent
|
||||
* and therefore keep their existing semantics.
|
||||
*
|
||||
@ -24,19 +26,17 @@ use Throwable;
|
||||
* Its `ProcessedCodeCoverageData` stores, per source file, per line, the
|
||||
* list of test IDs that covered that line. We:
|
||||
*
|
||||
* 1. Load the cached snapshot (from a previous `--tia --coverage` run).
|
||||
* 1. Load the cached snapshot from `State` (serialised bytes).
|
||||
* 2. Strip every test id that re-ran this time from the cached map —
|
||||
* the tests that ran now are the ones whose attribution is fresh.
|
||||
* 3. Merge the current run into the stripped cached snapshot via
|
||||
* `CodeCoverage::merge()`.
|
||||
* 4. Write the merged result back to the report path (so Pest's report
|
||||
* generator sees the full suite) and to the cache path (for the
|
||||
* generator sees the full suite) and back into `State` (for the
|
||||
* next invocation).
|
||||
* 5. Remove the marker so subsequent plain `--coverage` runs are
|
||||
* untouched.
|
||||
*
|
||||
* If no cache exists yet (first `--tia --coverage` run on this machine)
|
||||
* we simply save the current file as the cache — nothing to merge yet.
|
||||
* we serialise the current object and save it — nothing to merge yet.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
@ -44,35 +44,33 @@ final class CoverageMerger
|
||||
{
|
||||
public static function applyIfMarked(string $reportPath): void
|
||||
{
|
||||
$markerPath = Tia::coverageMarkerPath();
|
||||
$state = self::state();
|
||||
|
||||
if (! is_file($markerPath)) {
|
||||
if ($state === null || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@unlink($markerPath);
|
||||
$state->delete(Tia::KEY_COVERAGE_MARKER);
|
||||
|
||||
$cachePath = Tia::coverageCachePath();
|
||||
$cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
|
||||
|
||||
if (! is_file($cachePath)) {
|
||||
// First `--tia --coverage` run: nothing cached yet, the current
|
||||
// report is the full suite itself. Save it verbatim so the next
|
||||
// run has a snapshot to merge against.
|
||||
@copy($reportPath, $cachePath);
|
||||
if ($cachedBytes === null) {
|
||||
// First `--tia --coverage` run: nothing cached yet, so the
|
||||
// current file already represents the full suite. Capture it
|
||||
// verbatim (as serialised bytes) for next time.
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
if ($current !== null) {
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current));
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var CodeCoverage $cached */
|
||||
$cached = require $cachePath;
|
||||
$cached = self::unserializeCoverage($cachedBytes);
|
||||
$current = self::requireCoverage($reportPath);
|
||||
|
||||
/** @var CodeCoverage $current */
|
||||
$current = require $reportPath;
|
||||
} catch (Throwable) {
|
||||
// Corrupt cache or unreadable report — fall back to the plain
|
||||
// PHPUnit behaviour (the existing `require $reportPath` in the
|
||||
// caller still runs against the untouched file).
|
||||
if ($cached === null || $current === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -80,15 +78,15 @@ final class CoverageMerger
|
||||
|
||||
$cached->merge($current);
|
||||
|
||||
// Serialise the merged object back using PHPUnit's own "return
|
||||
// expression" PHP format. Using `var_export` on the serialised
|
||||
// payload keeps the file self-contained and independent of
|
||||
// PHPUnit's internal exporter — the reader only needs to
|
||||
// `require` it back.
|
||||
$serialised = "<?php return unserialize(".var_export(serialize($cached), true).");\n";
|
||||
$serialised = serialize($cached);
|
||||
|
||||
@file_put_contents($reportPath, $serialised);
|
||||
@file_put_contents($cachePath, $serialised);
|
||||
// Write back to the PHPUnit-style `.cov` path so the report reader
|
||||
// can `require` it, and to the state cache for the next run.
|
||||
@file_put_contents(
|
||||
$reportPath,
|
||||
"<?php return unserialize(".var_export($serialised, true).");\n",
|
||||
);
|
||||
$state->write(Tia::KEY_COVERAGE_CACHE, $serialised);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -146,4 +144,43 @@ final class CoverageMerger
|
||||
|
||||
return array_keys($ids);
|
||||
}
|
||||
|
||||
private static function state(): ?State
|
||||
{
|
||||
try {
|
||||
$state = Container::getInstance()->get(State::class);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $state instanceof State ? $state : null;
|
||||
}
|
||||
|
||||
private static function requireCoverage(string $reportPath): ?CodeCoverage
|
||||
{
|
||||
if (! is_file($reportPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
/** @var mixed $value */
|
||||
$value = require $reportPath;
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value instanceof CodeCoverage ? $value : null;
|
||||
}
|
||||
|
||||
private static function unserializeCoverage(string $bytes): ?CodeCoverage
|
||||
{
|
||||
try {
|
||||
/** @var mixed $value */
|
||||
$value = @unserialize($bytes);
|
||||
} catch (Throwable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value instanceof CodeCoverage ? $value : null;
|
||||
}
|
||||
}
|
||||
|
||||
150
src/Plugins/Tia/FileState.php
Normal file
150
src/Plugins/Tia/FileState.php
Normal file
@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\Contracts\State;
|
||||
|
||||
/**
|
||||
* Filesystem-backed implementation of the TIA `State` contract. Each key
|
||||
* maps verbatim to a file name under `$rootDir`, so existing `.temp/*.json`
|
||||
* layouts are preserved exactly.
|
||||
*
|
||||
* The root directory is created lazily on first write — callers don't have
|
||||
* to pre-provision it, and reads against a missing directory simply return
|
||||
* `null` rather than throwing.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class FileState implements State
|
||||
{
|
||||
/**
|
||||
* Configured root. May not exist on disk yet; resolved + created on
|
||||
* the first write. Keeping the raw string lets the instance be built
|
||||
* before Pest's temp dir has been materialised.
|
||||
*/
|
||||
private readonly string $rootDir;
|
||||
|
||||
public function __construct(string $rootDir)
|
||||
{
|
||||
$this->rootDir = rtrim($rootDir, DIRECTORY_SEPARATOR);
|
||||
}
|
||||
|
||||
public function read(string $key): ?string
|
||||
{
|
||||
$path = $this->pathFor($key);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$bytes = @file_get_contents($path);
|
||||
|
||||
return $bytes === false ? null : $bytes;
|
||||
}
|
||||
|
||||
public function write(string $key, string $content): bool
|
||||
{
|
||||
if (! $this->ensureRoot()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$path = $this->pathFor($key);
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
if (@file_put_contents($tmp, $content) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Atomic rename — on POSIX filesystems this is a single-step
|
||||
// replacement, so concurrent readers never see a half-written file.
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function delete(string $key): bool
|
||||
{
|
||||
$path = $this->pathFor($key);
|
||||
|
||||
if (! is_file($path)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return @unlink($path);
|
||||
}
|
||||
|
||||
public function exists(string $key): bool
|
||||
{
|
||||
return is_file($this->pathFor($key));
|
||||
}
|
||||
|
||||
public function keysWithPrefix(string $prefix): array
|
||||
{
|
||||
$root = $this->resolvedRoot();
|
||||
|
||||
if ($root === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pattern = $root.DIRECTORY_SEPARATOR.$prefix.'*';
|
||||
$matches = glob($pattern);
|
||||
|
||||
if ($matches === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = [];
|
||||
|
||||
foreach ($matches as $path) {
|
||||
$keys[] = basename($path);
|
||||
}
|
||||
|
||||
return $keys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute path for `$key`. Not part of the interface — used by the
|
||||
* coverage merger and similar callers that need direct filesystem
|
||||
* access (e.g. `require` on a cached PHP file). Consumers that only
|
||||
* deal in bytes should go through `read()` / `write()`.
|
||||
*/
|
||||
public function pathFor(string $key): string
|
||||
{
|
||||
return $this->rootDir.DIRECTORY_SEPARATOR.$key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the resolved root if it exists already, otherwise `null`.
|
||||
* Used by read-side helpers so they don't eagerly create the directory
|
||||
* just to find nothing inside.
|
||||
*/
|
||||
private function resolvedRoot(): ?string
|
||||
{
|
||||
$resolved = @realpath($this->rootDir);
|
||||
|
||||
return $resolved === false ? null : $resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the root dir on demand. Returns false only when creation
|
||||
* fails and the directory still isn't there afterwards.
|
||||
*/
|
||||
private function ensureRoot(): bool
|
||||
{
|
||||
if (is_dir($this->rootDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (@mkdir($this->rootDir, 0755, true)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return is_dir($this->rootDir);
|
||||
}
|
||||
}
|
||||
@ -374,19 +374,15 @@ final class Graph
|
||||
}
|
||||
}
|
||||
|
||||
public static function load(string $projectRoot, string $path): ?self
|
||||
/**
|
||||
* Rebuilds a graph from its JSON representation. Returns `null` when
|
||||
* the payload is missing, unreadable, or schema-incompatible. Separated
|
||||
* from transport (state backend, file, etc.) so tests can feed bytes
|
||||
* directly without touching disk.
|
||||
*/
|
||||
public static function decode(string $json, string $projectRoot): ?self
|
||||
{
|
||||
if (! is_file($path)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
$data = json_decode($json, true);
|
||||
|
||||
if (! is_array($data) || ($data['schema'] ?? null) !== 1) {
|
||||
return null;
|
||||
@ -402,14 +398,14 @@ final class Graph
|
||||
return $graph;
|
||||
}
|
||||
|
||||
public function save(string $path): bool
|
||||
/**
|
||||
* Serialises the graph to its JSON on-disk form. Returns `null` if the
|
||||
* payload can't be encoded (extremely rare — pathological UTF-8 only).
|
||||
* Persistence is the caller's responsibility: write the returned bytes
|
||||
* through whatever `State` implementation is in play.
|
||||
*/
|
||||
public function encode(): ?string
|
||||
{
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'schema' => 1,
|
||||
'fingerprint' => $this->fingerprint,
|
||||
@ -418,25 +414,9 @@ final class Graph
|
||||
'baselines' => $this->baselines,
|
||||
];
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
if ($json === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (@file_put_contents($tmp, $json) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! @rename($tmp, $path)) {
|
||||
@unlink($tmp);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return $json === false ? null : $json;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user