feat(tia): continues to work on poc

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

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

@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Contracts\TestCaseFilter;
use Pest\Plugins\Tia\Graph;
/**
* Accepts a test file in one of three cases:
*
* 1. The file falls outside the project root (we cannot reason about it, so
* stay safe and run it).
* 2. The graph has no record of the file — this is a new test that was
* never part of a recording run, so we accept it by default. Skipping
* unknown tests would be a correctness hazard (developers add tests and
* TIA would silently not run them).
* 3. The graph knows the file AND it is in the affected set.
*
* @internal
*/
final readonly class TiaTestCaseFilter implements TestCaseFilter
{
/**
* @param array<string, true> $affectedTestFiles Keys are project-relative test file paths.
*/
public function __construct(
private string $projectRoot,
private Graph $graph,
private array $affectedTestFiles,
) {}
public function accept(string $testCaseFilename): bool
{
$rel = $this->relative($testCaseFilename);
if ($rel === null) {
return true;
}
if (! $this->graph->knowsTest($rel)) {
return true;
}
return isset($this->affectedTestFiles[$rel]);
}
private function relative(string $path): ?string
{
$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
{