diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 69c48deb..8ffbeefc 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -249,6 +249,17 @@ trait Testable 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 // catches it and marks the test with the correct status so // skips, failures, incompletes and todos appear in output @@ -371,7 +382,14 @@ trait Testable private function __runTest(Closure $closure, ...$args): mixed { 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; } diff --git a/src/Kernel.php b/src/Kernel.php index 965b1f6b..aef9bda7 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -13,7 +13,6 @@ use Pest\Plugins\Actions\CallsBoot; use Pest\Plugins\Actions\CallsHandleArguments; use Pest\Plugins\Actions\CallsHandleOriginalArguments; use Pest\Plugins\Actions\CallsTerminable; -use Pest\Plugins\Tia; use Pest\Support\Container; use Pest\Support\Reflection; use Pest\Support\View; @@ -37,6 +36,7 @@ final readonly class Kernel */ private const array BOOTSTRAPPERS = [ Bootstrappers\BootOverrides::class, + Plugins\Tia\Bootstrapper::class, Bootstrappers\BootSubscribers::class, Bootstrappers\BootFiles::class, Bootstrappers\BootView::class, @@ -65,17 +65,7 @@ final readonly class Kernel ->add(TestSuite::class, $testSuite) ->add(InputInterface::class, $input) ->add(OutputInterface::class, $output) - ->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) - ->add(Tia\Contracts\State::class, new Tia\FileState(__DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'.temp')) - ->add(Tia\BaselineSync::class, new Tia\BaselineSync( - $container->get(Tia\Contracts\State::class), // @phpstan-ignore argument.type - $output, - $input, - )); + ->add(Container::class, $container); $kernel = new self( new Application, diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 1454b914..73df0ab2 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -142,6 +142,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable */ 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 + */ + private array $cachedAssertionsByTestId = []; + /** * Captured at replay setup so the end-of-run summary can report the * scope of the changes that drove the run. @@ -268,6 +278,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable if ($result !== null) { $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). @@ -278,6 +293,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable 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} */ @@ -1101,7 +1127,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable } 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'], + ); } $this->saveGraph($graph); diff --git a/src/Plugins/Tia/Bootstrapper.php b/src/Plugins/Tia/Bootstrapper.php new file mode 100644 index 00000000..6abf2ea5 --- /dev/null +++ b/src/Plugins/Tia/Bootstrapper.php @@ -0,0 +1,47 @@ +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'; + } +} diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 5f4efc0a..64356312 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -60,7 +60,7 @@ final class Graph * @var array, - * results: array + * results: array * }> */ private array $baselines = []; @@ -257,14 +257,36 @@ final class Graph $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->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 { $baseline = $this->baselineFor($branch, $fallbackBranch); @@ -310,7 +332,7 @@ final class Graph } /** - * @return array{sha: ?string, tree: array, results: array} + * @return array{sha: ?string, tree: array, results: array} */ private function baselineFor(string $branch, string $fallbackBranch): array { diff --git a/src/Plugins/Tia/ResultCollector.php b/src/Plugins/Tia/ResultCollector.php index ceabb0d4..434b4220 100644 --- a/src/Plugins/Tia/ResultCollector.php +++ b/src/Plugins/Tia/ResultCollector.php @@ -118,6 +118,17 @@ final class ResultCollector $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 { if ($this->currentTestId === null) { @@ -128,14 +139,17 @@ final class ResultCollector ? round(microtime(true) - $this->startTime, 3) : 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] = [ 'status' => $status, 'message' => $message, 'time' => $time, - 'assertions' => 0, + 'assertions' => $existing['assertions'] ?? 0, ]; - - $this->currentTestId = null; - $this->startTime = null; } } diff --git a/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php index 122f7213..a6f1e2a9 100644 --- a/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php +++ b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php @@ -30,5 +30,11 @@ final class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscrib $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(); } }