feat(tia): adds poc

This commit is contained in:
nuno maduro
2026-04-16 06:17:14 -07:00
parent 4b8e303cd5
commit 192f289e7e
10 changed files with 289 additions and 29 deletions

View File

@ -12,6 +12,7 @@ use Pest\Support\ChainableClosure;
use Pest\Support\ExceptionTrace;
use Pest\Support\Reflection;
use Pest\Support\Shell;
use Pest\Plugins\Tia\State as TiaState;
use Pest\TestSuite;
use PHPUnit\Framework\Attributes\PostCondition;
use PHPUnit\Framework\IncompleteTest;
@ -227,6 +228,16 @@ trait Testable
{
TestSuite::getInstance()->test = $this;
// TIA replay fast-path. When the file is known to the dependency graph
// and none of its deps changed since recording, skip both the
// framework `setUp()` (Laravel app bootstrap, DB refresh, etc.) and
// the user `beforeEach` chain. The matching short-circuit inside
// `__runTest()` ensures the test body never executes, so no
// initialisation is needed.
if (TiaState::instance()->shouldReplayFromCache(self::$__filename, $this::class.'::'.$this->name())) {
return;
}
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$description = $method->description;
@ -302,6 +313,15 @@ trait Testable
*/
protected function tearDown(...$arguments): void
{
// TIA replay: setUp was skipped, the closure never ran — there is
// no matching cleanup to perform here. Keep the framework invariant
// of clearing the "current test" pointer and bail out.
if (TiaState::instance()->shouldReplayFromCache(self::$__filename, $this::class.'::'.$this->name())) {
TestSuite::getInstance()->test = null;
return;
}
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
if ($this->__afterEach instanceof Closure) {
@ -327,6 +347,15 @@ trait Testable
*/
private function __runTest(Closure $closure, ...$args): mixed
{
// TIA replay: the file's deps haven't changed and it last passed.
// Bypass the closure entirely and register a synthetic assertion so
// PHPUnit does not emit a "risky: no assertions" warning.
if (TiaState::instance()->shouldReplayFromCache(self::$__filename, $this::class.'::'.$this->name())) {
$this->addToAssertionCount(1);
return null;
}
$arguments = $this->__resolveTestArguments($args);
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);

View File

@ -10,11 +10,11 @@ use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\NoDirtyTestsFound;
use Pest\Panic;
use Pest\Support\Container;
use Pest\Support\Tia\ChangedFiles;
use Pest\Support\Tia\Fingerprint;
use Pest\Support\Tia\Graph;
use Pest\Support\Tia\Recorder;
use Pest\TestCaseFilters\TiaTestCaseFilter;
use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\State;
use Pest\TestSuite;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
@ -363,11 +363,73 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
}
TestSuite::getInstance()->tests->addTestCaseFilter(
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
State::instance()->activate(
$projectRoot,
$graph,
$affectedSet,
$this->loadPreviousDefects($projectRoot),
);
}
/**
* Reads PHPUnit's own result cache and returns the test ids that failed
* or errored in the previous run. These are excluded from replay so the
* user sees current state rather than a stale pass.
*
* @return array<string, true>
*/
private function loadPreviousDefects(string $projectRoot): array
{
// PHPUnit writes the cache under either `<projectRoot>/.phpunit.result.cache`
// (legacy) or `<cacheDirectory>/test-results`. Pest's Cache plugin
// additionally defaults `cacheDirectory` to
// `vendor/pestphp/pest/.temp` when the user hasn't configured one.
// We probe the common locations; if we miss the file, replay falls
// back to its safe default (still runs the test).
$candidates = [
$projectRoot.'/.phpunit.result.cache',
$projectRoot.'/.phpunit.cache/test-results',
$projectRoot.'/.pest/cache/test-results',
$projectRoot.'/vendor/pestphp/pest/.temp/test-results',
];
$path = null;
foreach ($candidates as $candidate) {
if (is_file($candidate)) {
$path = $candidate;
break;
}
}
if ($path === null) {
return [];
}
$raw = @file_get_contents($path);
if ($raw === false) {
return [];
}
$data = json_decode($raw, true);
if (! is_array($data) || ! isset($data['defects']) || ! is_array($data['defects'])) {
return [];
}
$out = [];
foreach ($data['defects'] as $id => $_status) {
if (is_string($id)) {
$out[$id] = true;
}
}
return $out;
}
/**
* @param array<int, string> $arguments
* @return array<int, string>
@ -386,28 +448,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$changed = $changedFiles->since($graph->recordedAtSha()) ?? [];
if ($changed === []) {
$this->output->writeln(' <fg=green>TIA</> no changes detected.');
Panic::with(new NoDirtyTestsFound);
}
$affected = $graph->affected($changed);
// Even with zero changes, we still run through the suite so the user
// sees the previous results reflected (cached passes replay as
// instant passes; failures re-run to surface current state). This
// matches the UX of test runners like NCrunch where every run
// produces a full report regardless of what actually executed.
$affected = $changed === [] ? [] : $graph->affected($changed);
$testSuite = TestSuite::getInstance();
if (! Parallel::isEnabled()) {
// Series mode: install the TestCaseFilter so Pest/PHPUnit skips
// unaffected tests during discovery. Keep filter semantics
// identical to parallel mode: unknown/new tests always pass.
// Series mode: activate replay state. Tests still appear in the
// run (correct counts, coverage aggregation, event timeline);
// unaffected ones short-circuit inside `Testable::__runTest`
// and replay their previous passing status.
$affectedSet = array_fill_keys($affected, true);
$testSuite->tests->addTestCaseFilter(
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
State::instance()->activate(
$projectRoot,
$graph,
$affectedSet,
$this->loadPreviousDefects($projectRoot),
);
$this->output->writeln(sprintf(
' <fg=green>TIA</> %d changed file(s) → %d known test file(s) + any new/unknown tests.',
' <fg=green>TIA</> %d changed file(s) → %d affected, remaining tests replay cached result.',
count($changed),
count($affected),
));
@ -435,7 +500,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
$this->output->writeln(sprintf(
' <fg=green>TIA</> %d changed file(s) → %d known test file(s) + any new/unknown tests (parallel).',
' <fg=green>TIA</> %d changed file(s) → %d affected, remaining tests replay cached result (parallel).',
count($changed),
count($affected),
));

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Pest\Support\Tia;
namespace Pest\Plugins\Tia;
use Symfony\Component\Process\Process;

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Pest\Support\Tia;
namespace Pest\Plugins\Tia;
/**
* Captures environmental inputs that, when changed, make the TIA graph stale.

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Pest\Support\Tia;
namespace Pest\Plugins\Tia;
/**
* File-level Test Impact Analysis graph.
@ -132,6 +132,14 @@ final class Graph
return $rel !== null && isset($this->edges[$rel]);
}
/**
* @return array<int, string> All project-relative test files the graph knows.
*/
public function allTestFiles(): array
{
return array_keys($this->edges);
}
public function setFingerprint(array $fingerprint): void
{
$this->fingerprint = $fingerprint;

View File

@ -2,7 +2,7 @@
declare(strict_types=1);
namespace Pest\Support\Tia;
namespace Pest\Plugins\Tia;
use ReflectionClass;
use ReflectionException;

158
src/Plugins/Tia/State.php Normal file
View File

@ -0,0 +1,158 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Shared TIA replay state consulted by Pest's `Testable` trait at runtime.
*
* Why a singleton: the plugin runs in `handleArguments` (before tests are
* discovered), but the actual replay decision has to happen when each test
* boots (`setUp` / `__runTest`). Those call sites are inside a trait that
* has no easy way to inject dependencies, so they reach into this state
* holder.
*
* Decision: a test file replays its previous pass iff
* 1. TIA replay mode is active,
* 2. the file is **known** to the dependency graph,
* 3. the file is **not** in the affected set (its deps are unchanged),
* 4. it was **not** in the previous run's defect list (only cached passes
* replay; previously-failing tests rerun so users see current state).
*
* Points 1-3 live in this class. Point 4 uses PHPUnit's own
* `DefaultResultCache`, queried at decision time.
*
* @internal
*/
final class State
{
private static ?self $instance = null;
private bool $replayMode = false;
/**
* Keys are project-relative test file paths. Affected = must rerun.
*
* @var array<string, true>
*/
private array $affectedFiles = [];
/**
* Keys are project-relative test file paths. Known = recorded in graph.
*
* @var array<string, true>
*/
private array $knownFiles = [];
/**
* Test ids (class::method) that were in the previous run's defect list.
*
* @var array<string, true>
*/
private array $previousDefects = [];
/**
* Canonicalised project root used for relative-path calculations.
*/
private string $projectRoot = '';
public static function instance(): self
{
return self::$instance ??= new self;
}
/**
* Turns on replay mode with the given graph + affected set.
*
* @param array<string, true> $affectedFiles
*/
public function activate(string $projectRoot, Graph $graph, array $affectedFiles, array $previousDefects): void
{
$real = @realpath($projectRoot);
$this->projectRoot = $real !== false ? $real : $projectRoot;
$this->replayMode = true;
$this->affectedFiles = $affectedFiles;
$this->previousDefects = $previousDefects;
// Pre-compute the known set from the graph so per-test lookups stay
// O(1). Iterating edges once here beats calling `Graph::knowsTest`
// from every test's `setUp`.
$this->knownFiles = [];
foreach ($graph->allTestFiles() as $rel) {
$this->knownFiles[$rel] = true;
}
}
public function isReplayMode(): bool
{
return $this->replayMode;
}
/**
* Returns `true` when the given absolute test file should replay its
* previous passing result instead of re-executing. `$testId` may be
* `null` when the caller cannot cheaply determine it (e.g. early in
* `setUp` before PHPUnit has published the name) — in that case we
* replay iff the file is safe at the file level, and `__runTest` will
* repeat the check with a proper id.
*/
public function shouldReplayFromCache(string $absoluteTestFile, ?string $testId = null): bool
{
if (! $this->replayMode) {
return false;
}
$rel = $this->relative($absoluteTestFile);
if ($rel === null) {
return false;
}
if (! isset($this->knownFiles[$rel])) {
return false;
}
if (isset($this->affectedFiles[$rel])) {
return false;
}
if ($testId !== null && isset($this->previousDefects[$testId])) {
return false;
}
return true;
}
public function reset(): void
{
$this->replayMode = false;
$this->affectedFiles = [];
$this->knownFiles = [];
$this->previousDefects = [];
$this->projectRoot = '';
}
private function relative(string $path): ?string
{
if ($path === '' || $this->projectRoot === '') {
return null;
}
$real = @realpath($path);
if ($real === false) {
$real = $path;
}
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (! str_starts_with($real, $root)) {
return null;
}
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
}
}

View File

@ -2,10 +2,10 @@
declare(strict_types=1);
namespace Pest\TestCaseFilters;
namespace Pest\Plugins\Tia;
use Pest\Contracts\TestCaseFilter;
use Pest\Support\Tia\Graph;
use Pest\Plugins\Tia\Graph;
/**
* Accepts a test file in one of three cases:

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Support\Tia\Recorder;
use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber;

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Support\Tia\Recorder;
use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;