mirror of
https://github.com/pestphp/pest.git
synced 2026-04-20 22:20:17 +02:00
feat(tia): adds poc
This commit is contained in:
@ -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);
|
||||
|
||||
|
||||
@ -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),
|
||||
));
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Support\Tia;
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
@ -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.
|
||||
@ -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;
|
||||
@ -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
158
src/Plugins/Tia/State.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user