This commit is contained in:
nuno maduro
2026-04-20 20:58:38 -07:00
parent 1476b529a1
commit a5915b16ab
7 changed files with 152 additions and 22 deletions

View File

@ -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;
}

View File

@ -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,

View File

@ -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<string, int>
*/
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);

View File

@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Contracts\Bootstrapper as BootstrapperContract;
use Pest\Plugins\Tia\Contracts\State;
use Pest\Support\Container;
/**
* Plugin-level container registrations for TIA. Runs as part of Kernel's
* bootstrapper chain so Tia's own service graph is set up without Kernel
* having to know about any of its internals.
*
* Most Tia services (`Recorder`, `CoverageCollector`, `WatchPatterns`,
* `ResultCollector`, `BaselineSync`) are auto-buildable — Pest's container
* resolves them lazily via constructor reflection. The only service that
* requires an explicit binding is the `State` contract, because the
* filesystem implementation needs a root-directory string that reflection
* can't infer.
*
* @internal
*/
final readonly class Bootstrapper implements BootstrapperContract
{
public function __construct(private Container $container) {}
public function boot(): void
{
$this->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';
}
}

View File

@ -60,7 +60,7 @@ final class Graph
* @var array<string, array{
* sha: ?string,
* tree: array<string, string>,
* results: array<string, array{status: int, message: string, time: float}>
* results: array<string, array{status: int, message: string, time: float, assertions?: int}>
* }>
*/
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<string, string>, results: array<string, array{status: int, message: string, time: float}>}
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float, assertions?: int}>}
*/
private function baselineFor(string $branch, string $fallbackBranch): array
{

View File

@ -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;
}
}

View File

@ -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();
}
}