feat(tia): continues to work on poc

This commit is contained in:
nuno maduro
2026-04-16 08:19:44 -07:00
parent 50601e6118
commit df0f440f84
17 changed files with 135 additions and 436 deletions

View File

@ -6,11 +6,7 @@ namespace Pest\Concerns;
use Closure; use Closure;
use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Contracts\Plugins\AfterEachable;
use Pest\Contracts\Plugins\BeforeEachable;
use Pest\Contracts\Plugins\Runnable;
use Pest\Panic; use Pest\Panic;
use Pest\Plugin\Loader;
use Pest\Preset; use Pest\Preset;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\ExceptionTrace; use Pest\Support\ExceptionTrace;
@ -231,13 +227,6 @@ trait Testable
{ {
TestSuite::getInstance()->test = $this; TestSuite::getInstance()->test = $this;
/** @var BeforeEachable $plugin */
foreach (Loader::getPlugins(BeforeEachable::class) as $plugin) {
if ($plugin->beforeEach(self::$__filename, $this::class.'::'.$this->name()) === false) {
return;
}
}
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
$description = $method->description; $description = $method->description;
@ -313,15 +302,6 @@ trait Testable
*/ */
protected function tearDown(...$arguments): void protected function tearDown(...$arguments): void
{ {
/** @var AfterEachable $plugin */
foreach (Loader::getPlugins(AfterEachable::class) as $plugin) {
if ($plugin->afterEach(self::$__filename, $this::class.'::'.$this->name()) === false) {
TestSuite::getInstance()->test = null;
return;
}
}
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename); $afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
if ($this->__afterEach instanceof Closure) { if ($this->__afterEach instanceof Closure) {
@ -347,15 +327,6 @@ trait Testable
*/ */
private function __runTest(Closure $closure, ...$args): mixed private function __runTest(Closure $closure, ...$args): mixed
{ {
/** @var Runnable $plugin */
foreach (Loader::getPlugins(Runnable::class) as $plugin) {
if ($plugin->run(self::$__filename, $this::class.'::'.$this->name()) === false) {
$this->addToAssertionCount(1);
return null;
}
}
$arguments = $this->__resolveTestArguments($args); $arguments = $this->__resolveTestArguments($args);
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments); $this->__ensureDatasetArgumentNameAndNumberMatches($arguments);

View File

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts\Plugins;
/**
* Called at the start of `tearDown`. Return `false` to skip the framework
* tearDown, afterEach chain, and method-level cleanup.
*
* @internal
*/
interface AfterEachable
{
/**
* @param string $filename Absolute path of the test file.
* @param string $testId Fully-qualified `Class::method` identifier.
*/
public function afterEach(string $filename, string $testId): bool;
}

View File

@ -1,26 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts\Plugins;
/**
* Plugins implementing this interface are called before each test's `setUp`.
*
* Return `false` to skip the test entirely — `setUp`, body and `tearDown`
* are all bypassed and the test counts as passed with one synthetic
* assertion. Any other return value lets the test proceed normally.
*
* Resolution happens once per process via `Loader::getPlugins`; the per-test
* call is a cheap iteration over the cached list.
*
* @internal
*/
interface BeforeEachable
{
/**
* @param string $filename Absolute path of the test file.
* @param string $testId Fully-qualified `Class::method` identifier.
*/
public function beforeEach(string $filename, string $testId): bool;
}

View File

@ -1,21 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts\Plugins;
/**
* Called around the test body (`__runTest`). Return `false` to skip the
* closure — a synthetic assertion is registered so PHPUnit does not flag the
* test as risky.
*
* @internal
*/
interface Runnable
{
/**
* @param string $filename Absolute path of the test file.
* @param string $testId Fully-qualified `Class::method` identifier.
*/
public function run(string $filename, string $testId): bool;
}

View File

@ -13,6 +13,7 @@ use Pest\Plugins\Actions\CallsBoot;
use Pest\Plugins\Actions\CallsHandleArguments; use Pest\Plugins\Actions\CallsHandleArguments;
use Pest\Plugins\Actions\CallsHandleOriginalArguments; use Pest\Plugins\Actions\CallsHandleOriginalArguments;
use Pest\Plugins\Actions\CallsTerminable; use Pest\Plugins\Actions\CallsTerminable;
use Pest\Plugins\Tia;
use Pest\Support\Container; use Pest\Support\Container;
use Pest\Support\Reflection; use Pest\Support\Reflection;
use Pest\Support\View; use Pest\Support\View;
@ -64,7 +65,9 @@ final readonly class Kernel
->add(TestSuite::class, $testSuite) ->add(TestSuite::class, $testSuite)
->add(InputInterface::class, $input) ->add(InputInterface::class, $input)
->add(OutputInterface::class, $output) ->add(OutputInterface::class, $output)
->add(Container::class, $container); ->add(Container::class, $container)
->add(Tia\Recorder::class, new Tia\Recorder)
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns);
$kernel = new self( $kernel = new self(
new Application, new Application,

View File

@ -5,20 +5,15 @@ declare(strict_types=1);
namespace Pest\Plugins; namespace Pest\Plugins;
use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\AfterEachable;
use Pest\Contracts\Plugins\BeforeEachable;
use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Runnable;
use Pest\Contracts\Plugins\Terminable; use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\NoDirtyTestsFound;
use Pest\Panic;
use Pest\Support\Container;
use Pest\Plugins\Tia\ChangedFiles; use Pest\Plugins\Tia\ChangedFiles;
use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph; use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\State; use Pest\TestCaseFilters\TiaTestCaseFilter;
use Pest\Plugins\Tia\WatchPatterns; use Pest\Plugins\Tia\WatchPatterns;
use Pest\Support\Container;
use Pest\TestSuite; use Pest\TestSuite;
use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Output\OutputInterface;
use Throwable; use Throwable;
@ -30,7 +25,7 @@ use Throwable;
* ----- * -----
* - **Record** — no graph (or fingerprint / recording commit drifted). The * - **Record** — no graph (or fingerprint / recording commit drifted). The
* full suite runs with PCOV / Xdebug capture per test; the resulting * full suite runs with PCOV / Xdebug capture per test; the resulting
* `test → [source_file, …]` edges land in `.pest/cache/tia.json`. * `test → [source_file, …]` edges land in `.temp/tia.json`.
* - **Replay** — graph valid. We diff the working tree against the recording * - **Replay** — graph valid. We diff the working tree against the recording
* commit, intersect changed files with graph edges, and run only the * commit, intersect changed files with graph edges, and run only the
* affected tests. Newly-added tests unknown to the graph are always * affected tests. Newly-added tests unknown to the graph are always
@ -53,7 +48,7 @@ use Throwable;
* - **Worker, record**: boots through `bin/worker.php`, which re-runs * - **Worker, record**: boots through `bin/worker.php`, which re-runs
* `CallsHandleArguments`. We detect the worker context + recording flag, * `CallsHandleArguments`. We detect the worker context + recording flag,
* activate the `Recorder`, and flush the partial graph on `terminate()` * activate the `Recorder`, and flush the partial graph on `terminate()`
* into `.pest/cache/tia-worker-<TEST_TOKEN>.json`. * into `.temp/tia-worker-<TEST_TOKEN>.json`.
* - **Worker, replay**: nothing to do; args already narrowed. * - **Worker, replay**: nothing to do; args already narrowed.
* *
* Guardrails * Guardrails
@ -66,7 +61,7 @@ use Throwable;
* *
* @internal * @internal
*/ */
final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArguments, Runnable, Terminable final class Tia implements AddsOutput, HandlesArguments, Terminable
{ {
use Concerns\HandleArguments; use Concerns\HandleArguments;
@ -74,11 +69,21 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
private const string REBUILD_OPTION = '--tia-rebuild'; private const string REBUILD_OPTION = '--tia-rebuild';
private const string CACHE_PATH = '.pest/cache/tia.json'; /**
* TIA cache lives inside Pest's `.temp/` directory (same location as
* PHPUnit's result cache). This directory is gitignored by default in
* Pest's own `.gitignore`, so the graph is never committed.
*/
private const string TEMP_DIR = __DIR__
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'..'
.DIRECTORY_SEPARATOR.'.temp';
private const string AFFECTED_PATH = '.pest/cache/tia-affected.json'; private const string CACHE_FILE = 'tia.json';
private const string WORKER_CACHE_PREFIX = '.pest/cache/tia-worker-'; private const string AFFECTED_FILE = 'tia-affected.json';
private const string WORKER_PREFIX = 'tia-worker-';
/** /**
* Global flag toggled by the parent process so workers know to record. * Global flag toggled by the parent process so workers know to record.
@ -87,13 +92,50 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
/** /**
* Global flag that tells workers to install the TIA filter (replay mode). * Global flag that tells workers to install the TIA filter (replay mode).
* Workers read the affected set from `.pest/cache/tia-affected.json`. * Workers read the affected set from `.temp/tia-affected.json`.
*/ */
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
private bool $graphWritten = false; private bool $graphWritten = false;
public function __construct(private readonly OutputInterface $output) {} private static function tempDir(): string
{
$dir = (string) realpath(self::TEMP_DIR);
if ($dir === '' || $dir === '.') {
// .temp doesn't exist yet — create it.
@mkdir(self::TEMP_DIR, 0755, true);
$dir = (string) realpath(self::TEMP_DIR);
}
return $dir;
}
private static function cachePath(): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::CACHE_FILE;
}
private static function affectedPath(): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::AFFECTED_FILE;
}
private static function workerPath(string $token): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.$token.'.json';
}
private static function workerGlob(): string
{
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.'*.json';
}
public function __construct(
private readonly OutputInterface $output,
private readonly Recorder $recorder,
private readonly WatchPatterns $watchPatterns,
) {}
/** /**
* {@inheritDoc} * {@inheritDoc}
@ -134,28 +176,13 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
return $this->handleParent($arguments, $projectRoot, $forceRebuild); return $this->handleParent($arguments, $projectRoot, $forceRebuild);
} }
public function beforeEach(string $filename, string $testId): bool
{
return ! State::instance()->shouldReplayFromCache($filename, $testId);
}
public function run(string $filename, string $testId): bool
{
return ! State::instance()->shouldReplayFromCache($filename, $testId);
}
public function afterEach(string $filename, string $testId): bool
{
return ! State::instance()->shouldReplayFromCache($filename, $testId);
}
public function terminate(): void public function terminate(): void
{ {
if ($this->graphWritten) { if ($this->graphWritten) {
return; return;
} }
$recorder = Recorder::instance(); $recorder = $this->recorder;
if (! $recorder->isActive()) { if (! $recorder->isActive()) {
return; return;
@ -180,7 +207,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
} }
// Non-parallel record path: straight into the main cache. // Non-parallel record path: straight into the main cache.
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; $cachePath = self::cachePath();
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
$graph->setFingerprint(Fingerprint::compute($projectRoot)); $graph->setFingerprint(Fingerprint::compute($projectRoot));
@ -198,7 +225,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
$this->output->writeln(sprintf( $this->output->writeln(sprintf(
' <fg=green>TIA</> graph recorded (%d test files) at %s', ' <fg=green>TIA</> graph recorded (%d test files) at %s',
count($perTest), count($perTest),
self::CACHE_PATH, self::CACHE_FILE,
)); ));
$recorder->reset(); $recorder->reset();
@ -226,7 +253,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
return $exitCode; return $exitCode;
} }
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; $cachePath = self::cachePath();
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
$graph->setFingerprint(Fingerprint::compute($projectRoot)); $graph->setFingerprint(Fingerprint::compute($projectRoot));
@ -273,7 +300,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials) at %s', ' <fg=green>TIA</> graph recorded (%d test files, %d worker partials) at %s',
count($finalised), count($finalised),
count($partials), count($partials),
self::CACHE_PATH, self::CACHE_FILE,
)); ));
return $exitCode; return $exitCode;
@ -288,9 +315,9 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
// Initialise watch patterns (defaults + any user additions from // Initialise watch patterns (defaults + any user additions from
// tests/Pest.php which has already been loaded by BootFiles at // tests/Pest.php which has already been loaded by BootFiles at
// this point). // this point).
WatchPatterns::instance()->useDefaults($projectRoot); $this->watchPatterns->useDefaults($projectRoot);
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; $cachePath = self::cachePath();
$fingerprint = Fingerprint::compute($projectRoot); $fingerprint = Fingerprint::compute($projectRoot);
$graph = $forceRebuild ? null : Graph::load($projectRoot, $cachePath); $graph = $forceRebuild ? null : Graph::load($projectRoot, $cachePath);
@ -342,7 +369,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
return $arguments; return $arguments;
} }
$recorder = Recorder::instance(); $recorder = $this->recorder;
if (! $recorder->driverAvailable()) { if (! $recorder->driverAvailable()) {
// Driver availability is per-process. If the driver is missing // Driver availability is per-process. If the driver is missing
@ -358,8 +385,8 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
private function installWorkerReplayFilter(string $projectRoot): void private function installWorkerReplayFilter(string $projectRoot): void
{ {
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; $cachePath = self::cachePath();
$affectedPath = $projectRoot.DIRECTORY_SEPARATOR.self::AFFECTED_PATH; $affectedPath = self::affectedPath();
$graph = Graph::load($projectRoot, $cachePath); $graph = Graph::load($projectRoot, $cachePath);
@ -387,73 +414,11 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
} }
} }
State::instance()->activate( TestSuite::getInstance()->tests->addTestCaseFilter(
$projectRoot, new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
$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 * @param array<int, string> $arguments
* @return array<int, string> * @return array<int, string>
@ -471,48 +436,31 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
} }
$changed = $changedFiles->since($graph->recordedAtSha()) ?? []; $changed = $changedFiles->since($graph->recordedAtSha()) ?? [];
// 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); $affected = $changed === [] ? [] : $graph->affected($changed);
$testSuite = TestSuite::getInstance(); $totalKnown = count($graph->allTestFiles());
$affectedCount = count($affected);
$cachedCount = $totalKnown - $affectedCount;
if (! Parallel::isEnabled()) { $testSuite = TestSuite::getInstance();
// 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); $affectedSet = array_fill_keys($affected, true);
State::instance()->activate( if (! Parallel::isEnabled()) {
$projectRoot, $testSuite->tests->addTestCaseFilter(
$graph, new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
$affectedSet,
$this->loadPreviousDefects($projectRoot),
); );
$this->output->writeln(sprintf( $this->output->writeln(sprintf(
' <fg=green>TIA</> %d changed file(s) → %d affected, remaining tests replay cached result.', ' <fg=green>TIA</> %d changed file(s) → %d affected, %d cached.',
count($changed), count($changed),
count($affected), $affectedCount,
$cachedCount,
)); ));
return $arguments; return $arguments;
} }
// Parallel mode. Paratest's CLI only accepts a single positional // Parallel: persist affected set so workers can install the filter.
// `<path>`, so we cannot pass the affected set as multiple args.
// Instead, persist the affected set to a cache file and flip a
// global that tells each worker to install the TIA filter on boot.
//
// Cost trade-off: each worker still discovers the full test tree,
// but the filter drops unaffected tests before they ever run. Narrow
// CLI handoff would be ideal; it requires generating a temporary
// phpunit.xml and is out of scope for the MVP.
if (! $this->persistAffectedSet($projectRoot, $affected)) { if (! $this->persistAffectedSet($projectRoot, $affected)) {
$this->output->writeln( $this->output->writeln(
' <fg=red>TIA</> failed to persist affected set — running full suite.', ' <fg=red>TIA</> failed to persist affected set — running full suite.',
@ -524,9 +472,10 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1'); Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
$this->output->writeln(sprintf( $this->output->writeln(sprintf(
' <fg=green>TIA</> %d changed file(s) → %d affected, remaining tests replay cached result (parallel).', ' <fg=green>TIA</> %d changed file(s) → %d affected, %d cached (parallel).',
count($changed), count($changed),
count($affected), $affectedCount,
$cachedCount,
)); ));
return $arguments; return $arguments;
@ -537,7 +486,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
*/ */
private function persistAffectedSet(string $projectRoot, array $affected): bool private function persistAffectedSet(string $projectRoot, array $affected): bool
{ {
$path = $projectRoot.DIRECTORY_SEPARATOR.self::AFFECTED_PATH; $path = self::affectedPath();
$dir = dirname($path); $dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
@ -588,15 +537,19 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
return $arguments; return $arguments;
} }
$recorder = Recorder::instance(); $recorder = $this->recorder;
if (! $recorder->driverAvailable()) { if (! $recorder->driverAvailable()) {
$this->output->writeln( $this->output->writeln([
' <fg=red>TIA</> No coverage driver is available. '. '',
'Install ext-pcov or enable Xdebug in coverage mode, then rerun with `--tia`.', ' <fg=white;options=bold;bg=red> ERROR </> No coverage driver is available.',
); '',
' TIA requires ext-pcov or Xdebug with coverage mode enabled to',
' record the dependency graph. Install one and rerun with `--tia`.',
'',
]);
return $arguments; exit(1);
} }
$recorder->activate(); $recorder->activate();
@ -624,7 +577,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
$token = (string) getmypid(); $token = (string) getmypid();
} }
$path = $projectRoot.DIRECTORY_SEPARATOR.self::WORKER_CACHE_PREFIX.$token.'.json'; $path = self::workerPath($token);
$dir = dirname($path); $dir = dirname($path);
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
@ -653,7 +606,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
*/ */
private function collectWorkerPartials(string $projectRoot): array private function collectWorkerPartials(string $projectRoot): array
{ {
$pattern = $projectRoot.DIRECTORY_SEPARATOR.self::WORKER_CACHE_PREFIX.'*.json'; $pattern = self::workerGlob();
$matches = glob($pattern); $matches = glob($pattern);
return $matches === false ? [] : $matches; return $matches === false ? [] : $matches;
@ -686,10 +639,12 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
$out = []; $out = [];
foreach ($data as $test => $sources) { foreach ($data as $test => $sources) {
if (! is_string($test) || ! is_array($sources)) { if (! is_string($test)) {
continue;
}
if (! is_array($sources)) {
continue; continue;
} }
$clean = []; $clean = [];
foreach ($sources as $source) { foreach ($sources as $source) {

View File

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Support\Container;
/** /**
* User-facing TIA configuration, returned by `pest()->tia()`. * User-facing TIA configuration, returned by `pest()->tia()`.
* *
@ -31,7 +33,9 @@ final class Configuration
*/ */
public function watch(array $patterns): self public function watch(array $patterns): self
{ {
WatchPatterns::instance()->add($patterns); /** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->add($patterns);
return $this; return $this;
} }

View File

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Support\Container;
/** /**
* File-level Test Impact Analysis graph. * File-level Test Impact Analysis graph.
* *
@ -127,7 +129,8 @@ final class Graph
} }
// 2. Watch-pattern lookup (non-PHP assets → test directories). // 2. Watch-pattern lookup (non-PHP assets → test directories).
$watchPatterns = WatchPatterns::instance(); /** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$normalised = []; $normalised = [];
foreach ($changedFiles as $file) { foreach ($changedFiles as $file) {

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use ReflectionClass; use ReflectionClass;
use ReflectionException;
/** /**
* Captures per-test file coverage using the PCOV driver. * Captures per-test file coverage using the PCOV driver.
@ -18,8 +17,6 @@ use ReflectionException;
*/ */
final class Recorder final class Recorder
{ {
private static ?self $instance = null;
/** /**
* Test file currently being recorded, or `null` when idle. * Test file currently being recorded, or `null` when idle.
*/ */
@ -47,11 +44,6 @@ final class Recorder
private string $driver = 'none'; private string $driver = 'none';
public static function instance(): self
{
return self::$instance ??= new self;
}
public function activate(): void public function activate(): void
{ {
$this->active = true; $this->active = true;

View File

@ -1,159 +0,0 @@
<?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
* @param array<string, true> $previousDefects
*/
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)));
}
}

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults; namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions; use Composer\InstalledVersions;
use Pest\Browser\Support\BrowserTestIdentifier;
use Pest\Factories\TestCaseFactory; use Pest\Factories\TestCaseFactory;
use Pest\TestSuite; use Pest\TestSuite;
@ -72,7 +73,7 @@ final readonly class Browser implements WatchDefault
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser // Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
// is installed to find tests using `visit()` outside the conventional // is installed to find tests using `visit()` outside the conventional
// Browser/ folder. // Browser/ folder.
if (class_exists(\Pest\Browser\Support\BrowserTestIdentifier::class)) { if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests; $repo = TestSuite::getInstance()->tests;
foreach ($repo->getFilenames() as $filename) { foreach ($repo->getFilenames() as $filename) {
@ -83,7 +84,7 @@ final readonly class Browser implements WatchDefault
} }
foreach ($factory->methods as $method) { foreach ($factory->methods as $method) {
if (\Pest\Browser\Support\BrowserTestIdentifier::isBrowserTest($method)) { if (BrowserTestIdentifier::isBrowserTest($method)) {
$rel = $this->fileRelative($projectRoot, $filename); $rel = $this->fileRelative($projectRoot, $filename);
if ($rel !== null) { if ($rel !== null) {

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\WatchDefaults\WatchDefault; use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
use Pest\TestSuite;
/** /**
* Maps non-PHP file globs to the test directories they should invalidate. * Maps non-PHP file globs to the test directories they should invalidate.
@ -41,13 +42,6 @@ final class WatchPatterns
*/ */
private array $patterns = []; private array $patterns = [];
private static ?self $instance = null;
public static function instance(): self
{
return self::$instance ??= new self;
}
/** /**
* Probes every registered `WatchDefault` and merges the patterns of * Probes every registered `WatchDefault` and merges the patterns of
* those that apply. Called once during Tia plugin boot, after BootFiles * those that apply. Called once during Tia plugin boot, after BootFiles
@ -56,7 +50,7 @@ final class WatchPatterns
*/ */
public function useDefaults(string $projectRoot): void public function useDefaults(string $projectRoot): void
{ {
$testPath = \Pest\TestSuite::getInstance()->testPath; $testPath = TestSuite::getInstance()->testPath;
foreach (self::DEFAULTS as $class) { foreach (self::DEFAULTS as $class) {
$default = new $class; $default = new $class;

View File

@ -14,10 +14,12 @@ use PHPUnit\Event\Test\FinishedSubscriber;
* *
* @internal * @internal
*/ */
final class EnsureTiaCoverageIsFlushed implements FinishedSubscriber final readonly class EnsureTiaCoverageIsFlushed implements FinishedSubscriber
{ {
public function __construct(private Recorder $recorder) {}
public function notify(Finished $event): void public function notify(Finished $event): void
{ {
Recorder::instance()->endTest(); $this->recorder->endTest();
} }
} }

View File

@ -15,13 +15,13 @@ use PHPUnit\Event\Test\PreparedSubscriber;
* *
* @internal * @internal
*/ */
final class EnsureTiaCoverageIsRecorded implements PreparedSubscriber final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
{ {
public function __construct(private Recorder $recorder) {}
public function notify(Prepared $event): void public function notify(Prepared $event): void
{ {
$recorder = Recorder::instance(); if (! $this->recorder->isActive()) {
if (! $recorder->isActive()) {
return; return;
} }
@ -31,6 +31,6 @@ final class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
return; return;
} }
$recorder->beginTest($test->className(), $test->methodName(), $test->file()); $this->recorder->beginTest($test->className(), $test->methodName(), $test->file());
} }
} }

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Pest\Plugins\Tia; namespace Pest\TestCaseFilters;
use Pest\Contracts\TestCaseFilter; use Pest\Contracts\TestCaseFilter;
use Pest\Plugins\Tia\Graph; use Pest\Plugins\Tia\Graph;