Compare commits

..

5 Commits

Author SHA1 Message Date
a5915b16ab wip 2026-04-20 20:58:38 -07:00
1476b529a1 wip 2026-04-20 19:56:23 -07:00
2892341c28 wip 2026-04-20 14:28:18 -07:00
59e781e77b wip 2026-04-20 13:48:05 -07:00
55a3394f8c wip 2026-04-20 13:31:43 -07:00
11 changed files with 1166 additions and 290 deletions

View File

@ -249,6 +249,17 @@ trait Testable
return; return;
} }
// Risky tests have no public PHPUnit hook to replay as-risky.
// Best available: short-circuit as a pass so the test doesn't
// misreport as a failure. Aggregate risky totals won't
// survive replay — accepted trade-off until PHPUnit grows a
// programmatic risky-marker API.
if ($cached->isRisky()) {
$this->__cachedPass = true;
return;
}
// Non-success: throw the matching PHPUnit exception. Runner // Non-success: throw the matching PHPUnit exception. Runner
// catches it and marks the test with the correct status so // catches it and marks the test with the correct status so
// skips, failures, incompletes and todos appear in output // skips, failures, incompletes and todos appear in output
@ -371,7 +382,14 @@ trait Testable
private function __runTest(Closure $closure, ...$args): mixed private function __runTest(Closure $closure, ...$args): mixed
{ {
if ($this->__cachedPass) { if ($this->__cachedPass) {
$this->addToAssertionCount(1); // Feed the exact assertion count captured during the recorded
// run so Pest's "Tests: N passed (M assertions)" banner stays
// accurate on replay instead of collapsing to 1-per-test.
/** @var Tia $tia */
$tia = Container::getInstance()->get(Tia::class);
$assertions = $tia->getCachedAssertions($this::class.'::'.$this->name());
$this->addToAssertionCount($assertions > 0 ? $assertions : 1);
return null; return null;
} }

View File

@ -13,7 +13,6 @@ use Pest\Plugins\Actions\CallsBoot;
use Pest\Plugins\Actions\CallsHandleArguments; use Pest\Plugins\Actions\CallsHandleArguments;
use Pest\Plugins\Actions\CallsHandleOriginalArguments; use Pest\Plugins\Actions\CallsHandleOriginalArguments;
use Pest\Plugins\Actions\CallsTerminable; use Pest\Plugins\Actions\CallsTerminable;
use Pest\Plugins\Tia;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\Support\Reflection; use Pest\Support\Reflection;
use Pest\Support\View; use Pest\Support\View;
@ -37,6 +36,7 @@ final readonly class Kernel
*/ */
private const array BOOTSTRAPPERS = [ private const array BOOTSTRAPPERS = [
Bootstrappers\BootOverrides::class, Bootstrappers\BootOverrides::class,
Plugins\Tia\Bootstrapper::class,
Bootstrappers\BootSubscribers::class, Bootstrappers\BootSubscribers::class,
Bootstrappers\BootFiles::class, Bootstrappers\BootFiles::class,
Bootstrappers\BootView::class, Bootstrappers\BootView::class,
@ -65,11 +65,7 @@ final readonly class Kernel
->add(TestSuite::class, $testSuite) ->add(TestSuite::class, $testSuite)
->add(InputInterface::class, $input) ->add(InputInterface::class, $input)
->add(OutputInterface::class, $output) ->add(OutputInterface::class, $output)
->add(Container::class, $container) ->add(Container::class, $container);
->add(Tia\Recorder::class, new Tia\Recorder)
->add(Tia\CoverageCollector::class, new Tia\CoverageCollector)
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns)
->add(Tia\ResultCollector::class, new Tia\ResultCollector);
$kernel = new self( $kernel = new self(
new Application, new Application,

View File

@ -8,7 +8,9 @@ use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable; use Pest\Contracts\Plugins\Terminable;
use PHPUnit\Framework\TestStatus\TestStatus; use PHPUnit\Framework\TestStatus\TestStatus;
use Pest\Plugins\Tia\BaselineSync;
use Pest\Plugins\Tia\ChangedFiles; use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Plugins\Tia\CoverageCollector; use Pest\Plugins\Tia\CoverageCollector;
use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph; use Pest\Plugins\Tia\Graph;
@ -71,38 +73,34 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private const string REBUILD_OPTION = '--tia-rebuild'; private const string REBUILD_OPTION = '--tia-rebuild';
private const string PUBLISH_OPTION = '--tia-publish';
/** /**
* TIA cache lives inside Pest's `.temp/` directory (same location as * State keys under which TIA persists its blobs. Kept here as constants
* PHPUnit's result cache). This directory is gitignored by default in * (rather than scattered strings) so the storage layout is visible in
* Pest's own `.gitignore`, so the graph is never committed. * one place, and so `CoverageMerger` can reference the same keys.
*/ */
private const string TEMP_DIR = __DIR__ public const string KEY_GRAPH = 'tia.json';
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'.temp';
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 * Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage`
* `--tia --coverage` run. When the next run replays most tests from * run. Stored as bytes so the backend stays JSON/file-agnostic — the
* the TIA graph, only the affected tests produce fresh coverage; the * merger un/serialises rather than `require`-ing a PHP file.
* rest is merged in from this cache so the report stays complete.
*/ */
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 * merge. Absent on plain `--coverage` runs so non-TIA usage keeps its
* current (narrow) behaviour. * current (narrow) behaviour.
*/ */
private const string COVERAGE_MARKER_FILE = 'tia-coverage.marker'; public const string KEY_COVERAGE_MARKER = 'tia-coverage.marker';
private const string WORKER_PREFIX = 'tia-worker-';
private const string WORKER_RESULTS_PREFIX = 'tia-worker-results-';
/** /**
* Global flag toggled by the parent process so workers know to record. * Global flag toggled by the parent process so workers know to record.
@ -135,19 +133,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
private int $replayedCount = 0; private int $replayedCount = 0;
/**
* Counter-part of `$replayedCount`: every time `getCachedResult()`
* decides the test must execute (affected, unknown, or no cached
* result), we bump this. Together the two counters let the summary
* show "affected + replayed" in units of test methods, not test
* files, matching the "Tests: N" total Pest prints above.
*/
private int $executedCount = 0;
/**
* Cached assertion count per test id for the current replay run. Keyed
* by `ClassName::methodName`; populated when `getCachedResult()` hits
* cache and drained by `Testable::__runTest()` on the short-circuit
* path so the emitted count matches the recorded run.
*
* @var array<string, int>
*/
private array $cachedAssertionsByTestId = [];
/** /**
* Captured at replay setup so the end-of-run summary can report the * Captured at replay setup so the end-of-run summary can report the
* scope of the changes that drove the run. * scope of the changes that drove the run.
*/ */
private int $changedFileCount = 0; private int $changedFileCount = 0;
/**
* Captured at replay setup — number of tests the graph flagged as
* affected (i.e. should re-execute). May overshoot the actually-
* executed count when the user narrows with a path filter.
*/
private int $affectedTestCount = 0;
/** /**
* Holds the graph during replay so `beforeEach` can look up cached * Holds the graph during replay so `beforeEach` can look up cached
* results without re-loading from disk on every test. * results without re-loading from disk on every test.
@ -168,57 +178,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
private array $affectedFiles = []; private array $affectedFiles = [];
private static function tempDir(): string private static function workerEdgesKey(string $token): string
{ {
$dir = (string) realpath(self::TEMP_DIR); return self::KEY_WORKER_EDGES_PREFIX.$token.'.json';
if ($dir === '' || $dir === '.') {
// .temp doesn't exist yet — create it.
@mkdir(self::TEMP_DIR, 0755, true);
$dir = (string) realpath(self::TEMP_DIR);
}
return $dir;
} }
private static function cachePath(): string private static function workerResultsKey(string $token): string
{ {
return self::tempDir().DIRECTORY_SEPARATOR.self::CACHE_FILE; return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json';
}
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;
} }
/** /**
@ -242,8 +209,38 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private readonly Recorder $recorder, private readonly Recorder $recorder,
private readonly CoverageCollector $coverageCollector, private readonly CoverageCollector $coverageCollector,
private readonly WatchPatterns $watchPatterns, private readonly WatchPatterns $watchPatterns,
private readonly State $state,
private readonly BaselineSync $baselineSync,
) {} ) {}
/**
* 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 * Returns the cached result for the given test, or `null` if the test
* must run (affected, unknown, or no replay mode active). * must run (affected, unknown, or no replay mode active).
@ -263,11 +260,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// Affected files must re-execute. // Affected files must re-execute.
if ($rel !== null && isset($this->affectedFiles[$rel])) { if ($rel !== null && isset($this->affectedFiles[$rel])) {
$this->executedCount++;
return null; return null;
} }
// Unknown files (not in graph) must execute — they're new. // Unknown files (not in graph) must execute — they're new.
if ($rel === null || ! $this->replayGraph->knowsTest($rel)) { if ($rel === null || ! $this->replayGraph->knowsTest($rel)) {
$this->executedCount++;
return null; return null;
} }
@ -277,11 +278,32 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if ($result !== null) { if ($result !== null) {
$this->replayedCount++; $this->replayedCount++;
// Cache the assertion count alongside the status so `Testable`
// can emit the exact `addToAssertionCount()` at replay time
// without hitting the graph twice per test.
$assertions = $this->replayGraph->getAssertions($this->branch, $testId);
$this->cachedAssertionsByTestId[$testId] = $assertions ?? 0;
} else {
// Graph knows the test file but has no stored result for this
// specific test id (new test, or first time seeing this method).
// It must execute.
$this->executedCount++;
} }
return $result; return $result;
} }
/**
* Exact assertion count captured for the given test during its last
* recorded run. Returns `0` if unknown (new test, or old graph entry
* pre-dating assertion-count tracking). `Testable::__runTest` reads
* this to feed `addToAssertionCount()` instead of defaulting to 1.
*/
public function getCachedAssertions(string $testId): int
{
return $this->cachedAssertionsByTestId[$testId] ?? 0;
}
/** /**
* {@inheritDoc} * {@inheritDoc}
*/ */
@ -291,6 +313,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'; $recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1';
$replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1'; $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1';
// `--tia-publish` is its own entry point: it neither records nor
// replays, it just uploads whatever baseline is already on disk
// and exits. Handled before the usual `--tia` gating so users can
// publish without also triggering a suite run.
if (! $isWorker && $this->hasArgument(self::PUBLISH_OPTION, $arguments)) {
$projectRoot = TestSuite::getInstance()->rootPath;
exit($this->baselineSync->publish($projectRoot));
}
$enabled = $this->hasArgument(self::OPTION, $arguments); $enabled = $this->hasArgument(self::OPTION, $arguments);
$forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments); $forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments);
@ -328,11 +360,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return; return;
} }
// Worker in replay mode: flush the ResultCollector + replay counter // Flush the ResultCollector + replay counter from workers into a
// into a partial so the parent can merge them into the graph after // partial so the parent can merge them. Needed during replay so the
// paratest returns. Parent's own ResultCollector is empty in parallel // summary is accurate, and also during the initial record run so
// runs because workers — not the parent — execute the tests. // the graph lands with results on first write — otherwise the next
if (Parallel::isWorker() && $this->replayGraph !== null) { // run would load a graph with edges but empty results, miss the
// cache for every test, and look pointlessly slow.
if (Parallel::isWorker() && ($this->replayGraph !== null || $this->recordingActive)) {
$this->flushWorkerReplay(); $this->flushWorkerReplay();
} }
@ -365,25 +399,33 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
// Non-parallel record path: straight into the main cache. // 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->setFingerprint(Fingerprint::compute($projectRoot));
$graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha()); $graph->setRecordedAtSha($this->branch, $currentSha);
// Snapshot whatever is currently dirty in the working tree. Without
// this, the very first `--tia` replay would see those same files
// via `since()` and report them as "changed" — even though they're
// identical to what we just recorded against.
$graph->setLastRunTree(
$this->branch,
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
);
$graph->replaceEdges($perTest); $graph->replaceEdges($perTest);
$graph->pruneMissingTests(); $graph->pruneMissingTests();
if (! $graph->save($cachePath)) { if (! $this->saveGraph($graph)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph to '.$cachePath); $this->output->writeln(' <fg=red>TIA</> failed to write graph.');
$recorder->reset(); $recorder->reset();
return; return;
} }
$this->output->writeln(sprintf( $this->output->writeln(sprintf(
' <fg=green>TIA</> graph recorded (%d test files) at %s', ' <fg=green>TIA</> graph recorded (%d test files).',
count($perTest), count($perTest),
self::CACHE_FILE,
)); ));
$recorder->reset(); $recorder->reset();
@ -406,45 +448,53 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// since the original recording. Without this, re-running `--tia` // since the original recording. Without this, re-running `--tia`
// twice in a row would re-execute the same affected tests both // twice in a row would re-execute the same affected tests both
// times even though nothing new changed. // times even though nothing new changed.
if ($this->replayRan) { // In parallel runs the workers executed the tests, so their
// In parallel runs the workers executed the tests, so their // ResultCollector + replay counter live in other processes. Pull
// ResultCollector + replay counter live in other processes. Pull // those partials in first — both replay and record paths need them:
// those partials in before both the summary and the graph // replay to make the summary accurate, record so the initial graph
// snapshot so the parent state reflects the whole run. // lands with results instead of a second "warm-up" run being needed
if (Parallel::isEnabled()) { // before replay is actually fast.
$this->mergeWorkerReplayPartials(); if (Parallel::isEnabled()) {
} $this->mergeWorkerReplayPartials();
}
if ($this->replayRan) {
$this->bumpRecordedSha(); $this->bumpRecordedSha();
$this->emitReplaySummary(); $this->emitReplaySummary();
} }
// Snapshot per-test results (status + message) from PHPUnit's result
// cache into our graph so future replay runs can faithfully reproduce
// pass/fail/skip/todo/incomplete for unaffected tests.
$this->snapshotTestResults();
if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') { if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') {
// Series path: graph was already written by `terminate()` (or
// nothing to record). Snapshot results now so they ride along.
$this->snapshotTestResults();
return $exitCode; return $exitCode;
} }
$projectRoot = TestSuite::getInstance()->rootPath; $projectRoot = TestSuite::getInstance()->rootPath;
$partials = $this->collectWorkerPartials($projectRoot); $partialKeys = $this->collectWorkerEdgesPartials();
if ($partials === []) { if ($partialKeys === []) {
return $exitCode; 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->setFingerprint(Fingerprint::compute($projectRoot));
$graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha()); $graph->setRecordedAtSha($this->branch, $currentSha);
// Snapshot any currently-dirty files so the first replay run
// doesn't mis-report them as changed. See the series record path.
$graph->setLastRunTree(
$this->branch,
$changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []),
);
$merged = []; $merged = [];
foreach ($partials as $partialPath) { foreach ($partialKeys as $key) {
$data = $this->readPartial($partialPath); $data = $this->readPartial($key);
if ($data === null) { if ($data === null) {
continue; continue;
@ -460,7 +510,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
} }
@unlink($partialPath); $this->state->delete($key);
} }
$finalised = []; $finalised = [];
@ -472,19 +522,24 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->replaceEdges($finalised); $graph->replaceEdges($finalised);
$graph->pruneMissingTests(); $graph->pruneMissingTests();
if (! $graph->save($cachePath)) { if (! $this->saveGraph($graph)) {
$this->output->writeln(' <fg=red>TIA</> failed to write graph to '.$cachePath); $this->output->writeln(' <fg=red>TIA</> failed to write graph.');
return $exitCode; return $exitCode;
} }
$this->output->writeln(sprintf( $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($finalised),
count($partials), count($partialKeys),
self::CACHE_FILE,
)); ));
// Persist per-test results (merged from worker partials above) into
// the freshly-written graph. Without this the graph would ship with
// edges but no results, and the very next `--tia` run would miss
// cache for every test even though nothing changed.
$this->snapshotTestResults();
return $exitCode; return $exitCode;
} }
@ -504,10 +559,9 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
// the implicit branch identity. // the implicit branch identity.
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main'; $this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
$cachePath = self::cachePath();
$fingerprint = Fingerprint::compute($projectRoot); $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)) { if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
$this->output->writeln( $this->output->writeln(
@ -530,11 +584,38 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
} }
// No local graph and not being forced to rebuild from scratch: try
// to pull a team-shared baseline so fresh checkouts (new devs, CI
// containers) don't pay the full record cost. If the pull succeeds
// the graph is re-read and re-validated against the local env.
if ($graph === null && ! $forceRebuild) {
if ($this->baselineSync->fetchIfAvailable($projectRoot)) {
$graph = $this->loadGraph($projectRoot);
if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) {
$this->output->writeln(
' <fg=yellow>TIA</> pulled baseline fingerprint mismatch — discarding.',
);
$this->state->delete(self::KEY_GRAPH);
$this->state->delete(self::KEY_COVERAGE_CACHE);
$graph = null;
}
}
}
// Drop the marker so `Support\Coverage::report()` knows to merge the // Drop the marker so `Support\Coverage::report()` knows to merge the
// current (narrow) coverage with the cached full-run snapshot. Plain // current (narrow) coverage with the cached full-run snapshot. Plain
// `--coverage` runs don't drop it, so their behaviour is untouched. // `--coverage` runs don't drop it, so their behaviour is untouched.
if ($this->piggybackCoverage) { 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) { if ($graph instanceof Graph) {
@ -599,18 +680,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
private function installWorkerReplay(string $projectRoot): void private function installWorkerReplay(string $projectRoot): void
{ {
$cachePath = self::cachePath(); $graph = $this->loadGraph($projectRoot);
$affectedPath = self::affectedPath();
$graph = Graph::load($projectRoot, $cachePath);
if (! $graph instanceof Graph) { if (! $graph instanceof Graph) {
return; return;
} }
$raw = @file_get_contents($affectedPath); $raw = $this->state->read(self::KEY_AFFECTED);
if ($raw === false) { if ($raw === null) {
return; return;
} }
@ -660,7 +738,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$affected = $changed === [] ? [] : $graph->affected($changed); $affected = $changed === [] ? [] : $graph->affected($changed);
$this->changedFileCount = count($changed); $this->changedFileCount = count($changed);
$this->affectedTestCount = count($affected);
$affectedSet = array_fill_keys($affected, true); $affectedSet = array_fill_keys($affected, true);
@ -695,32 +772,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
private function persistAffectedSet(string $projectRoot, array $affected): bool 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); $json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES);
if ($json === false) { if ($json === false) {
return false; return false;
} }
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; return $this->state->write(self::KEY_AFFECTED, $json);
if (@file_put_contents($tmp, $json) === false) {
return false;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
} }
/** /**
@ -809,49 +867,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
private function flushWorkerPartial(string $projectRoot, array $perTest): void 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); $json = json_encode($perTest, JSON_UNESCAPED_SLASHES);
if ($json === false) { if ($json === false) {
return; return;
} }
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; $this->state->write(self::workerEdgesKey($this->workerToken()), $json);
if (@file_put_contents($tmp, $json) === false) {
return;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
}
} }
/** /**
* @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(); return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
$matches = glob($pattern);
return $matches === false ? [] : $matches;
} }
private function purgeWorkerPartials(string $projectRoot): void private function purgeWorkerPartials(string $projectRoot): void
{ {
foreach ($this->collectWorkerPartials($projectRoot) as $path) { foreach ($this->collectWorkerEdgesPartials() as $key) {
@unlink($path); $this->state->delete($key);
} }
foreach ($this->collectWorkerReplayPartials() as $path) { foreach ($this->collectWorkerReplayPartials() as $key) {
@unlink($path); $this->state->delete($key);
} }
} }
@ -867,46 +907,29 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$results = $collector->all(); $results = $collector->all();
if ($results === [] && $this->replayedCount === 0) { if ($results === [] && $this->replayedCount === 0 && $this->executedCount === 0) {
return;
}
$token = $this->workerToken();
$path = self::workerResultsPath($token);
$dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
return; return;
} }
$json = json_encode([ $json = json_encode([
'results' => $results, 'results' => $results,
'replayed' => $this->replayedCount, 'replayed' => $this->replayedCount,
'executed' => $this->executedCount,
], JSON_UNESCAPED_SLASHES); ], JSON_UNESCAPED_SLASHES);
if ($json === false) { if ($json === false) {
return; return;
} }
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; $this->state->write(self::workerResultsKey($this->workerToken()), $json);
if (@file_put_contents($tmp, $json) === false) {
return;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
}
} }
/** /**
* @return array<int, string> * @return list<string> State keys of per-worker replay partials.
*/ */
private function collectWorkerReplayPartials(): array private function collectWorkerReplayPartials(): array
{ {
$matches = glob(self::workerResultsGlob()); return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX);
return $matches === false ? [] : $matches;
} }
/** /**
@ -919,17 +942,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** @var ResultCollector $collector */ /** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class); $collector = Container::getInstance()->get(ResultCollector::class);
foreach ($this->collectWorkerReplayPartials() as $path) { foreach ($this->collectWorkerReplayPartials() as $key) {
$raw = @file_get_contents($path); $raw = $this->state->read($key);
$this->state->delete($key);
if ($raw === false) {
@unlink($path);
if ($raw === null) {
continue; continue;
} }
$decoded = json_decode($raw, true); $decoded = json_decode($raw, true);
@unlink($path);
if (! is_array($decoded)) { if (! is_array($decoded)) {
continue; continue;
@ -939,6 +960,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$this->replayedCount += $decoded['replayed']; $this->replayedCount += $decoded['replayed'];
} }
if (isset($decoded['executed']) && is_int($decoded['executed'])) {
$this->executedCount += $decoded['executed'];
}
if (isset($decoded['results']) && is_array($decoded['results'])) { if (isset($decoded['results']) && is_array($decoded['results'])) {
$normalised = []; $normalised = [];
@ -980,11 +1005,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** /**
* @return array<string, array<int, string>>|null * @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; return null;
} }
@ -1039,10 +1064,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
private function emitReplaySummary(): void private function emitReplaySummary(): void
{ {
// `$executedCount` and `$replayedCount` are maintained in lockstep
// by `getCachedResult()` — every test id that hits that method bumps
// exactly one of them. Summing the two gives the test-method total
// that lines up with Pest's "Tests: N" banner directly above.
$this->output->writeln(sprintf( $this->output->writeln(sprintf(
' <fg=green>TIA</> %d changed file(s) → %d affected, %d replayed.', ' <fg=green>TIA</> %d changed file(s) → %d affected, %d replayed.',
$this->changedFileCount, $this->changedFileCount,
$this->affectedTestCount, $this->executedCount,
$this->replayedCount, $this->replayedCount,
)); ));
} }
@ -1050,9 +1079,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
private function bumpRecordedSha(): void private function bumpRecordedSha(): void
{ {
$projectRoot = TestSuite::getInstance()->rootPath; $projectRoot = TestSuite::getInstance()->rootPath;
$cachePath = self::cachePath();
$graph = Graph::load($projectRoot, $cachePath); $graph = $this->loadGraph($projectRoot);
if (! $graph instanceof Graph) { if (! $graph instanceof Graph) {
return; return;
@ -1071,7 +1099,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$workingTreeFiles = $changedFiles->since($currentSha) ?? []; $workingTreeFiles = $changedFiles->since($currentSha) ?? [];
$graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles)); $graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles));
$graph->save($cachePath); $this->saveGraph($graph);
} }
/** /**
@ -1090,20 +1118,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return; return;
} }
$cachePath = self::cachePath();
$projectRoot = TestSuite::getInstance()->rootPath; $projectRoot = TestSuite::getInstance()->rootPath;
$graph = Graph::load($projectRoot, $cachePath); $graph = $this->loadGraph($projectRoot);
if (! $graph instanceof Graph) { if (! $graph instanceof Graph) {
return; return;
} }
foreach ($results as $testId => $result) { foreach ($results as $testId => $result) {
$graph->setResult($this->branch, $testId, $result['status'], $result['message'], $result['time']); $graph->setResult(
$this->branch,
$testId,
$result['status'],
$result['message'],
$result['time'],
$result['assertions'],
);
} }
$graph->save($cachePath); $this->saveGraph($graph);
$collector->reset(); $collector->reset();
} }

View File

@ -0,0 +1,524 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
/**
* Pulls a team-shared TIA baseline on the first `--tia` run so new
* contributors and fresh CI workspaces start in replay mode instead of
* paying the ~30s record cost.
*
* The baseline lives as a GitHub Release with a fixed tag containing two
* assets — the graph JSON and the coverage cache. The repo is inferred
* from `.git/config`'s `origin` remote, so no per-project configuration
* is required. Non-GitHub remotes silently opt out.
*
* Fetching is attempted in order:
* 1. `gh release download` — uses the user's existing GitHub auth,
* works for private repos.
* 2. Plain HTTPS — public-repo fallback when `gh` isn't installed.
*
* Fingerprint validation happens back in `Tia::handleParent` after the
* blobs are written: a mismatched environment (different PHP version,
* composer.lock, etc.) discards the pulled baseline and falls through to
* the regular record path.
*
* @internal
*/
final class BaselineSync
{
/**
* Conventional tag the CI recipe publishes under. Not configurable for
* MVP — if teams outgrow the convention, a `PEST_TIA_BASELINE_TAG` env
* var is the likely escape hatch.
*/
private const string RELEASE_TAG = 'pest-tia-baseline';
/**
* Asset filenames within the release — mirror the state keys so the
* CI publisher and the sync consumer stay in lock-step.
*/
private const string GRAPH_ASSET = Tia::KEY_GRAPH;
private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE;
public function __construct(
private readonly State $state,
private readonly OutputInterface $output,
private readonly InputInterface $input,
) {}
/**
* Attempts the full detect → prompt → download flow. Returns true when
* the graph blob was pulled and written to state. Coverage is best-
* effort: its absence doesn't fail the sync, since plain `--tia` (no
* `--coverage`) works fine without it.
*/
public function fetchIfAvailable(string $projectRoot): bool
{
$repo = $this->detectGitHubRepo($projectRoot);
if ($repo === null) {
return false;
}
if (! $this->confirm($repo)) {
return false;
}
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> fetching baseline from <fg=white>%s</>…',
$repo,
));
$graphJson = $this->download($repo, self::GRAPH_ASSET);
if ($graphJson === null) {
$this->output->writeln(
' <fg=yellow>TIA</> no baseline published yet — recording locally.',
);
return false;
}
if (! $this->state->write(Tia::KEY_GRAPH, $graphJson)) {
return false;
}
// Coverage cache is optional. The baseline is useful even without
// it (plain `--tia` never needs it) so don't fail the whole sync
// just because this asset is missing or slow.
$coverageBin = $this->download($repo, self::COVERAGE_ASSET);
if ($coverageBin !== null) {
$this->state->write(Tia::KEY_COVERAGE_CACHE, $coverageBin);
}
$this->output->writeln(sprintf(
' <fg=green>TIA</> baseline ready (%s).',
$this->formatSize(strlen($graphJson) + strlen($coverageBin ?? '')),
));
return true;
}
/**
* Publishes the *local* baseline to GitHub Releases under the
* conventional tag, creating the release on first run or uploading
* into the existing one otherwise.
*
* Uploading from a developer workstation is intentionally discouraged
* — CI is the authoritative publisher because its environment is
* reproducible, its working tree is clean, and its result cache
* isn't contaminated by local flakiness. The prompt here defaults to
* *No* to keep this an explicit, opt-in action.
*
* Returns a CLI-style exit code so the caller can `exit()` on it.
*/
public function publish(string $projectRoot): int
{
$graphBytes = $this->state->read(Tia::KEY_GRAPH);
if ($graphBytes === null) {
$this->output->writeln([
'',
' <fg=red>TIA</> no local baseline to publish.',
' Run <fg=cyan>./vendor/bin/pest --tia</> first to record one, then retry.',
'',
]);
return 1;
}
$repo = $this->detectGitHubRepo($projectRoot);
if ($repo === null) {
$this->output->writeln([
'',
' <fg=red>TIA</> cannot infer a GitHub repo from <fg=gray>.git/config</>.',
' Publishing is supported only for GitHub-hosted projects.',
'',
]);
return 1;
}
if (! $this->commandExists('gh')) {
$this->output->writeln([
'',
' <fg=red>TIA</> publishing requires the <fg=cyan>gh</> CLI.',
' Install: <fg=gray>https://cli.github.com</>',
'',
]);
return 1;
}
$this->output->writeln([
'',
' <fg=black;bg=yellow> WARNING </> Publishing local baselines is discouraged.',
'',
' Local runs can bake flaky results or dirty working-tree state into the',
' baseline, which your team then replays. CI-published baselines are safer.',
' See <fg=gray>https://pestphp.com/docs/tia/ci</> for the recommended workflow.',
'',
]);
if (! $this->confirmPublish($repo)) {
$this->output->writeln(' <fg=yellow>TIA</> publish cancelled.');
return 0;
}
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-publish-'.bin2hex(random_bytes(4));
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
$this->output->writeln(' <fg=red>TIA</> failed to create temp dir for upload.');
return 1;
}
$graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET;
if (@file_put_contents($graphPath, $graphBytes) === false) {
$this->cleanup($tmpDir);
return 1;
}
$filesToUpload = [$graphPath];
$coverageBytes = $this->state->read(Tia::KEY_COVERAGE_CACHE);
if ($coverageBytes !== null) {
$coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET;
if (@file_put_contents($coveragePath, $coverageBytes) !== false) {
$filesToUpload[] = $coveragePath;
}
}
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> publishing to <fg=white>%s</> (tag <fg=white>%s</>)…',
$repo,
self::RELEASE_TAG,
));
$exitCode = $this->ghReleaseUploadOrCreate($repo, $filesToUpload);
$this->cleanup($tmpDir);
if ($exitCode !== 0) {
$this->output->writeln(' <fg=red>TIA</> <fg=cyan>gh release</> failed.');
return $exitCode;
}
$this->output->writeln(sprintf(
' <fg=green>TIA</> baseline published (%s).',
$this->formatSize(strlen($graphBytes) + ($coverageBytes === null ? 0 : strlen($coverageBytes))),
));
return 0;
}
/**
* Uploads into the existing release if present, falls back to
* creating the release with the assets attached on first run.
*
* @param array<int, string> $files
*/
private function ghReleaseUploadOrCreate(string $repo, array $files): int
{
$uploadArgs = ['gh', 'release', 'upload', self::RELEASE_TAG, ...$files, '-R', $repo, '--clobber'];
$upload = new Process($uploadArgs);
$upload->setTimeout(300.0);
$upload->run(function (string $_, string $buffer): void {
$this->output->write($buffer);
});
if ($upload->isSuccessful()) {
return 0;
}
// Release likely doesn't exist yet — create it, attaching the files.
$createArgs = [
'gh', 'release', 'create', self::RELEASE_TAG,
...$files,
'-R', $repo,
'--title', 'Pest TIA baseline',
'--notes', 'Machine-generated baseline for Pest TIA. Do not edit manually.',
];
$create = new Process($createArgs);
$create->setTimeout(300.0);
$create->run(function (string $_, string $buffer): void {
$this->output->write($buffer);
});
return $create->isSuccessful() ? 0 : 1;
}
private function confirmPublish(string $repo): bool
{
if (! $this->isTerminal()) {
return false;
}
$this->output->writeln(sprintf(
' Publish to <fg=white>%s</> (tag <fg=white>%s</>)? <fg=gray>[y/N]</>',
$repo,
self::RELEASE_TAG,
));
$handle = @fopen('php://stdin', 'r');
if ($handle === false) {
return false;
}
$line = fgets($handle);
fclose($handle);
if ($line === false) {
return false;
}
// Unlike the fetch prompt, this one defaults to *No*. Empty input
// or anything other than an explicit "y"/"yes" cancels.
$line = strtolower(trim($line));
return $line === 'y' || $line === 'yes';
}
/**
* Parses `.git/config` for the `origin` remote and extracts
* `org/repo`. Supports the two URL flavours git emits out of the box.
* Non-GitHub remotes (GitLab, Bitbucket, self-hosted) → null, which
* silently opts the repo out of auto-sync.
*/
private function detectGitHubRepo(string $projectRoot): ?string
{
$gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config';
if (! is_file($gitConfig)) {
return null;
}
$content = @file_get_contents($gitConfig);
if ($content === false) {
return null;
}
// Find the `[remote "origin"]` section and the first `url` line
// inside it. Tolerates INI whitespace quirks (tabs, CRLF).
if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) {
return null;
}
$url = $match[1];
// SSH: git@github.com:org/repo(.git)
if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) {
return $m[1];
}
// HTTPS: https://github.com/org/repo(.git) (optional trailing slash)
if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) {
return $m[1];
}
return null;
}
/**
* One-shot Y/n prompt. Defaults to Y. In non-interactive shells (CI,
* piped input) returns false so scripted runs never hang waiting for
* input.
*/
private function confirm(string $repo): bool
{
if (! $this->isTerminal()) {
return false;
}
$this->output->writeln('');
$this->output->writeln(sprintf(
' <fg=cyan>TIA</> no local cache — fetch baseline from <fg=white>%s</>? <fg=gray>[Y/n]</>',
$repo,
));
$handle = @fopen('php://stdin', 'r');
if ($handle === false) {
return false;
}
$line = fgets($handle);
fclose($handle);
if ($line === false) {
return false;
}
$line = strtolower(trim($line));
return $line === '' || $line === 'y' || $line === 'yes';
}
/**
* Real-TTY check for STDIN. Symfony's `isInteractive()` defaults to true
* unless `--no-interaction` is explicitly passed, which would make
* scripted invocations (CI, pipes, subshells) hang at a prompt nobody
* sees. Combining both signals is the safe default.
*/
private function isTerminal(): bool
{
if (! $this->input->isInteractive()) {
return false;
}
if (! defined('STDIN')) {
return false;
}
if (function_exists('posix_isatty')) {
return @posix_isatty(STDIN) === true;
}
if (function_exists('stream_isatty')) {
return @stream_isatty(STDIN) === true;
}
return false;
}
/**
* Tries `gh` first (handles private repos + rate limiting via the
* user's GitHub auth), falls through to public HTTPS. Returns the
* raw asset bytes, or null on any failure.
*/
private function download(string $repo, string $asset): ?string
{
$viaGh = $this->downloadViaGh($repo, $asset);
if ($viaGh !== null) {
return $viaGh;
}
return $this->downloadViaHttps($repo, $asset);
}
private function downloadViaGh(string $repo, string $asset): ?string
{
if (! $this->commandExists('gh')) {
return null;
}
$tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4));
if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) {
return null;
}
$process = new Process([
'gh', 'release', 'download', self::RELEASE_TAG,
'-R', $repo,
'-p', $asset,
'-D', $tmpDir,
'--clobber',
]);
$process->setTimeout(120.0);
$process->run();
$payload = null;
if ($process->isSuccessful()) {
$path = $tmpDir.DIRECTORY_SEPARATOR.$asset;
if (is_file($path)) {
$content = @file_get_contents($path);
$payload = $content === false ? null : $content;
}
}
$this->cleanup($tmpDir);
return $payload;
}
private function downloadViaHttps(string $repo, string $asset): ?string
{
$url = sprintf(
'https://github.com/%s/releases/download/%s/%s',
$repo,
self::RELEASE_TAG,
$asset,
);
$ctx = stream_context_create([
'http' => [
'timeout' => 120,
'follow_location' => 1,
'ignore_errors' => false,
],
]);
$content = @file_get_contents($url, false, $ctx);
return $content === false ? null : $content;
}
private function commandExists(string $cmd): bool
{
$probe = new Process(['command', '-v', $cmd]);
$probe->run();
if ($probe->isSuccessful()) {
return true;
}
$which = new Process(['which', $cmd]);
$which->run();
return $which->isSuccessful();
}
private function cleanup(string $dir): void
{
if (! is_dir($dir)) {
return;
}
$entries = glob($dir.DIRECTORY_SEPARATOR.'*');
if ($entries !== false) {
foreach ($entries as $entry) {
if (is_file($entry)) {
@unlink($entry);
}
}
}
@rmdir($dir);
}
private function formatSize(int $bytes): string
{
if ($bytes >= 1024 * 1024) {
return sprintf('%.1f MB', $bytes / 1024 / 1024);
}
if ($bytes >= 1024) {
return sprintf('%.1f KB', $bytes / 1024);
}
return $bytes.' B';
}
}

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Contracts\Bootstrapper as BootstrapperContract;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
/**
* Plugin-level container registrations for TIA. Runs as part of Kernel's
* bootstrapper chain so Tia's own service graph is set up without Kernel
* having to know about any of its internals.
*
* Most Tia services (`Recorder`, `CoverageCollector`, `WatchPatterns`,
* `ResultCollector`, `BaselineSync`) are auto-buildable — Pest's container
* resolves them lazily via constructor reflection. The only service that
* requires an explicit binding is the `State` contract, because the
* filesystem implementation needs a root-directory string that reflection
* can't infer.
*
* @internal
*/
final readonly class Bootstrapper implements BootstrapperContract
{
public function __construct(private Container $container) {}
public function boot(): void
{
$this->container->add(State::class, new FileState($this->tempDir()));
}
/**
* Resolve Pest's `.temp/` directory relative to this file so TIA's
* caches share the same location as the rest of Pest's transient
* state (PHPUnit result cache, coverage PHP dumps, etc.).
*/
private function tempDir(): string
{
return __DIR__
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'.temp';
}
}

View 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;
}

View File

@ -5,6 +5,8 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia; use Pest\Plugins\Tia;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\CodeCoverage;
use Throwable; use Throwable;
@ -14,7 +16,7 @@ use Throwable;
* executing only the affected tests. * executing only the affected tests.
* *
* Invoked from `Pest\Support\Coverage::report()` right before the coverage * 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 * behaviour — plain `--coverage` runs (no `--tia`) leave the marker absent
* and therefore keep their existing semantics. * and therefore keep their existing semantics.
* *
@ -24,19 +26,17 @@ use Throwable;
* Its `ProcessedCodeCoverageData` stores, per source file, per line, the * Its `ProcessedCodeCoverageData` stores, per source file, per line, the
* list of test IDs that covered that line. We: * 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 — * 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. * the tests that ran now are the ones whose attribution is fresh.
* 3. Merge the current run into the stripped cached snapshot via * 3. Merge the current run into the stripped cached snapshot via
* `CodeCoverage::merge()`. * `CodeCoverage::merge()`.
* 4. Write the merged result back to the report path (so Pest's report * 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). * 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) * 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 * @internal
*/ */
@ -44,35 +44,33 @@ final class CoverageMerger
{ {
public static function applyIfMarked(string $reportPath): void 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; return;
} }
@unlink($markerPath); $state->delete(Tia::KEY_COVERAGE_MARKER);
$cachePath = Tia::coverageCachePath(); $cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE);
if (! is_file($cachePath)) { if ($cachedBytes === null) {
// First `--tia --coverage` run: nothing cached yet, the current // First `--tia --coverage` run: nothing cached yet, so the
// report is the full suite itself. Save it verbatim so the next // current file already represents the full suite. Capture it
// run has a snapshot to merge against. // verbatim (as serialised bytes) for next time.
@copy($reportPath, $cachePath); $current = self::requireCoverage($reportPath);
if ($current !== null) {
$state->write(Tia::KEY_COVERAGE_CACHE, serialize($current));
}
return; return;
} }
try { $cached = self::unserializeCoverage($cachedBytes);
/** @var CodeCoverage $cached */ $current = self::requireCoverage($reportPath);
$cached = require $cachePath;
/** @var CodeCoverage $current */ if ($cached === null || $current === null) {
$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).
return; return;
} }
@ -80,15 +78,15 @@ final class CoverageMerger
$cached->merge($current); $cached->merge($current);
// Serialise the merged object back using PHPUnit's own "return $serialised = serialize($cached);
// 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";
@file_put_contents($reportPath, $serialised); // Write back to the PHPUnit-style `.cov` path so the report reader
@file_put_contents($cachePath, $serialised); // 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); 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;
}
} }

View 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);
}
}

View File

@ -60,7 +60,7 @@ final class Graph
* @var array<string, array{ * @var array<string, array{
* sha: ?string, * sha: ?string,
* tree: array<string, string>, * tree: array<string, string>,
* results: array<string, array{status: int, message: string, time: float}> * results: array<string, array{status: int, message: string, time: float, assertions?: int}>
* }> * }>
*/ */
private array $baselines = []; private array $baselines = [];
@ -257,14 +257,36 @@ final class Graph
$this->baselines[$branch]['sha'] = $sha; $this->baselines[$branch]['sha'] = $sha;
} }
public function setResult(string $branch, string $testId, int $status, string $message, float $time): void public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0): void
{ {
$this->ensureBaseline($branch); $this->ensureBaseline($branch);
$this->baselines[$branch]['results'][$testId] = [ $this->baselines[$branch]['results'][$testId] = [
'status' => $status, 'message' => $message, 'time' => $time, 'status' => $status,
'message' => $message,
'time' => $time,
'assertions' => $assertions,
]; ];
} }
/**
* Returns the cached assertion count for a test, or `null` if unknown.
* Callers use this to feed `addToAssertionCount()` at replay time so
* the "Tests: N passed (M assertions)" banner matches the recorded run
* instead of defaulting to 1 assertion per test.
*/
public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
if (! isset($baseline['results'][$testId]['assertions'])) {
return null;
}
$value = $baseline['results'][$testId]['assertions'];
return is_int($value) ? $value : null;
}
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
{ {
$baseline = $this->baselineFor($branch, $fallbackBranch); $baseline = $this->baselineFor($branch, $fallbackBranch);
@ -310,7 +332,7 @@ final class Graph
} }
/** /**
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float}>} * @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int}>}
*/ */
private function baselineFor(string $branch, string $fallbackBranch): array private function baselineFor(string $branch, string $fallbackBranch): array
{ {
@ -374,19 +396,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)) { $data = json_decode($json, true);
return null;
}
$raw = @file_get_contents($path);
if ($raw === false) {
return null;
}
$data = json_decode($raw, true);
if (! is_array($data) || ($data['schema'] ?? null) !== 1) { if (! is_array($data) || ($data['schema'] ?? null) !== 1) {
return null; return null;
@ -402,14 +420,14 @@ final class Graph
return $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 = [ $payload = [
'schema' => 1, 'schema' => 1,
'fingerprint' => $this->fingerprint, 'fingerprint' => $this->fingerprint,
@ -418,25 +436,9 @@ final class Graph
'baselines' => $this->baselines, 'baselines' => $this->baselines,
]; ];
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
$json = json_encode($payload, JSON_UNESCAPED_SLASHES); $json = json_encode($payload, JSON_UNESCAPED_SLASHES);
if ($json === false) { return $json === false ? null : $json;
return false;
}
if (@file_put_contents($tmp, $json) === false) {
return false;
}
if (! @rename($tmp, $path)) {
@unlink($tmp);
return false;
}
return true;
} }
/** /**

View File

@ -118,6 +118,17 @@ final class ResultCollector
$this->startTime = null; $this->startTime = null;
} }
/**
* Called by the Finished subscriber after a test's outcome + assertion
* events have all fired. Clears the "currently recording" pointer so
* the next test's events don't get mis-attributed.
*/
public function finishTest(): void
{
$this->currentTestId = null;
$this->startTime = null;
}
private function record(int $status, string $message): void private function record(int $status, string $message): void
{ {
if ($this->currentTestId === null) { if ($this->currentTestId === null) {
@ -128,14 +139,17 @@ final class ResultCollector
? round(microtime(true) - $this->startTime, 3) ? round(microtime(true) - $this->startTime, 3)
: 0.0; : 0.0;
// PHPUnit can fire more than one outcome event per test — the
// canonical case is a risky pass (`Passed` then `ConsideredRisky`).
// Last-wins semantics preserve the most specific status; the
// existing assertion count (if any) survives the overwrite.
$existing = $this->results[$this->currentTestId] ?? null;
$this->results[$this->currentTestId] = [ $this->results[$this->currentTestId] = [
'status' => $status, 'status' => $status,
'message' => $message, 'message' => $message,
'time' => $time, 'time' => $time,
'assertions' => 0, 'assertions' => $existing['assertions'] ?? 0,
]; ];
$this->currentTestId = null;
$this->startTime = null;
} }
} }

View File

@ -30,5 +30,11 @@ final class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscrib
$event->numberOfAssertionsPerformed(), $event->numberOfAssertionsPerformed(),
); );
} }
// Close the "currently recording" window on Finished so the next
// test's events don't get mis-attributed. Keeping the pointer open
// through the outcome subscribers is what lets a late-firing
// `ConsideredRisky` overwrite an earlier `Passed`.
$this->collector->finishTest();
} }
} }