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 Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Contracts\Plugins\AfterEachable;
use Pest\Contracts\Plugins\BeforeEachable;
use Pest\Contracts\Plugins\Runnable;
use Pest\Panic;
use Pest\Plugin\Loader;
use Pest\Preset;
use Pest\Support\ChainableClosure;
use Pest\Support\ExceptionTrace;
@ -231,13 +227,6 @@ trait Testable
{
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());
$description = $method->description;
@ -313,15 +302,6 @@ trait Testable
*/
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);
if ($this->__afterEach instanceof Closure) {
@ -347,15 +327,6 @@ trait Testable
*/
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);
$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\CallsHandleOriginalArguments;
use Pest\Plugins\Actions\CallsTerminable;
use Pest\Plugins\Tia;
use Pest\Support\Container;
use Pest\Support\Reflection;
use Pest\Support\View;
@ -64,7 +65,9 @@ final readonly class Kernel
->add(TestSuite::class, $testSuite)
->add(InputInterface::class, $input)
->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(
new Application,

View File

@ -5,20 +5,15 @@ declare(strict_types=1);
namespace Pest\Plugins;
use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\AfterEachable;
use Pest\Contracts\Plugins\BeforeEachable;
use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Runnable;
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\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\State;
use Pest\TestCaseFilters\TiaTestCaseFilter;
use Pest\Plugins\Tia\WatchPatterns;
use Pest\Support\Container;
use Pest\TestSuite;
use Symfony\Component\Console\Output\OutputInterface;
use Throwable;
@ -30,7 +25,7 @@ use Throwable;
* -----
* - **Record** — no graph (or fingerprint / recording commit drifted). The
* 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
* commit, intersect changed files with graph edges, and run only the
* 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
* `CallsHandleArguments`. We detect the worker context + recording flag,
* 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.
*
* Guardrails
@ -66,7 +61,7 @@ use Throwable;
*
* @internal
*/
final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArguments, Runnable, Terminable
final class Tia implements AddsOutput, HandlesArguments, Terminable
{
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 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.
@ -87,13 +92,50 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
/**
* 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 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}
@ -134,28 +176,13 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
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
{
if ($this->graphWritten) {
return;
}
$recorder = Recorder::instance();
$recorder = $this->recorder;
if (! $recorder->isActive()) {
return;
@ -180,7 +207,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
}
// 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->setFingerprint(Fingerprint::compute($projectRoot));
@ -198,7 +225,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
$this->output->writeln(sprintf(
' <fg=green>TIA</> graph recorded (%d test files) at %s',
count($perTest),
self::CACHE_PATH,
self::CACHE_FILE,
));
$recorder->reset();
@ -226,7 +253,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
return $exitCode;
}
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH;
$cachePath = self::cachePath();
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($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',
count($finalised),
count($partials),
self::CACHE_PATH,
self::CACHE_FILE,
));
return $exitCode;
@ -288,9 +315,9 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
// Initialise watch patterns (defaults + any user additions from
// tests/Pest.php which has already been loaded by BootFiles at
// this point).
WatchPatterns::instance()->useDefaults($projectRoot);
$this->watchPatterns->useDefaults($projectRoot);
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH;
$cachePath = self::cachePath();
$fingerprint = Fingerprint::compute($projectRoot);
$graph = $forceRebuild ? null : Graph::load($projectRoot, $cachePath);
@ -342,7 +369,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
return $arguments;
}
$recorder = Recorder::instance();
$recorder = $this->recorder;
if (! $recorder->driverAvailable()) {
// 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
{
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH;
$affectedPath = $projectRoot.DIRECTORY_SEPARATOR.self::AFFECTED_PATH;
$cachePath = self::cachePath();
$affectedPath = self::affectedPath();
$graph = Graph::load($projectRoot, $cachePath);
@ -387,73 +414,11 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
}
}
State::instance()->activate(
$projectRoot,
$graph,
$affectedSet,
$this->loadPreviousDefects($projectRoot),
TestSuite::getInstance()->tests->addTestCaseFilter(
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
);
}
/**
* 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>
@ -471,48 +436,31 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
}
$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);
$totalKnown = count($graph->allTestFiles());
$affectedCount = count($affected);
$cachedCount = $totalKnown - $affectedCount;
$testSuite = TestSuite::getInstance();
$affectedSet = array_fill_keys($affected, true);
if (! Parallel::isEnabled()) {
// 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);
State::instance()->activate(
$projectRoot,
$graph,
$affectedSet,
$this->loadPreviousDefects($projectRoot),
$testSuite->tests->addTestCaseFilter(
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
);
$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($affected),
$affectedCount,
$cachedCount,
));
return $arguments;
}
// Parallel mode. Paratest's CLI only accepts a single positional
// `<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.
// Parallel: persist affected set so workers can install the filter.
if (! $this->persistAffectedSet($projectRoot, $affected)) {
$this->output->writeln(
' <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');
$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($affected),
$affectedCount,
$cachedCount,
));
return $arguments;
@ -537,7 +486,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
*/
private function persistAffectedSet(string $projectRoot, array $affected): bool
{
$path = $projectRoot.DIRECTORY_SEPARATOR.self::AFFECTED_PATH;
$path = self::affectedPath();
$dir = dirname($path);
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;
}
$recorder = Recorder::instance();
$recorder = $this->recorder;
if (! $recorder->driverAvailable()) {
$this->output->writeln(
' <fg=red>TIA</> No coverage driver is available. '.
'Install ext-pcov or enable Xdebug in coverage mode, then rerun with `--tia`.',
);
$this->output->writeln([
'',
' <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();
@ -624,7 +577,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
$token = (string) getmypid();
}
$path = $projectRoot.DIRECTORY_SEPARATOR.self::WORKER_CACHE_PREFIX.$token.'.json';
$path = self::workerPath($token);
$dir = dirname($path);
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
{
$pattern = $projectRoot.DIRECTORY_SEPARATOR.self::WORKER_CACHE_PREFIX.'*.json';
$pattern = self::workerGlob();
$matches = glob($pattern);
return $matches === false ? [] : $matches;
@ -686,10 +639,12 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
$out = [];
foreach ($data as $test => $sources) {
if (! is_string($test) || ! is_array($sources)) {
if (! is_string($test)) {
continue;
}
if (! is_array($sources)) {
continue;
}
$clean = [];
foreach ($sources as $source) {

View File

@ -28,9 +28,9 @@ final readonly class ChangedFiles
/**
* @return array<int, string>|null `null` when git is unavailable, or when
* the recorded SHA is no longer reachable
* from HEAD (rebase / force-push) — in
* that case the graph should be rebuilt.
* the recorded SHA is no longer reachable
* from HEAD (rebase / force-push) — in
* that case the graph should be rebuilt.
*/
public function since(?string $sha): ?array
{

View File

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

View File

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

View File

@ -5,7 +5,6 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use ReflectionClass;
use ReflectionException;
/**
* Captures per-test file coverage using the PCOV driver.
@ -18,8 +17,6 @@ use ReflectionException;
*/
final class Recorder
{
private static ?self $instance = null;
/**
* Test file currently being recorded, or `null` when idle.
*/
@ -47,11 +44,6 @@ final class Recorder
private string $driver = 'none';
public static function instance(): self
{
return self::$instance ??= new self;
}
public function activate(): void
{
$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;
use Composer\InstalledVersions;
use Pest\Browser\Support\BrowserTestIdentifier;
use Pest\Factories\TestCaseFactory;
use Pest\TestSuite;
@ -72,7 +73,7 @@ final readonly class Browser implements WatchDefault
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
// is installed to find tests using `visit()` outside the conventional
// Browser/ folder.
if (class_exists(\Pest\Browser\Support\BrowserTestIdentifier::class)) {
if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests;
foreach ($repo->getFilenames() as $filename) {
@ -83,7 +84,7 @@ final readonly class Browser implements WatchDefault
}
foreach ($factory->methods as $method) {
if (\Pest\Browser\Support\BrowserTestIdentifier::isBrowserTest($method)) {
if (BrowserTestIdentifier::isBrowserTest($method)) {
$rel = $this->fileRelative($projectRoot, $filename);
if ($rel !== null) {

View File

@ -22,7 +22,7 @@ interface WatchDefault
public function applicable(): bool;
/**
* @return array<string, array<int, string>> glob → list of project-relative test dirs
* @return array<string, array<int, string>> glob → list of project-relative test dirs
*/
public function defaults(string $projectRoot, string $testPath): array;
}

View File

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
use Pest\TestSuite;
/**
* Maps non-PHP file globs to the test directories they should invalidate.
@ -37,17 +38,10 @@ final class WatchPatterns
];
/**
* @var array<string, array<int, string>> glob → list of project-relative test dirs
* @var array<string, array<int, string>> glob → list of project-relative test dirs
*/
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
* those that apply. Called once during Tia plugin boot, after BootFiles
@ -56,7 +50,7 @@ final class WatchPatterns
*/
public function useDefaults(string $projectRoot): void
{
$testPath = \Pest\TestSuite::getInstance()->testPath;
$testPath = TestSuite::getInstance()->testPath;
foreach (self::DEFAULTS as $class) {
$default = new $class;
@ -94,7 +88,7 @@ final class WatchPatterns
*
* @param string $projectRoot Absolute path.
* @param array<int, string> $changedFiles Project-relative paths.
* @return array<int, string> Project-relative test directories.
* @return array<int, string> Project-relative test directories.
*/
public function matchedDirectories(string $projectRoot, array $changedFiles): array
{

View File

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

View File

@ -15,13 +15,13 @@ use PHPUnit\Event\Test\PreparedSubscriber;
*
* @internal
*/
final class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
final readonly class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
{
public function __construct(private Recorder $recorder) {}
public function notify(Prepared $event): void
{
$recorder = Recorder::instance();
if (! $recorder->isActive()) {
if (! $this->recorder->isActive()) {
return;
}
@ -31,6 +31,6 @@ final class EnsureTiaCoverageIsRecorded implements PreparedSubscriber
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);
namespace Pest\Plugins\Tia;
namespace Pest\TestCaseFilters;
use Pest\Contracts\TestCaseFilter;
use Pest\Plugins\Tia\Graph;