From 192f289e7e507bb10ccae5d8fa234a7a6de098a3 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 16 Apr 2026 06:17:14 -0700 Subject: [PATCH] feat(tia): adds poc --- src/Concerns/Testable.php | 29 ++++ src/Plugins/Tia.php | 107 +++++++++--- src/{Support => Plugins}/Tia/ChangedFiles.php | 2 +- src/{Support => Plugins}/Tia/Fingerprint.php | 2 +- src/{Support => Plugins}/Tia/Graph.php | 10 +- src/{Support => Plugins}/Tia/Recorder.php | 2 +- src/Plugins/Tia/State.php | 158 ++++++++++++++++++ .../Tia}/TiaTestCaseFilter.php | 4 +- .../EnsureTiaCoverageIsFlushed.php | 2 +- .../EnsureTiaCoverageIsRecorded.php | 2 +- 10 files changed, 289 insertions(+), 29 deletions(-) rename src/{Support => Plugins}/Tia/ChangedFiles.php (99%) rename src/{Support => Plugins}/Tia/Fingerprint.php (99%) rename src/{Support => Plugins}/Tia/Graph.php (97%) rename src/{Support => Plugins}/Tia/Recorder.php (99%) create mode 100644 src/Plugins/Tia/State.php rename src/{TestCaseFilters => Plugins/Tia}/TiaTestCaseFilter.php (96%) diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 3f7e3b77..d6428c53 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -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); diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index db9d0978..81e820d6 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -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 + */ + private function loadPreviousDefects(string $projectRoot): array + { + // PHPUnit writes the cache under either `/.phpunit.result.cache` + // (legacy) or `/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 $arguments * @return array @@ -386,28 +448,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $changed = $changedFiles->since($graph->recordedAtSha()) ?? []; - if ($changed === []) { - $this->output->writeln(' 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( - ' TIA %d changed file(s) → %d known test file(s) + any new/unknown tests.', + ' 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( - ' TIA %d changed file(s) → %d known test file(s) + any new/unknown tests (parallel).', + ' TIA %d changed file(s) → %d affected, remaining tests replay cached result (parallel).', count($changed), count($affected), )); diff --git a/src/Support/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php similarity index 99% rename from src/Support/Tia/ChangedFiles.php rename to src/Plugins/Tia/ChangedFiles.php index 8249d9dd..d2a568aa 100644 --- a/src/Support/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pest\Support\Tia; +namespace Pest\Plugins\Tia; use Symfony\Component\Process\Process; diff --git a/src/Support/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php similarity index 99% rename from src/Support/Tia/Fingerprint.php rename to src/Plugins/Tia/Fingerprint.php index fe2cb9f0..39561468 100644 --- a/src/Support/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -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. diff --git a/src/Support/Tia/Graph.php b/src/Plugins/Tia/Graph.php similarity index 97% rename from src/Support/Tia/Graph.php rename to src/Plugins/Tia/Graph.php index 91d7f6fc..6fd9a3bf 100644 --- a/src/Support/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -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 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; diff --git a/src/Support/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php similarity index 99% rename from src/Support/Tia/Recorder.php rename to src/Plugins/Tia/Recorder.php index e18508b3..bdb7909c 100644 --- a/src/Support/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pest\Support\Tia; +namespace Pest\Plugins\Tia; use ReflectionClass; use ReflectionException; diff --git a/src/Plugins/Tia/State.php b/src/Plugins/Tia/State.php new file mode 100644 index 00000000..9fee2dbd --- /dev/null +++ b/src/Plugins/Tia/State.php @@ -0,0 +1,158 @@ + + */ + private array $affectedFiles = []; + + /** + * Keys are project-relative test file paths. Known = recorded in graph. + * + * @var array + */ + private array $knownFiles = []; + + /** + * Test ids (class::method) that were in the previous run's defect list. + * + * @var array + */ + 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 $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))); + } +} diff --git a/src/TestCaseFilters/TiaTestCaseFilter.php b/src/Plugins/Tia/TiaTestCaseFilter.php similarity index 96% rename from src/TestCaseFilters/TiaTestCaseFilter.php rename to src/Plugins/Tia/TiaTestCaseFilter.php index 7838f346..d210b4be 100644 --- a/src/TestCaseFilters/TiaTestCaseFilter.php +++ b/src/Plugins/Tia/TiaTestCaseFilter.php @@ -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: diff --git a/src/Subscribers/EnsureTiaCoverageIsFlushed.php b/src/Subscribers/EnsureTiaCoverageIsFlushed.php index 0ccdddc1..1b3e8fa0 100644 --- a/src/Subscribers/EnsureTiaCoverageIsFlushed.php +++ b/src/Subscribers/EnsureTiaCoverageIsFlushed.php @@ -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; diff --git a/src/Subscribers/EnsureTiaCoverageIsRecorded.php b/src/Subscribers/EnsureTiaCoverageIsRecorded.php index 6be7bdd7..0d388c98 100644 --- a/src/Subscribers/EnsureTiaCoverageIsRecorded.php +++ b/src/Subscribers/EnsureTiaCoverageIsRecorded.php @@ -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;