diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php index c01af8f5..f605d9be 100644 --- a/src/Bootstrappers/BootSubscribers.php +++ b/src/Bootstrappers/BootSubscribers.php @@ -28,6 +28,13 @@ final readonly class BootSubscribers implements Bootstrapper Subscribers\EnsureTiaCoverageIsRecorded::class, Subscribers\EnsureTiaCoverageIsFlushed::class, Subscribers\EnsureTiaResultsAreCollected::class, + Subscribers\EnsureTiaResultIsRecordedOnPassed::class, + Subscribers\EnsureTiaResultIsRecordedOnFailed::class, + Subscribers\EnsureTiaResultIsRecordedOnErrored::class, + Subscribers\EnsureTiaResultIsRecordedOnSkipped::class, + Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class, + Subscribers\EnsureTiaResultIsRecordedOnRisky::class, + Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class, ]; /** diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 38488934..69c48deb 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -5,17 +5,17 @@ declare(strict_types=1); namespace Pest\Concerns; use Closure; -use Pest\Contracts\Plugins\BeforeEachable; use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Panic; -use Pest\Plugin\Loader; -use Pest\Plugins\Tia\CachedTestResult; +use Pest\Plugins\Tia; use Pest\Preset; +use Pest\Support\Container; use Pest\Support\ChainableClosure; use Pest\Support\ExceptionTrace; use Pest\Support\Reflection; use Pest\Support\Shell; use Pest\TestSuite; +use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\IncompleteTest; use PHPUnit\Framework\SkippedTest; @@ -238,27 +238,30 @@ trait Testable $this->__cachedPass = false; - /** @var BeforeEachable $plugin */ - foreach (Loader::getPlugins(BeforeEachable::class) as $plugin) { - $cached = $plugin->beforeEach(self::$__filename, $this::class.'::'.$this->name()); + /** @var Tia $tia */ + $tia = Container::getInstance()->get(Tia::class); + $cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name()); - if ($cached instanceof CachedTestResult) { - if ($cached->isSuccess()) { - $this->__cachedPass = true; + if ($cached !== null) { + if ($cached->isSuccess()) { + $this->__cachedPass = true; - return; - } - - // Non-success: throw appropriate exception. PHPUnit catches - // it in runBare() and marks the test with the correct status. - // This makes skips, failures, incompletes, todos appear in - // output exactly as if the test ran. - match ($cached->status) { - 1 => $this->markTestSkipped($cached->message), // skip / todo - 2 => $this->markTestIncomplete($cached->message), // incomplete - default => throw new \PHPUnit\Framework\AssertionFailedError($cached->message ?: 'Cached failure'), - }; + return; } + + // Non-success: throw the matching PHPUnit exception. Runner + // catches it and marks the test with the correct status so + // skips, failures, incompletes and todos appear in output + // exactly as they did in the cached run. + if ($cached->isSkipped()) { + $this->markTestSkipped($cached->message()); + } + + if ($cached->isIncomplete()) { + $this->markTestIncomplete($cached->message()); + } + + throw new AssertionFailedError($cached->message() ?: 'Cached failure'); } $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); diff --git a/src/Contracts/Plugins/BeforeEachable.php b/src/Contracts/Plugins/BeforeEachable.php deleted file mode 100644 index 436cfcec..00000000 --- a/src/Contracts/Plugins/BeforeEachable.php +++ /dev/null @@ -1,25 +0,0 @@ -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( - ' 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( - ' 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( + ' TIA %d changed file(s) → %d affected, %d replayed.', + $this->changedFileCount, + $this->affectedTestCount, + $this->replayedCount, + )); + } + private function bumpRecordedSha(): void { $projectRoot = TestSuite::getInstance()->rootPath; diff --git a/src/Plugins/Tia/CachedTestResult.php b/src/Plugins/Tia/CachedTestResult.php deleted file mode 100644 index d63bec73..00000000 --- a/src/Plugins/Tia/CachedTestResult.php +++ /dev/null @@ -1,33 +0,0 @@ -status === 0; - } -} diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 226513fe..3a7a62f4 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -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; } diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 02102df8..24643bb3 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -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(), + }; } /** diff --git a/src/Plugins/Tia/ResultCollector.php b/src/Plugins/Tia/ResultCollector.php index acf8ebbe..a2fd50f5 100644 --- a/src/Plugins/Tia/ResultCollector.php +++ b/src/Plugins/Tia/ResultCollector.php @@ -14,7 +14,7 @@ namespace Pest\Plugins\Tia; final class ResultCollector { /** - * @var array + * @var array */ private array $results = []; @@ -83,13 +83,20 @@ final class ResultCollector } /** - * @return array + * @return array */ 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; diff --git a/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php new file mode 100644 index 00000000..122f7213 --- /dev/null +++ b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php @@ -0,0 +1,34 @@ +test(); + + if ($test instanceof TestMethod) { + $this->collector->recordAssertions( + $test->className().'::'.$test->methodName(), + $event->numberOfAssertionsPerformed(), + ); + } + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php b/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php new file mode 100644 index 00000000..c6cd4a27 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php @@ -0,0 +1,22 @@ +collector->testErrored($event->throwable()->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php new file mode 100644 index 00000000..c46cf8a0 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php @@ -0,0 +1,22 @@ +collector->testFailed($event->throwable()->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php b/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php new file mode 100644 index 00000000..fe91ecbb --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php @@ -0,0 +1,22 @@ +collector->testIncomplete($event->throwable()->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php new file mode 100644 index 00000000..739b213a --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php @@ -0,0 +1,22 @@ +collector->testPassed(); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php b/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php new file mode 100644 index 00000000..8554816e --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php @@ -0,0 +1,22 @@ +collector->testRisky($event->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php b/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php new file mode 100644 index 00000000..1b94cf66 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php @@ -0,0 +1,22 @@ +collector->testSkipped($event->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultsAreCollected.php b/src/Subscribers/EnsureTiaResultsAreCollected.php index 4643c2bb..aec17fea 100644 --- a/src/Subscribers/EnsureTiaResultsAreCollected.php +++ b/src/Subscribers/EnsureTiaResultsAreCollected.php @@ -6,81 +6,30 @@ namespace Pest\Subscribers; use Pest\Plugins\Tia\ResultCollector; use PHPUnit\Event\Code\TestMethod; -use PHPUnit\Event\Test\ConsideredRisky; -use PHPUnit\Event\Test\ConsideredRiskySubscriber; -use PHPUnit\Event\Test\Errored; -use PHPUnit\Event\Test\ErroredSubscriber; -use PHPUnit\Event\Test\Failed; -use PHPUnit\Event\Test\FailedSubscriber; -use PHPUnit\Event\Test\MarkedIncomplete; -use PHPUnit\Event\Test\MarkedIncompleteSubscriber; -use PHPUnit\Event\Test\Passed; -use PHPUnit\Event\Test\PassedSubscriber; use PHPUnit\Event\Test\Prepared; use PHPUnit\Event\Test\PreparedSubscriber; -use PHPUnit\Event\Test\Skipped; -use PHPUnit\Event\Test\SkippedSubscriber; /** - * Feeds per-test outcomes (status + message + time) into the TIA - * `ResultCollector` so the graph can persist them for faithful replay. + * Starts a per-test recording window on Prepared. Sibling subscribers + * (`EnsureTia*`) close it with the outcome and the assertion count so the + * graph can persist everything needed for faithful replay. + * + * Why one subscriber per event: PHPUnit's `TypeMap::map()` picks only the + * first subscriber interface it finds on a class, so one class cannot fan + * out to multiple events — each event needs its own subscriber class. * * @internal */ -final class EnsureTiaResultsAreCollected implements - ConsideredRiskySubscriber, - ErroredSubscriber, - FailedSubscriber, - MarkedIncompleteSubscriber, - PassedSubscriber, - PreparedSubscriber, - SkippedSubscriber +final class EnsureTiaResultsAreCollected implements PreparedSubscriber { public function __construct(private readonly ResultCollector $collector) {} - public function notify(Prepared|Passed|Failed|Errored|Skipped|MarkedIncomplete|ConsideredRisky $event): void + public function notify(Prepared $event): void { - if ($event instanceof Prepared) { - $test = $event->test(); + $test = $event->test(); - if ($test instanceof TestMethod) { - $this->collector->testPrepared($test->className().'::'.$test->methodName()); - } - - return; + if ($test instanceof TestMethod) { + $this->collector->testPrepared($test->className().'::'.$test->methodName()); } - - if ($event instanceof Passed) { - $this->collector->testPassed(); - - return; - } - - if ($event instanceof Failed) { - $this->collector->testFailed($event->throwable()->message()); - - return; - } - - if ($event instanceof Errored) { - $this->collector->testErrored($event->throwable()->message()); - - return; - } - - if ($event instanceof Skipped) { - $this->collector->testSkipped($event->message()); - - return; - } - - if ($event instanceof MarkedIncomplete) { - $this->collector->testIncomplete($event->throwable()->message()); - - return; - } - - // Last possible type: ConsideredRisky (all others returned above). - $this->collector->testRisky($event->message()); // @phpstan-ignore method.notFound } } diff --git a/tests/Arch.php b/tests/Arch.php index 56c45107..3eca267a 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -37,7 +37,6 @@ arch('contracts') ->toOnlyUse([ 'NunoMaduro\Collision\Contracts', 'Pest\Factories\TestCaseMethodFactory', - 'Pest\Plugins\Tia\CachedTestResult', 'Symfony\Component\Console', 'Pest\Arch\Contracts', 'Pest\PendingCalls',