feat(tia): continues to work on poc

This commit is contained in:
nuno maduro
2026-04-16 14:24:20 -07:00
parent 42d1092a9e
commit 9c8033d60c
17 changed files with 301 additions and 172 deletions

View File

@ -5,10 +5,9 @@ declare(strict_types=1);
namespace Pest\Plugins;
use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\BeforeEachable;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable;
use Pest\Plugins\Tia\CachedTestResult;
use PHPUnit\Framework\TestStatus\TestStatus;
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
@ -63,7 +62,7 @@ use Throwable;
*
* @internal
*/
final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Terminable
final class Tia implements AddsOutput, HandlesArguments, Terminable
{
use Concerns\HandleArguments;
@ -102,6 +101,26 @@ final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Termina
private bool $replayRan = false;
/**
* Counts cache hits during a replay run. Incremented each time
* `getCachedResult()` returns a non-null status so the end-of-run
* summary reflects what actually happened, not a graph-level estimate.
*/
private int $replayedCount = 0;
/**
* Captured at replay setup so the end-of-run summary can report the
* scope of the changes that drove the run.
*/
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
* results without re-loading from disk on every test.
@ -161,7 +180,11 @@ final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Termina
private readonly WatchPatterns $watchPatterns,
) {}
public function beforeEach(string $filename, string $testId): ?CachedTestResult
/**
* Returns the cached result for the given test, or `null` if the test
* must run (affected, unknown, or no replay mode active).
*/
public function getCachedResult(string $filename, string $testId): ?TestStatus
{
if ($this->replayGraph === null) {
return null;
@ -186,7 +209,13 @@ final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Termina
// Known + unaffected: return cached result if we have one for this
// branch (falls back to main if branch is fresh).
return $this->replayGraph->getResult($this->branch, $testId);
$result = $this->replayGraph->getResult($this->branch, $testId);
if ($result !== null) {
$this->replayedCount++;
}
return $result;
}
/**
@ -301,6 +330,7 @@ final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Termina
// times even though nothing new changed.
if ($this->replayRan) {
$this->bumpRecordedSha();
$this->emitReplaySummary();
}
// Snapshot per-test results (status + message) from PHPUnit's result
@ -526,11 +556,9 @@ final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Termina
$affected = $changed === [] ? [] : $graph->affected($changed);
$totalKnown = count($graph->allTestFiles());
$affectedCount = count($affected);
$cachedCount = $totalKnown - $affectedCount;
$this->changedFileCount = count($changed);
$this->affectedTestCount = count($affected);
$testSuite = TestSuite::getInstance();
$affectedSet = array_fill_keys($affected, true);
$this->replayRan = true;
@ -538,13 +566,6 @@ final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Termina
$this->affectedFiles = $affectedSet;
if (! Parallel::isEnabled()) {
$this->output->writeln(sprintf(
' <fg=green>TIA</> %d changed file(s) → %d affected, %d replayed.',
count($changed),
$affectedCount,
$cachedCount,
));
return $arguments;
}
@ -559,13 +580,6 @@ final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Termina
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
$this->output->writeln(sprintf(
' <fg=green>TIA</> %d changed file(s) → %d affected, %d cached (parallel).',
count($changed),
$affectedCount,
$cachedCount,
));
return $arguments;
}
@ -761,6 +775,22 @@ final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Termina
* compares against this baseline so identical files are skipped even if
* git still reports them as modified.
*/
/**
* Prints the post-run TIA summary. Runs after the test report so the
* replayed count reflects what actually happened (cache hits counted
* inside `getCachedResult`) rather than a graph-level estimate that
* ignores any CLI path filter the user passed in.
*/
private function emitReplaySummary(): void
{
$this->output->writeln(sprintf(
' <fg=green>TIA</> %d changed file(s) → %d affected, %d replayed.',
$this->changedFileCount,
$this->affectedTestCount,
$this->replayedCount,
));
}
private function bumpRecordedSha(): void
{
$projectRoot = TestSuite::getInstance()->rootPath;

View File

@ -1,33 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Immutable snapshot of a previous test run's outcome. Stored in the TIA
* graph and returned by `BeforeEachable::beforeEach` so `Testable` can
* faithfully replay the exact status — pass, fail, skip, todo, incomplete,
* risky, etc. — without executing the test body.
*
* @internal
*/
final readonly class CachedTestResult
{
/**
* PHPUnit TestStatus int constants:
* 0 = success, 1 = skipped, 2 = incomplete,
* 3 = notice, 4 = deprecation, 5 = risky,
* 6 = warning, 7 = failure, 8 = error.
*/
public function __construct(
public int $status,
public string $message = '',
public float $time = 0.0,
) {}
public function isSuccess(): bool
{
return $this->status === 0;
}
}

View File

@ -60,8 +60,13 @@ final readonly class ChangedFiles
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) {
// File deleted since last run — definitely changed.
$remaining[] = $file;
// File is absent now. If the snapshot recorded it as absent
// too (sentinel ''), state is identical to last run — treat
// as unchanged. Otherwise it was present last run and got
// deleted since — that's a real change.
if ($lastRunTree[$file] !== '') {
$remaining[] = $file;
}
continue;
}
@ -92,6 +97,11 @@ final readonly class ChangedFiles
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) {
// Record the deletion with an empty-string sentinel so the
// next run recognises "still deleted" as unchanged rather
// than re-flagging the file as a fresh change.
$out[$file] = '';
continue;
}

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Support\Container;
use PHPUnit\Framework\TestStatus\TestStatus;
/**
* File-level Test Impact Analysis graph.
@ -264,7 +265,7 @@ final class Graph
];
}
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?CachedTestResult
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
@ -274,7 +275,21 @@ final class Graph
$r = $baseline['results'][$testId];
return new CachedTestResult($r['status'], $r['message'], $r['time']);
// PHPUnit's `TestStatus::from(int)` ignores messages, so reconstruct
// each variant via its specific factory. Keeps the stored message
// intact (important for skips/failures shown to the user).
return match ($r['status']) {
0 => TestStatus::success(),
1 => TestStatus::skipped($r['message']),
2 => TestStatus::incomplete($r['message']),
3 => TestStatus::notice($r['message']),
4 => TestStatus::deprecation($r['message']),
5 => TestStatus::risky($r['message']),
6 => TestStatus::warning($r['message']),
7 => TestStatus::failure($r['message']),
8 => TestStatus::error($r['message']),
default => TestStatus::unknown(),
};
}
/**

View File

@ -14,7 +14,7 @@ namespace Pest\Plugins\Tia;
final class ResultCollector
{
/**
* @var array<string, array{status: int, message: string, time: float}>
* @var array<string, array{status: int, message: string, time: float, assertions: int}>
*/
private array $results = [];
@ -83,13 +83,20 @@ final class ResultCollector
}
/**
* @return array<string, array{status: int, message: string, time: float}>
* @return array<string, array{status: int, message: string, time: float, assertions: int}>
*/
public function all(): array
{
return $this->results;
}
public function recordAssertions(string $testId, int $assertions): void
{
if (isset($this->results[$testId])) {
$this->results[$testId]['assertions'] = $assertions;
}
}
public function reset(): void
{
$this->results = [];
@ -111,6 +118,7 @@ final class ResultCollector
'status' => $status,
'message' => $message,
'time' => $time,
'assertions' => 0,
];
$this->currentTestId = null;