Compare commits

...

8 Commits

26 changed files with 1589 additions and 133 deletions

View File

@ -27,6 +27,7 @@ final readonly class BootSubscribers implements Bootstrapper
Subscribers\EnsureTeamCityEnabled::class, Subscribers\EnsureTeamCityEnabled::class,
Subscribers\EnsureTiaCoverageIsRecorded::class, Subscribers\EnsureTiaCoverageIsRecorded::class,
Subscribers\EnsureTiaCoverageIsFlushed::class, Subscribers\EnsureTiaCoverageIsFlushed::class,
Subscribers\EnsureTiaResultsAreCollected::class,
]; ];
/** /**

View File

@ -5,8 +5,11 @@ declare(strict_types=1);
namespace Pest\Concerns; namespace Pest\Concerns;
use Closure; use Closure;
use Pest\Contracts\Plugins\BeforeEachable;
use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic; use Pest\Panic;
use Pest\Plugin\Loader;
use Pest\Plugins\Tia\CachedTestResult;
use Pest\Preset; use Pest\Preset;
use Pest\Support\ChainableClosure; use Pest\Support\ChainableClosure;
use Pest\Support\ExceptionTrace; use Pest\Support\ExceptionTrace;
@ -75,6 +78,12 @@ trait Testable
*/ */
public bool $__ran = false; public bool $__ran = false;
/**
* Set when a `BeforeEachable` plugin returns a cached success result.
* Checked in `__runTest` and `tearDown` to skip body + cleanup.
*/
private bool $__cachedPass = false;
/** /**
* The test's test closure. * The test's test closure.
*/ */
@ -227,6 +236,31 @@ trait Testable
{ {
TestSuite::getInstance()->test = $this; TestSuite::getInstance()->test = $this;
$this->__cachedPass = false;
/** @var BeforeEachable $plugin */
foreach (Loader::getPlugins(BeforeEachable::class) as $plugin) {
$cached = $plugin->beforeEach(self::$__filename, $this::class.'::'.$this->name());
if ($cached instanceof CachedTestResult) {
if ($cached->isSuccess()) {
$this->__cachedPass = true;
return;
}
// Non-success: throw appropriate exception. PHPUnit catches
// it in runBare() and marks the test with the correct status.
// This makes skips, failures, incompletes, todos appear in
// output exactly as if the test ran.
match ($cached->status) {
1 => $this->markTestSkipped($cached->message), // skip / todo
2 => $this->markTestIncomplete($cached->message), // incomplete
default => throw new \PHPUnit\Framework\AssertionFailedError($cached->message ?: 'Cached failure'),
};
}
}
$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;
@ -302,6 +336,12 @@ trait Testable
*/ */
protected function tearDown(...$arguments): void protected function tearDown(...$arguments): void
{ {
if ($this->__cachedPass) {
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) {
@ -327,6 +367,12 @@ trait Testable
*/ */
private function __runTest(Closure $closure, ...$args): mixed private function __runTest(Closure $closure, ...$args): mixed
{ {
if ($this->__cachedPass) {
$this->addToAssertionCount(1);
return null;
}
$arguments = $this->__resolveTestArguments($args); $arguments = $this->__resolveTestArguments($args);
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments); $this->__ensureDatasetArgumentNameAndNumberMatches($arguments);

View File

@ -119,6 +119,14 @@ final readonly class Configuration
return new Browser\Configuration; return new Browser\Configuration;
} }
/**
* Gets the TIA (Test Impact Analysis) configuration.
*/
public function tia(): Plugins\Tia\Configuration
{
return new Plugins\Tia\Configuration;
}
/** /**
* Proxies calls to the uses method. * Proxies calls to the uses method.
* *

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Pest\Contracts\Plugins;
use Pest\Plugins\Tia\CachedTestResult;
/**
* Plugins implementing this interface are consulted before each test's
* `setUp()`. The return value controls what happens:
*
* - `null` → test proceeds normally.
* - `CachedTestResult` → test replays the cached status. For non-success
* statuses the appropriate exception is thrown
* from `setUp` (PHPUnit handles it natively). For
* success, a synthetic assertion is registered and
* the body + tearDown are skipped via a flag.
*
* @internal
*/
interface BeforeEachable
{
public function beforeEach(string $filename, string $testId): ?CachedTestResult;
}

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,10 @@ 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)
->add(Tia\ResultCollector::class, new Tia\ResultCollector);
$kernel = new self( $kernel = new self(
new Application, new Application,

View File

@ -5,16 +5,18 @@ declare(strict_types=1);
namespace Pest\Plugins; namespace Pest\Plugins;
use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\AddsOutput;
use Pest\Contracts\Plugins\BeforeEachable;
use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\HandlesArguments;
use Pest\Contracts\Plugins\Terminable; use Pest\Contracts\Plugins\Terminable;
use Pest\Exceptions\NoDirtyTestsFound; use Pest\Plugins\Tia\CachedTestResult;
use Pest\Panic; use Pest\Plugins\Tia\ChangedFiles;
use Pest\Support\Container; use Pest\Plugins\Tia\Fingerprint;
use Pest\Support\Tia\ChangedFiles; use Pest\Plugins\Tia\Graph;
use Pest\Support\Tia\Fingerprint; use Pest\Plugins\Tia\Recorder;
use Pest\Support\Tia\Graph; use Pest\Plugins\Tia\ResultCollector;
use Pest\Support\Tia\Recorder;
use Pest\TestCaseFilters\TiaTestCaseFilter; use Pest\TestCaseFilters\TiaTestCaseFilter;
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;
@ -26,7 +28,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
@ -49,7 +51,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
@ -62,7 +64,7 @@ use Throwable;
* *
* @internal * @internal
*/ */
final class Tia implements AddsOutput, HandlesArguments, Terminable final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Terminable
{ {
use Concerns\HandleArguments; use Concerns\HandleArguments;
@ -70,11 +72,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
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.
@ -83,13 +95,100 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/** /**
* 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 bool $replayRan = false;
/**
* Holds the graph during replay so `beforeEach` can look up cached
* results without re-loading from disk on every test.
*/
private ?Graph $replayGraph = null;
/**
* Current git branch (or `HEAD` SHA when detached). Resolved once per
* run so all graph accesses use the same branch key.
*/
private string $branch = 'main';
/**
* Test files that are affected (should re-execute). Keyed by
* project-relative path. Set during `enterReplayMode`.
*
* @var array<string, true>
*/
private array $affectedFiles = [];
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,
) {}
public function beforeEach(string $filename, string $testId): ?CachedTestResult
{
if ($this->replayGraph === null) {
return null;
}
// Resolve file to project-relative path.
$projectRoot = TestSuite::getInstance()->rootPath;
$real = @realpath($filename);
$rel = $real !== false
? str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen(rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR)))
: null;
// Affected files must re-execute.
if ($rel !== null && isset($this->affectedFiles[$rel])) {
return null;
}
// Unknown files (not in graph) must execute — they're new.
if ($rel === null || ! $this->replayGraph->knowsTest($rel)) {
return null;
}
// Known + unaffected: return cached result if we have one for this
// branch (falls back to main if branch is fresh).
return $this->replayGraph->getResult($this->branch, $testId);
}
/** /**
* {@inheritDoc} * {@inheritDoc}
@ -136,7 +235,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return; return;
} }
$recorder = Recorder::instance(); $recorder = $this->recorder;
if (! $recorder->isActive()) { if (! $recorder->isActive()) {
return; return;
@ -161,11 +260,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
} }
// 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));
$graph->setRecordedAtSha((new ChangedFiles($projectRoot))->currentSha()); $graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha());
$graph->replaceEdges($perTest); $graph->replaceEdges($perTest);
$graph->pruneMissingTests(); $graph->pruneMissingTests();
@ -179,7 +278,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$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();
@ -196,6 +295,20 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $exitCode; return $exitCode;
} }
// After a successful replay run, advance the recorded SHA to HEAD
// so the next run only diffs against what changed since NOW, not
// since the original recording. Without this, re-running `--tia`
// twice in a row would re-execute the same affected tests both
// times even though nothing new changed.
if ($this->replayRan) {
$this->bumpRecordedSha();
}
// Snapshot per-test results (status + message) from PHPUnit's result
// cache into our graph so future replay runs can faithfully reproduce
// pass/fail/skip/todo/incomplete for unaffected tests.
$this->snapshotTestResults();
if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') { if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') {
return $exitCode; return $exitCode;
} }
@ -207,11 +320,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
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));
$graph->setRecordedAtSha((new ChangedFiles($projectRoot))->currentSha()); $graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha());
$merged = []; $merged = [];
@ -254,7 +367,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
' <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;
@ -266,7 +379,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array
{ {
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; // Initialise watch patterns (defaults + any user additions from
// tests/Pest.php which has already been loaded by BootFiles at
// this point).
$this->watchPatterns->useDefaults($projectRoot);
// Resolve current branch once per run so every baseline lookup uses
// the same key. Detached HEAD (or no git) falls back to `main` as
// the implicit branch identity.
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
$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);
@ -280,10 +403,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
if ($graph instanceof Graph) { if ($graph instanceof Graph) {
$changedFiles = new ChangedFiles($projectRoot); $changedFiles = new ChangedFiles($projectRoot);
$branchSha = $graph->recordedAtSha($this->branch);
if ($changedFiles->gitAvailable() if ($changedFiles->gitAvailable()
&& $graph->recordedAtSha() !== null && $branchSha !== null
&& $changedFiles->since($graph->recordedAtSha()) === null) { && $changedFiles->since($branchSha) === null) {
$this->output->writeln( $this->output->writeln(
' <fg=yellow>TIA</> recorded commit is no longer reachable — graph will be rebuilt.', ' <fg=yellow>TIA</> recorded commit is no longer reachable — graph will be rebuilt.',
); );
@ -304,6 +428,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array
{ {
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
if ($replayingGlobal) { if ($replayingGlobal) {
// Replay in a worker: load the graph and the affected set that // Replay in a worker: load the graph and the affected set that
// the parent persisted, then install the per-file filter so // the parent persisted, then install the per-file filter so
@ -318,7 +444,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
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
@ -334,8 +460,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
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);
@ -384,46 +510,40 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $arguments; return $arguments;
} }
$changed = $changedFiles->since($graph->recordedAtSha()) ?? []; $changed = $changedFiles->since($graph->recordedAtSha($this->branch)) ?? [];
if ($changed === []) { // Drop files whose content hash matches the last-run snapshot. This
$this->output->writeln(' <fg=green>TIA</> no changes detected.'); // is the "dirty but identical" filter: if a file is uncommitted but
// its content hasn't moved since the last `--tia` invocation, its
// dependents already re-ran last time and don't need re-running
// again.
$changed = $changedFiles->filterUnchangedSinceLastRun($changed, $graph->lastRunTree($this->branch));
Panic::with(new NoDirtyTestsFound); $affected = $changed === [] ? [] : $graph->affected($changed);
}
$affected = $graph->affected($changed); $totalKnown = count($graph->allTestFiles());
$affectedCount = count($affected);
$cachedCount = $totalKnown - $affectedCount;
$testSuite = TestSuite::getInstance(); $testSuite = TestSuite::getInstance();
$affectedSet = array_fill_keys($affected, true);
$this->replayRan = true;
$this->replayGraph = $graph;
$this->affectedFiles = $affectedSet;
if (! Parallel::isEnabled()) { 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.
$affectedSet = array_fill_keys($affected, true);
$testSuite->tests->addTestCaseFilter(
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
);
$this->output->writeln(sprintf( $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, %d replayed.',
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.',
@ -435,9 +555,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
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 known test file(s) + any new/unknown tests (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;
@ -448,7 +569,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
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)) {
@ -499,15 +620,19 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
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();
@ -535,7 +660,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$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)) {
@ -564,7 +689,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
*/ */
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;
@ -597,10 +722,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$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) {
@ -615,6 +742,80 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return $out; return $out;
} }
/**
* After a successful replay, bump the graph's `recorded_at_sha` to the
* current HEAD. This way the next `--tia` run diffs only against what
* changed since THIS run, not since the original recording.
*
* The graph edges themselves are untouched — only the SHA marker moves.
*/
/**
* After a successful replay, advance the baseline: bump `recorded_at_sha`
* to the current HEAD (handles committed changes) and snapshot the
* working tree's content hashes (handles uncommitted changes). Next run
* compares against this baseline so identical files are skipped even if
* git still reports them as modified.
*/
private function bumpRecordedSha(): void
{
$projectRoot = TestSuite::getInstance()->rootPath;
$cachePath = self::cachePath();
$graph = Graph::load($projectRoot, $cachePath);
if (! $graph instanceof Graph) {
return;
}
$changedFiles = new ChangedFiles($projectRoot);
$currentSha = $changedFiles->currentSha();
if ($currentSha !== null) {
$graph->setRecordedAtSha($this->branch, $currentSha);
}
// Snapshot the working tree: hash every currently-modified file.
// On next run, files still appearing as modified but whose hash
// matches this snapshot are treated as unchanged.
$workingTreeFiles = $changedFiles->since($currentSha) ?? [];
$graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles));
$graph->save($cachePath);
}
/**
* Merges per-test status + message from the `ResultCollector` into the
* TIA graph. Runs after every `--tia` invocation so the graph always has
* fresh results for faithful replay (pass, fail, skip, todo, etc.).
*/
private function snapshotTestResults(): void
{
/** @var ResultCollector $collector */
$collector = Container::getInstance()->get(ResultCollector::class);
$results = $collector->all();
if ($results === []) {
return;
}
$cachePath = self::cachePath();
$projectRoot = TestSuite::getInstance()->rootPath;
$graph = Graph::load($projectRoot, $cachePath);
if (! $graph instanceof Graph) {
return;
}
foreach ($results as $testId => $result) {
$graph->setResult($this->branch, $testId, $result['status'], $result['message'], $result['time']);
}
$graph->save($cachePath);
$collector->reset();
}
private function coverageReportActive(): bool private function coverageReportActive(): bool
{ {
try { try {
@ -624,6 +825,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
return false; return false;
} }
return property_exists($coverage, 'coverage') && $coverage->coverage === true; return $coverage->coverage === true;
} }
} }

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Immutable snapshot of a previous test run's outcome. Stored in the TIA
* graph and returned by `BeforeEachable::beforeEach` so `Testable` can
* faithfully replay the exact status — pass, fail, skip, todo, incomplete,
* risky, etc. — without executing the test body.
*
* @internal
*/
final readonly class CachedTestResult
{
/**
* PHPUnit TestStatus int constants:
* 0 = success, 1 = skipped, 2 = incomplete,
* 3 = notice, 4 = deprecation, 5 = risky,
* 6 = warning, 7 = failure, 8 = error.
*/
public function __construct(
public int $status,
public string $message = '',
public float $time = 0.0,
) {}
public function isSuccess(): bool
{
return $this->status === 0;
}
}

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Pest\Support\Tia; namespace Pest\Plugins\Tia;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
@ -28,9 +28,87 @@ final readonly class ChangedFiles
/** /**
* @return array<int, string>|null `null` when git is unavailable, or when * @return array<int, string>|null `null` when git is unavailable, or when
* the recorded SHA is no longer reachable * the recorded SHA is no longer reachable
* from HEAD (rebase / force-push) in * from HEAD (rebase / force-push) in
* that case the graph should be rebuilt. * that case the graph should be rebuilt.
*/
/**
* Removes files whose current content hash matches the snapshot from the
* last `--tia` run. Used to ignore "dirty but unchanged" files a file
* that git still reports as modified but whose content is bit-identical
* to the previous TIA invocation.
*
* @param array<int, string> $files project-relative paths.
* @param array<string, string> $lastRunTree path content hash from last run.
* @return array<int, string>
*/
public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array
{
if ($lastRunTree === []) {
return $files;
}
$remaining = [];
foreach ($files as $file) {
if (! isset($lastRunTree[$file])) {
$remaining[] = $file;
continue;
}
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) {
// File deleted since last run — definitely changed.
$remaining[] = $file;
continue;
}
$hash = @hash_file('xxh128', $absolute);
if ($hash === false || $hash !== $lastRunTree[$file]) {
$remaining[] = $file;
}
}
return $remaining;
}
/**
* Computes content hashes for the given project-relative files. Used to
* snapshot the working tree after a successful run so the next run can
* detect which files are actually different.
*
* @param array<int, string> $files
* @return array<string, string> path xxh128 content hash
*/
public function snapshotTree(array $files): array
{
$out = [];
foreach ($files as $file) {
$absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file;
if (! is_file($absolute)) {
continue;
}
$hash = @hash_file('xxh128', $absolute);
if ($hash !== false) {
$out[$file] = $hash;
}
}
return $out;
}
/**
* @return array<int, string>|null `null` when git is unavailable, or when
* the recorded SHA is no longer reachable
* from HEAD (rebase / force-push).
*/ */
public function since(?string $sha): ?array public function since(?string $sha): ?array
{ {
@ -87,6 +165,24 @@ final readonly class ChangedFiles
return false; return false;
} }
public function currentBranch(): ?string
{
if (! $this->gitAvailable()) {
return null;
}
$process = new Process(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], $this->projectRoot);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
$branch = trim($process->getOutput());
return $branch === '' || $branch === 'HEAD' ? null : $branch;
}
public function gitAvailable(): bool public function gitAvailable(): bool
{ {
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot); $process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot);

View File

@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Pest\Support\Container;
/**
* User-facing TIA configuration, returned by `pest()->tia()`.
*
* Usage in `tests/Pest.php`:
*
* pest()->tia()->watch([
* 'resources/js/**\/*.tsx' => 'tests/Browser',
* 'public/build/**\/*' => 'tests/Browser',
* ]);
*
* Patterns are merged with the built-in defaults (config, routes, views,
* frontend assets, migrations). Duplicate glob keys overwrite the default
* mapping so users can redirect a pattern to a narrower directory.
*
* @internal
*/
final class Configuration
{
/**
* Adds watch-pattern → test-directory mappings that supplement (or
* override) the built-in defaults.
*
* @param array<string, string> $patterns glob → project-relative test dir
* @return $this
*/
public function watch(array $patterns): self
{
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$watchPatterns->add($patterns);
return $this;
}
}

View File

@ -2,7 +2,7 @@
declare(strict_types=1); declare(strict_types=1);
namespace Pest\Support\Tia; namespace Pest\Plugins\Tia;
/** /**
* Captures environmental inputs that, when changed, make the TIA graph stale. * Captures environmental inputs that, when changed, make the TIA graph stale.
@ -19,7 +19,7 @@ final readonly class Fingerprint
private const int SCHEMA_VERSION = 2; private const int SCHEMA_VERSION = 2;
/** /**
* @param non-empty-string $projectRoot * @return array<string, int|string|null>
*/ */
public static function compute(string $projectRoot): array public static function compute(string $projectRoot): array
{ {

View File

@ -2,7 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace Pest\Support\Tia; namespace Pest\Plugins\Tia;
use Pest\Support\Container;
/** /**
* File-level Test Impact Analysis graph. * File-level Test Impact Analysis graph.
@ -45,9 +47,22 @@ final class Graph
private array $fingerprint = []; private array $fingerprint = [];
/** /**
* Commit SHA the graph was recorded against (if in a git repo). * Per-branch baselines. Each branch independently tracks:
* - `sha` last HEAD at which `--tia` ran on this branch
* - `tree` content hashes of modified files at that point
* - `results` per-test status + message + time
*
* Graph edges (test source) stay shared across branches because
* structure doesn't change per branch. Only run-state is per-branch so
* a failing test on one branch doesn't poison another branch's replay.
*
* @var array<string, array{
* sha: ?string,
* tree: array<string, string>,
* results: array<string, array{status: int, message: string, time: float}>
* }>
*/ */
private ?string $recordedAtSha = null; private array $baselines = [];
/** /**
* Canonicalised project root. Resolved through `realpath()` so paths * Canonicalised project root. Resolved through `realpath()` so paths
@ -88,38 +103,104 @@ final class Graph
/** /**
* Returns the set of test files whose dependencies intersect $changedFiles. * Returns the set of test files whose dependencies intersect $changedFiles.
* *
* Two resolution paths:
* 1. **Coverage edges** test depends on a PHP source file that changed.
* 2. **Watch patterns** a non-PHP file (JS, CSS, config, ) matches a
* glob that maps to a test directory; every test under that directory
* is affected.
*
* @param array<int, string> $changedFiles Absolute or relative paths. * @param array<int, string> $changedFiles Absolute or relative paths.
* @return array<int, string> Relative test file paths. * @return array<int, string> Relative test file paths.
*/ */
public function affected(array $changedFiles): array public function affected(array $changedFiles): array
{ {
$changedIds = []; // Normalise all changed paths once.
$normalised = [];
foreach ($changedFiles as $file) { foreach ($changedFiles as $file) {
$rel = $this->relative($file); $rel = $this->relative($file);
if ($rel === null) { if ($rel !== null) {
continue; $normalised[] = $rel;
}
if (isset($this->fileIds[$rel])) {
$changedIds[$this->fileIds[$rel]] = true;
} }
} }
$affected = []; // 1. Coverage-edge lookup (PHP → PHP).
$changedIds = [];
$unknownSourceDirs = [];
foreach ($normalised as $rel) {
if (isset($this->fileIds[$rel])) {
$changedIds[$this->fileIds[$rel]] = true;
} elseif (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) {
// Source PHP file unknown to the graph — might be a new file
// that only exists on this branch (graph inherited from main).
// Track its directory for the sibling heuristic (step 3).
$unknownSourceDirs[dirname($rel)] = true;
}
}
$affectedSet = [];
foreach ($this->edges as $testFile => $ids) { foreach ($this->edges as $testFile => $ids) {
foreach ($ids as $id) { foreach ($ids as $id) {
if (isset($changedIds[$id])) { if (isset($changedIds[$id])) {
$affected[] = $testFile; $affectedSet[$testFile] = true;
break; break;
} }
} }
} }
return $affected; // 2. Watch-pattern lookup (non-PHP assets → test directories).
/** @var WatchPatterns $watchPatterns */
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
$dirs = $watchPatterns->matchedDirectories($this->projectRoot, $normalised);
$allTestFiles = array_keys($this->edges);
foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) {
$affectedSet[$testFile] = true;
}
// 3. Sibling heuristic for unknown source files.
//
// When a PHP source file is unknown to the graph (no test depends on
// it), it is either genuinely untested OR it was added on a branch
// whose graph was inherited from another branch (e.g. main). In the
// latter case the graph simply never saw the file.
//
// To avoid silent misses: find tests that already cover ANY file in
// the same directory. If `app/Models/OrderItem.php` is unknown but
// `app/Models/Order.php` is covered by `OrderTest`, run `OrderTest`
// — it likely exercises sibling files in the same module.
//
// This over-runs slightly (sibling may be unrelated) but never
// under-runs. And once the test executes, its coverage captures the
// new file → graph self-heals for next run.
if ($unknownSourceDirs !== []) {
foreach ($this->edges as $testFile => $ids) {
if (isset($affectedSet[$testFile])) {
continue;
}
foreach ($ids as $id) {
if (! isset($this->files[$id])) {
continue;
}
$depDir = dirname($this->files[$id]);
if (isset($unknownSourceDirs[$depDir])) {
$affectedSet[$testFile] = true;
break;
}
}
}
}
return array_keys($affectedSet);
} }
/** /**
@ -132,24 +213,108 @@ final class Graph
return $rel !== null && isset($this->edges[$rel]); 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);
}
/**
* @param array<string, int|string|null> $fingerprint
*/
public function setFingerprint(array $fingerprint): void public function setFingerprint(array $fingerprint): void
{ {
$this->fingerprint = $fingerprint; $this->fingerprint = $fingerprint;
} }
/**
* @return array<string, int|string|null>
*/
public function fingerprint(): array public function fingerprint(): array
{ {
return $this->fingerprint; return $this->fingerprint;
} }
public function setRecordedAtSha(?string $sha): void /**
* Returns the SHA the given branch last ran against, or falls back to
* `$fallbackBranch` (typically `main`) when this branch has no baseline
* yet. That way a freshly-created feature branch inherits main's
* baseline on its first run.
*/
public function recordedAtSha(string $branch, string $fallbackBranch = 'main'): ?string
{ {
$this->recordedAtSha = $sha; $baseline = $this->baselineFor($branch, $fallbackBranch);
return $baseline['sha'];
} }
public function recordedAtSha(): ?string public function setRecordedAtSha(string $branch, ?string $sha): void
{ {
return $this->recordedAtSha; $this->ensureBaseline($branch);
$this->baselines[$branch]['sha'] = $sha;
}
public function setResult(string $branch, string $testId, int $status, string $message, float $time): void
{
$this->ensureBaseline($branch);
$this->baselines[$branch]['results'][$testId] = [
'status' => $status, 'message' => $message, 'time' => $time,
];
}
public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?CachedTestResult
{
$baseline = $this->baselineFor($branch, $fallbackBranch);
if (! isset($baseline['results'][$testId])) {
return null;
}
$r = $baseline['results'][$testId];
return new CachedTestResult($r['status'], $r['message'], $r['time']);
}
/**
* @param array<string, string> $tree project-relative path content hash
*/
public function setLastRunTree(string $branch, array $tree): void
{
$this->ensureBaseline($branch);
$this->baselines[$branch]['tree'] = $tree;
}
/**
* @return array<string, string>
*/
public function lastRunTree(string $branch, string $fallbackBranch = 'main'): array
{
return $this->baselineFor($branch, $fallbackBranch)['tree'];
}
/**
* @return array{sha: ?string, tree: array<string, string>, results: array<string, array{status: int, message: string, time: float}>}
*/
private function baselineFor(string $branch, string $fallbackBranch): array
{
if (isset($this->baselines[$branch])) {
return $this->baselines[$branch];
}
if ($branch !== $fallbackBranch && isset($this->baselines[$fallbackBranch])) {
return $this->baselines[$fallbackBranch];
}
return ['sha' => null, 'tree' => [], 'results' => []];
}
private function ensureBaseline(string $branch): void
{
if (! isset($this->baselines[$branch])) {
$this->baselines[$branch] = ['sha' => null, 'tree' => [], 'results' => []];
}
} }
/** /**
@ -214,10 +379,10 @@ final class Graph
$graph = new self($projectRoot); $graph = new self($projectRoot);
$graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : []; $graph->fingerprint = is_array($data['fingerprint'] ?? null) ? $data['fingerprint'] : [];
$graph->recordedAtSha = is_string($data['recorded_at_sha'] ?? null) ? $data['recorded_at_sha'] : null;
$graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : []; $graph->files = is_array($data['files'] ?? null) ? array_values($data['files']) : [];
$graph->fileIds = array_flip($graph->files); $graph->fileIds = array_flip($graph->files);
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : []; $graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
return $graph; return $graph;
} }
@ -233,9 +398,9 @@ final class Graph
$payload = [ $payload = [
'schema' => 1, 'schema' => 1,
'fingerprint' => $this->fingerprint, 'fingerprint' => $this->fingerprint,
'recorded_at_sha' => $this->recordedAtSha,
'files' => $this->files, 'files' => $this->files,
'edges' => $this->edges, 'edges' => $this->edges,
'baselines' => $this->baselines,
]; ];
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';

View File

@ -2,10 +2,9 @@
declare(strict_types=1); declare(strict_types=1);
namespace Pest\Support\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;
@ -69,12 +61,20 @@ final class Recorder
$this->driver = 'pcov'; $this->driver = 'pcov';
$this->driverAvailable = true; $this->driverAvailable = true;
} elseif (function_exists('xdebug_start_code_coverage')) { } elseif (function_exists('xdebug_start_code_coverage')) {
// Probe: Xdebug silently emits a warning and refuses to start // Xdebug is loaded. Probe whether coverage mode is active by
// when not in coverage mode. Suppress + check for mode errors. // attempting a start — it emits E_WARNING when the mode is off.
$ok = @\xdebug_start_code_coverage(); // We capture the warning via a temporary error handler.
$probeOk = true;
set_error_handler(static function () use (&$probeOk): bool {
$probeOk = false;
if ($ok === null || $ok) { return true;
@\xdebug_stop_code_coverage(false); });
\xdebug_start_code_coverage();
restore_error_handler();
if ($probeOk) {
\xdebug_stop_code_coverage(false);
$this->driver = 'xdebug'; $this->driver = 'xdebug';
$this->driverAvailable = true; $this->driverAvailable = true;
} }
@ -195,31 +195,23 @@ final class Recorder
return null; return null;
} }
try { $reflection = new ReflectionClass($className);
$reflection = new ReflectionClass($className);
} catch (ReflectionException) {
return null;
}
if ($reflection->hasProperty('__filename')) { if ($reflection->hasProperty('__filename')) {
try { $property = $reflection->getProperty('__filename');
$property = $reflection->getProperty('__filename');
if ($property->isStatic()) { if ($property->isStatic()) {
$value = $property->getValue(); $value = $property->getValue();
if (is_string($value) && $value !== '') { if (is_string($value)) {
return $value; return $value;
}
} }
} catch (ReflectionException) {
// fall through to getFileName()
} }
} }
$file = $reflection->getFileName(); $file = $reflection->getFileName();
return $file !== false && $file !== '' ? $file : null; return is_string($file) ? $file : null;
} }
/** /**

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Collects per-test status + message during the run so the graph can persist
* them for faithful replay. PHPUnit's own result cache discards messages
* during serialisation — this collector retains them.
*
* @internal
*/
final class ResultCollector
{
/**
* @var array<string, array{status: int, message: string, time: float}>
*/
private array $results = [];
private ?string $currentTestId = null;
private ?float $startTime = null;
public function testPrepared(string $testId): void
{
$this->currentTestId = $testId;
$this->startTime = microtime(true);
}
public function testPassed(): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(0, '');
}
public function testFailed(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(7, $message);
}
public function testErrored(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(8, $message);
}
public function testSkipped(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(1, $message);
}
public function testIncomplete(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(2, $message);
}
public function testRisky(string $message): void
{
if ($this->currentTestId === null) {
return;
}
$this->record(5, $message);
}
/**
* @return array<string, array{status: int, message: string, time: float}>
*/
public function all(): array
{
return $this->results;
}
public function reset(): void
{
$this->results = [];
$this->currentTestId = null;
$this->startTime = null;
}
private function record(int $status, string $message): void
{
if ($this->currentTestId === null) {
return;
}
$time = $this->startTime !== null
? round(microtime(true) - $this->startTime, 3)
: 0.0;
$this->results[$this->currentTestId] = [
'status' => $status,
'message' => $message,
'time' => $time,
];
$this->currentTestId = null;
$this->startTime = null;
}
}

View File

@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
use Pest\Browser\Support\BrowserTestIdentifier;
use Pest\Factories\TestCaseFactory;
use Pest\TestSuite;
/**
* Watch patterns for frontend assets that affect browser tests.
*
* Uses `BrowserTestIdentifier` from pest-plugin-browser (if installed) to
* auto-discover directories containing browser tests. Falls back to the
* `tests/Browser` convention when the plugin is absent.
*
* @internal
*/
final readonly class Browser implements WatchDefault
{
public function applicable(): bool
{
// Browser tests can exist in any PHP project. We only activate when
// there is an actual `tests/Browser` directory OR pest-plugin-browser
// is installed.
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('pestphp/pest-plugin-browser');
}
public function defaults(string $projectRoot, string $testPath): array
{
$browserDirs = $this->detectBrowserTestDirs($projectRoot, $testPath);
$globs = [
'resources/js/**/*.js',
'resources/js/**/*.ts',
'resources/js/**/*.tsx',
'resources/js/**/*.jsx',
'resources/js/**/*.vue',
'resources/js/**/*.svelte',
'resources/css/**/*.css',
'resources/css/**/*.scss',
'resources/css/**/*.less',
// Vite / Webpack build output that browser tests may consume.
'public/build/**/*.js',
'public/build/**/*.css',
];
$patterns = [];
foreach ($globs as $glob) {
$patterns[$glob] = $browserDirs;
}
return $patterns;
}
/**
* @return array<int, string>
*/
private function detectBrowserTestDirs(string $projectRoot, string $testPath): array
{
$dirs = [];
$candidate = $testPath.'/Browser';
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
$dirs[] = $candidate;
}
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
// is installed to find tests using `visit()` outside the conventional
// Browser/ folder.
if (class_exists(BrowserTestIdentifier::class)) {
$repo = TestSuite::getInstance()->tests;
foreach ($repo->getFilenames() as $filename) {
$factory = $repo->get($filename);
if (! $factory instanceof TestCaseFactory) {
continue;
}
foreach ($factory->methods as $method) {
if (BrowserTestIdentifier::isBrowserTest($method)) {
$rel = $this->fileRelative($projectRoot, $filename);
if ($rel !== null) {
$dirs[] = dirname($rel);
}
break;
}
}
}
}
return array_values(array_unique($dirs === [] ? [$testPath] : $dirs));
}
private function fileRelative(string $projectRoot, string $path): ?string
{
$real = @realpath($path);
if ($real === false) {
$real = $path;
}
$root = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
if (! str_starts_with($real, $root)) {
return null;
}
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
/**
* Watch patterns for Inertia.js projects (Laravel or otherwise).
*
* Inertia bridges PHP controllers with JS/TS page components. A change to
* a React / Vue / Svelte page can break assertions in browser tests or
* Inertia-specific feature tests.
*
* @internal
*/
final readonly class Inertia implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& (InstalledVersions::isInstalled('inertiajs/inertia-laravel')
|| InstalledVersions::isInstalled('rompetomp/inertia-bundle'));
}
public function defaults(string $projectRoot, string $testPath): array
{
$browserDir = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Browser')
? $testPath.'/Browser'
: $testPath;
return [
// Inertia page components (React / Vue / Svelte).
'resources/js/Pages/**/*.vue' => [$testPath, $browserDir],
'resources/js/Pages/**/*.tsx' => [$testPath, $browserDir],
'resources/js/Pages/**/*.jsx' => [$testPath, $browserDir],
'resources/js/Pages/**/*.svelte' => [$testPath, $browserDir],
// Shared layouts / components consumed by pages.
'resources/js/Layouts/**/*.vue' => [$browserDir],
'resources/js/Layouts/**/*.tsx' => [$browserDir],
'resources/js/Components/**/*.vue' => [$browserDir],
'resources/js/Components/**/*.tsx' => [$browserDir],
// SSR entry point.
'resources/js/ssr.js' => [$browserDir],
'resources/js/ssr.ts' => [$browserDir],
'resources/js/app.js' => [$browserDir],
'resources/js/app.ts' => [$browserDir],
];
}
}

View File

@ -0,0 +1,81 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
/**
* Watch patterns for Laravel projects.
*
* Laravel boots the entire application inside `setUp()` (before PHPUnit's
* `Prepared` event where TIA's coverage window opens). That means PHP files
* loaded during boot — config, routes, service providers, migrations — are
* invisible to the coverage driver. Watch patterns are the only way to
* track them.
*
* @internal
*/
final readonly class Laravel implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('laravel/framework');
}
public function defaults(string $projectRoot, string $testPath): array
{
$featurePath = is_dir($projectRoot.DIRECTORY_SEPARATOR.$testPath.'/Feature')
? $testPath.'/Feature'
: $testPath;
return [
// Config — loaded during app boot (setUp), invisible to coverage.
// Affects both Feature and Unit: Pest.php commonly binds fakes
// and seeds DB based on config values.
'config/*.php' => [$testPath],
'config/**/*.php' => [$testPath],
// Routes — loaded during boot. HTTP/Feature tests depend on them.
'routes/*.php' => [$featurePath],
'routes/**/*.php' => [$featurePath],
// Service providers / bootstrap — loaded during boot, affect
// bindings, middleware, event listeners, scheduled tasks.
'bootstrap/app.php' => [$testPath],
'bootstrap/providers.php' => [$testPath],
// Migrations — run via RefreshDatabase/FastRefreshDatabase in
// setUp. Schema changes can break any test that touches DB.
'database/migrations/**/*.php' => [$testPath],
// Seeders — often run globally via Pest.php beforeEach.
'database/seeders/**/*.php' => [$testPath],
// Factories — loaded lazily but still PHP that coverage may miss
// if the factory file was already autoloaded before Prepared.
'database/factories/**/*.php' => [$testPath],
// Blade templates — compiled to cache, source file not executed.
'resources/views/**/*.blade.php' => [$featurePath],
// Translations — JSON translations read via file_get_contents,
// PHP translations loaded via include (but during boot).
'lang/**/*.php' => [$featurePath],
'lang/**/*.json' => [$featurePath],
'resources/lang/**/*.php' => [$featurePath],
'resources/lang/**/*.json' => [$featurePath],
// Build tool config — affects compiled assets consumed by
// browser and Inertia tests.
'vite.config.js' => [$featurePath],
'vite.config.ts' => [$featurePath],
'webpack.mix.js' => [$featurePath],
'tailwind.config.js' => [$featurePath],
'tailwind.config.ts' => [$featurePath],
'postcss.config.js' => [$featurePath],
];
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
/**
* Watch patterns for projects using Livewire.
*
* Livewire components pair a PHP class with a Blade view. A view change can
* break rendering or assertions in feature / browser tests even though the
* PHP side is untouched.
*
* @internal
*/
final readonly class Livewire implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('livewire/livewire');
}
public function defaults(string $projectRoot, string $testPath): array
{
return [
// Livewire views live alongside Blade views or in a dedicated dir.
'resources/views/livewire/**/*.blade.php' => [$testPath],
'resources/views/components/**/*.blade.php' => [$testPath],
// Livewire JS interop / Alpine plugins.
'resources/js/**/*.js' => [$testPath],
'resources/js/**/*.ts' => [$testPath],
];
}
}

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
/**
* Baseline watch patterns for any PHP project.
*
* @internal
*/
final readonly class Php implements WatchDefault
{
public function applicable(): bool
{
return true;
}
public function defaults(string $projectRoot, string $testPath): array
{
// NOTE: composer.json / composer.lock changes are caught by the
// fingerprint (which hashes composer.lock). PHP files are tracked by
// the coverage driver. Only non-PHP, non-fingerprinted files that
// can silently alter test behaviour belong here.
return [
// Environment files — can change DB drivers, feature flags,
// queue connections, etc. Not PHP, not fingerprinted.
'.env' => [$testPath],
'.env.testing' => [$testPath],
// Docker / CI — can affect integration test infrastructure.
'docker-compose.yml' => [$testPath],
'docker-compose.yaml' => [$testPath],
// PHPUnit / Pest config (XML) — phpunit.xml IS fingerprinted, but
// phpunit.xml.dist and other XML overrides are not individually
// tracked by the coverage driver.
'phpunit.xml.dist' => [$testPath],
// Test fixtures — JSON, CSV, XML, TXT data files consumed by
// assertions. A fixture change can flip a test result.
$testPath.'/Fixtures/**/*.json' => [$testPath],
$testPath.'/Fixtures/**/*.csv' => [$testPath],
$testPath.'/Fixtures/**/*.xml' => [$testPath],
$testPath.'/Fixtures/**/*.txt' => [$testPath],
// Pest snapshots — external edits to snapshot files invalidate
// snapshot assertions.
$testPath.'/.pest/snapshots/**/*.snap' => [$testPath],
];
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
use Composer\InstalledVersions;
/**
* Watch patterns for Symfony projects.
*
* @internal
*/
final readonly class Symfony implements WatchDefault
{
public function applicable(): bool
{
return class_exists(InstalledVersions::class)
&& InstalledVersions::isInstalled('symfony/framework-bundle');
}
public function defaults(string $projectRoot, string $testPath): array
{
// Symfony boots the kernel in setUp() (before the coverage window).
// PHP config, routes, kernel, and migrations are loaded during boot
// and invisible to the coverage driver. Same reasoning as Laravel.
return [
// Config — YAML, XML, and PHP. All loaded during kernel boot.
'config/*.yaml' => [$testPath],
'config/*.yml' => [$testPath],
'config/*.php' => [$testPath],
'config/*.xml' => [$testPath],
'config/**/*.yaml' => [$testPath],
'config/**/*.yml' => [$testPath],
'config/**/*.php' => [$testPath],
'config/**/*.xml' => [$testPath],
// Routes — loaded during boot.
'config/routes/*.yaml' => [$testPath],
'config/routes/*.php' => [$testPath],
'config/routes/*.xml' => [$testPath],
'config/routes/**/*.yaml' => [$testPath],
// Kernel / bootstrap — loaded during boot.
'src/Kernel.php' => [$testPath],
// Migrations — run during setUp (before coverage window).
'migrations/**/*.php' => [$testPath],
// Twig templates — compiled, source not PHP-executed.
'templates/**/*.html.twig' => [$testPath],
'templates/**/*.twig' => [$testPath],
// Translations (YAML / XLF / XLIFF).
'translations/**/*.yaml' => [$testPath],
'translations/**/*.yml' => [$testPath],
'translations/**/*.xlf' => [$testPath],
'translations/**/*.xliff' => [$testPath],
// Doctrine XML/YAML mappings.
'config/doctrine/**/*.xml' => [$testPath],
'config/doctrine/**/*.yaml' => [$testPath],
// Webpack Encore / asset-mapper config + frontend sources.
'webpack.config.js' => [$testPath],
'importmap.php' => [$testPath],
'assets/**/*.js' => [$testPath],
'assets/**/*.ts' => [$testPath],
'assets/**/*.vue' => [$testPath],
'assets/**/*.css' => [$testPath],
'assets/**/*.scss' => [$testPath],
];
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia\WatchDefaults;
/**
* A set of file-watch patterns that apply when a particular framework,
* library or project layout is detected.
*
* Each implementation probes for the presence of the tool it covers
* (`applicable`) and returns glob → test-directory mappings (`defaults`)
* that are merged into `WatchPatterns`.
*
* @internal
*/
interface WatchDefault
{
/**
* Whether this default set applies to the current project.
*/
public function applicable(): bool;
/**
* @return array<string, array<int, string>> glob → list of project-relative test dirs
*/
public function defaults(string $projectRoot, string $testPath): array;
}

View File

@ -0,0 +1,188 @@
<?php
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.
*
* Coverage drivers only see `.php` files. Frontend assets, config files,
* Blade templates, routes and environment files are invisible to the graph.
* Watch patterns bridge the gap: when a changed file matches a glob, every
* test under the associated directory is marked as affected.
*
* Defaults are assembled dynamically from the `WatchDefaults/` registry —
* each implementation probes the current project and contributes patterns
* when applicable. Users extend via `pest()->tia()->watch(…)`.
*
* @internal
*/
final class WatchPatterns
{
/**
* All known default providers, in evaluation order.
*
* @var array<int, class-string<WatchDefault>>
*/
private const array DEFAULTS = [
WatchDefaults\Php::class,
WatchDefaults\Laravel::class,
WatchDefaults\Symfony::class,
WatchDefaults\Livewire::class,
WatchDefaults\Inertia::class,
WatchDefaults\Browser::class,
];
/**
* @var array<string, array<int, string>> glob → list of project-relative test dirs
*/
private array $patterns = [];
/**
* Probes every registered `WatchDefault` and merges the patterns of
* those that apply. Called once during Tia plugin boot, after BootFiles
* has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()`
* calls are already in `$this->patterns`).
*/
public function useDefaults(string $projectRoot): void
{
$testPath = TestSuite::getInstance()->testPath;
foreach (self::DEFAULTS as $class) {
$default = new $class;
if (! $default->applicable()) {
continue;
}
foreach ($default->defaults($projectRoot, $testPath) as $glob => $dirs) {
$this->patterns[$glob] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], $dirs),
));
}
}
}
/**
* Adds user-defined patterns. Merges with existing entries so a single
* glob can map to multiple directories.
*
* @param array<string, string> $patterns glob → project-relative test dir
*/
public function add(array $patterns): void
{
foreach ($patterns as $glob => $dir) {
$this->patterns[$glob] = array_values(array_unique(
array_merge($this->patterns[$glob] ?? [], [$dir]),
));
}
}
/**
* Returns all test directories whose watch patterns match at least one of
* the given changed files.
*
* @param string $projectRoot Absolute path.
* @param array<int, string> $changedFiles Project-relative paths.
* @return array<int, string> Project-relative test directories.
*/
public function matchedDirectories(string $projectRoot, array $changedFiles): array
{
if ($this->patterns === []) {
return [];
}
$matched = [];
foreach ($changedFiles as $file) {
foreach ($this->patterns as $glob => $dirs) {
if ($this->globMatches($glob, $file)) {
foreach ($dirs as $dir) {
$matched[$dir] = true;
}
}
}
}
return array_keys($matched);
}
/**
* Given the affected directories, returns every test file in the graph
* that lives under one of those directories.
*
* @param array<int, string> $directories Project-relative dirs.
* @param array<int, string> $allTestFiles Project-relative test files from graph.
* @return array<int, string>
*/
public function testsUnderDirectories(array $directories, array $allTestFiles): array
{
if ($directories === []) {
return [];
}
$affected = [];
foreach ($allTestFiles as $testFile) {
foreach ($directories as $dir) {
$prefix = rtrim($dir, '/').'/';
if (str_starts_with($testFile, $prefix)) {
$affected[] = $testFile;
break;
}
}
}
return $affected;
}
public function reset(): void
{
$this->patterns = [];
}
/**
* Matches a project-relative file against a glob pattern.
*
* Supports `*` (single segment), `**` (any depth) and `?`.
*/
private function globMatches(string $pattern, string $file): bool
{
$pattern = str_replace('\\', '/', $pattern);
$file = str_replace('\\', '/', $file);
$regex = '';
$len = strlen($pattern);
$i = 0;
while ($i < $len) {
$c = $pattern[$i];
if ($c === '*' && isset($pattern[$i + 1]) && $pattern[$i + 1] === '*') {
$regex .= '.*';
$i += 2;
if (isset($pattern[$i]) && $pattern[$i] === '/') {
$i++;
}
} elseif ($c === '*') {
$regex .= '[^/]*';
$i++;
} elseif ($c === '?') {
$regex .= '[^/]';
$i++;
} else {
$regex .= preg_quote($c, '#');
$i++;
}
}
return (bool) preg_match('#^'.$regex.'$#', $file);
}
}

View File

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Pest\Subscribers; namespace Pest\Subscribers;
use Pest\Support\Tia\Recorder; use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Test\Finished; use PHPUnit\Event\Test\Finished;
use PHPUnit\Event\Test\FinishedSubscriber; use PHPUnit\Event\Test\FinishedSubscriber;
@ -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

@ -4,7 +4,7 @@ declare(strict_types=1);
namespace Pest\Subscribers; namespace Pest\Subscribers;
use Pest\Support\Tia\Recorder; use Pest\Plugins\Tia\Recorder;
use PHPUnit\Event\Code\TestMethod; use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\Prepared; use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber; use PHPUnit\Event\Test\PreparedSubscriber;
@ -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

@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Pest\Subscribers;
use Pest\Plugins\Tia\ResultCollector;
use PHPUnit\Event\Code\TestMethod;
use PHPUnit\Event\Test\ConsideredRisky;
use PHPUnit\Event\Test\ConsideredRiskySubscriber;
use PHPUnit\Event\Test\Errored;
use PHPUnit\Event\Test\ErroredSubscriber;
use PHPUnit\Event\Test\Failed;
use PHPUnit\Event\Test\FailedSubscriber;
use PHPUnit\Event\Test\MarkedIncomplete;
use PHPUnit\Event\Test\MarkedIncompleteSubscriber;
use PHPUnit\Event\Test\Passed;
use PHPUnit\Event\Test\PassedSubscriber;
use PHPUnit\Event\Test\Prepared;
use PHPUnit\Event\Test\PreparedSubscriber;
use PHPUnit\Event\Test\Skipped;
use PHPUnit\Event\Test\SkippedSubscriber;
/**
* Feeds per-test outcomes (status + message + time) into the TIA
* `ResultCollector` so the graph can persist them for faithful replay.
*
* @internal
*/
final class EnsureTiaResultsAreCollected implements
ConsideredRiskySubscriber,
ErroredSubscriber,
FailedSubscriber,
MarkedIncompleteSubscriber,
PassedSubscriber,
PreparedSubscriber,
SkippedSubscriber
{
public function __construct(private readonly ResultCollector $collector) {}
public function notify(Prepared|Passed|Failed|Errored|Skipped|MarkedIncomplete|ConsideredRisky $event): void
{
if ($event instanceof Prepared) {
$test = $event->test();
if ($test instanceof TestMethod) {
$this->collector->testPrepared($test->className().'::'.$test->methodName());
}
return;
}
if ($event instanceof Passed) {
$this->collector->testPassed();
return;
}
if ($event instanceof Failed) {
$this->collector->testFailed($event->throwable()->message());
return;
}
if ($event instanceof Errored) {
$this->collector->testErrored($event->throwable()->message());
return;
}
if ($event instanceof Skipped) {
$this->collector->testSkipped($event->message());
return;
}
if ($event instanceof MarkedIncomplete) {
$this->collector->testIncomplete($event->throwable()->message());
return;
}
// Last possible type: ConsideredRisky (all others returned above).
$this->collector->testRisky($event->message()); // @phpstan-ignore method.notFound
}
}

View File

@ -5,7 +5,7 @@ declare(strict_types=1);
namespace Pest\TestCaseFilters; namespace Pest\TestCaseFilters;
use Pest\Contracts\TestCaseFilter; use Pest\Contracts\TestCaseFilter;
use Pest\Support\Tia\Graph; use Pest\Plugins\Tia\Graph;
/** /**
* Accepts a test file in one of three cases: * Accepts a test file in one of three cases:

View File

@ -37,6 +37,7 @@ arch('contracts')
->toOnlyUse([ ->toOnlyUse([
'NunoMaduro\Collision\Contracts', 'NunoMaduro\Collision\Contracts',
'Pest\Factories\TestCaseMethodFactory', 'Pest\Factories\TestCaseMethodFactory',
'Pest\Plugins\Tia\CachedTestResult',
'Symfony\Component\Console', 'Symfony\Component\Console',
'Pest\Arch\Contracts', 'Pest\Arch\Contracts',
'Pest\PendingCalls', 'Pest\PendingCalls',