mirror of
https://github.com/pestphp/pest.git
synced 2026-04-20 22:20:17 +02:00
Compare commits
8 Commits
4b8e303cd5
...
41f11c0ef3
| Author | SHA1 | Date | |
|---|---|---|---|
| 41f11c0ef3 | |||
| e91634ff05 | |||
| df0f440f84 | |||
| 50601e6118 | |||
| 247d59abf6 | |||
| b24c375d72 | |||
| 30fff116fd | |||
| 192f289e7e |
@ -27,6 +27,7 @@ final readonly class BootSubscribers implements Bootstrapper
|
||||
Subscribers\EnsureTeamCityEnabled::class,
|
||||
Subscribers\EnsureTiaCoverageIsRecorded::class,
|
||||
Subscribers\EnsureTiaCoverageIsFlushed::class,
|
||||
Subscribers\EnsureTiaResultsAreCollected::class,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@ -5,8 +5,11 @@ declare(strict_types=1);
|
||||
namespace Pest\Concerns;
|
||||
|
||||
use Closure;
|
||||
use Pest\Contracts\Plugins\BeforeEachable;
|
||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugin\Loader;
|
||||
use Pest\Plugins\Tia\CachedTestResult;
|
||||
use Pest\Preset;
|
||||
use Pest\Support\ChainableClosure;
|
||||
use Pest\Support\ExceptionTrace;
|
||||
@ -75,6 +78,12 @@ trait Testable
|
||||
*/
|
||||
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.
|
||||
*/
|
||||
@ -227,6 +236,31 @@ trait Testable
|
||||
{
|
||||
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());
|
||||
|
||||
$description = $method->description;
|
||||
@ -302,6 +336,12 @@ trait Testable
|
||||
*/
|
||||
protected function tearDown(...$arguments): void
|
||||
{
|
||||
if ($this->__cachedPass) {
|
||||
TestSuite::getInstance()->test = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
|
||||
|
||||
if ($this->__afterEach instanceof Closure) {
|
||||
@ -327,6 +367,12 @@ trait Testable
|
||||
*/
|
||||
private function __runTest(Closure $closure, ...$args): mixed
|
||||
{
|
||||
if ($this->__cachedPass) {
|
||||
$this->addToAssertionCount(1);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$arguments = $this->__resolveTestArguments($args);
|
||||
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
|
||||
|
||||
|
||||
@ -119,6 +119,14 @@ final readonly class 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.
|
||||
*
|
||||
|
||||
25
src/Contracts/Plugins/BeforeEachable.php
Normal file
25
src/Contracts/Plugins/BeforeEachable.php
Normal 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;
|
||||
}
|
||||
@ -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,10 @@ 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)
|
||||
->add(Tia\ResultCollector::class, new Tia\ResultCollector);
|
||||
|
||||
$kernel = new self(
|
||||
new Application,
|
||||
|
||||
@ -5,16 +5,18 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins;
|
||||
|
||||
use Pest\Contracts\Plugins\AddsOutput;
|
||||
use Pest\Contracts\Plugins\BeforeEachable;
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
use Pest\Contracts\Plugins\Terminable;
|
||||
use Pest\Exceptions\NoDirtyTestsFound;
|
||||
use Pest\Panic;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\Tia\ChangedFiles;
|
||||
use Pest\Support\Tia\Fingerprint;
|
||||
use Pest\Support\Tia\Graph;
|
||||
use Pest\Support\Tia\Recorder;
|
||||
use Pest\Plugins\Tia\CachedTestResult;
|
||||
use Pest\Plugins\Tia\ChangedFiles;
|
||||
use Pest\Plugins\Tia\Fingerprint;
|
||||
use Pest\Plugins\Tia\Graph;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use Pest\Plugins\Tia\ResultCollector;
|
||||
use Pest\TestCaseFilters\TiaTestCaseFilter;
|
||||
use Pest\Plugins\Tia\WatchPatterns;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Throwable;
|
||||
@ -26,7 +28,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
|
||||
@ -49,7 +51,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
|
||||
@ -62,7 +64,7 @@ use Throwable;
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Terminable
|
||||
{
|
||||
use Concerns\HandleArguments;
|
||||
|
||||
@ -70,11 +72,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
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.
|
||||
@ -83,13 +95,100 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
/**
|
||||
* 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 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}
|
||||
@ -136,7 +235,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return;
|
||||
}
|
||||
|
||||
$recorder = Recorder::instance();
|
||||
$recorder = $this->recorder;
|
||||
|
||||
if (! $recorder->isActive()) {
|
||||
return;
|
||||
@ -161,11 +260,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
// 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));
|
||||
$graph->setRecordedAtSha((new ChangedFiles($projectRoot))->currentSha());
|
||||
$graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha());
|
||||
$graph->replaceEdges($perTest);
|
||||
$graph->pruneMissingTests();
|
||||
|
||||
@ -179,7 +278,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> graph recorded (%d test files) at %s',
|
||||
count($perTest),
|
||||
self::CACHE_PATH,
|
||||
self::CACHE_FILE,
|
||||
));
|
||||
|
||||
$recorder->reset();
|
||||
@ -196,6 +295,20 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
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') {
|
||||
return $exitCode;
|
||||
}
|
||||
@ -207,11 +320,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
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));
|
||||
$graph->setRecordedAtSha((new ChangedFiles($projectRoot))->currentSha());
|
||||
$graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha());
|
||||
|
||||
$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',
|
||||
count($finalised),
|
||||
count($partials),
|
||||
self::CACHE_PATH,
|
||||
self::CACHE_FILE,
|
||||
));
|
||||
|
||||
return $exitCode;
|
||||
@ -266,7 +379,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
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);
|
||||
|
||||
$graph = $forceRebuild ? null : Graph::load($projectRoot, $cachePath);
|
||||
@ -280,10 +403,11 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
if ($graph instanceof Graph) {
|
||||
$changedFiles = new ChangedFiles($projectRoot);
|
||||
$branchSha = $graph->recordedAtSha($this->branch);
|
||||
|
||||
if ($changedFiles->gitAvailable()
|
||||
&& $graph->recordedAtSha() !== null
|
||||
&& $changedFiles->since($graph->recordedAtSha()) === null) {
|
||||
&& $branchSha !== null
|
||||
&& $changedFiles->since($branchSha) === null) {
|
||||
$this->output->writeln(
|
||||
' <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
|
||||
{
|
||||
$this->branch = (new ChangedFiles($projectRoot))->currentBranch() ?? 'main';
|
||||
|
||||
if ($replayingGlobal) {
|
||||
// Replay in a worker: load the graph and the affected set that
|
||||
// the parent persisted, then install the per-file filter so
|
||||
@ -318,7 +444,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$recorder = Recorder::instance();
|
||||
$recorder = $this->recorder;
|
||||
|
||||
if (! $recorder->driverAvailable()) {
|
||||
// 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
|
||||
{
|
||||
$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);
|
||||
|
||||
@ -384,46 +510,40 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$changed = $changedFiles->since($graph->recordedAtSha()) ?? [];
|
||||
$changed = $changedFiles->since($graph->recordedAtSha($this->branch)) ?? [];
|
||||
|
||||
if ($changed === []) {
|
||||
$this->output->writeln(' <fg=green>TIA</> no changes detected.');
|
||||
// Drop files whose content hash matches the last-run snapshot. This
|
||||
// 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();
|
||||
$affectedSet = array_fill_keys($affected, true);
|
||||
|
||||
$this->replayRan = true;
|
||||
$this->replayGraph = $graph;
|
||||
$this->affectedFiles = $affectedSet;
|
||||
|
||||
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(
|
||||
' <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($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.',
|
||||
@ -435,9 +555,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> %d changed file(s) → %d known test file(s) + any new/unknown tests (parallel).',
|
||||
' <fg=green>TIA</> %d changed file(s) → %d affected, %d cached (parallel).',
|
||||
count($changed),
|
||||
count($affected),
|
||||
$affectedCount,
|
||||
$cachedCount,
|
||||
));
|
||||
|
||||
return $arguments;
|
||||
@ -448,7 +569,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
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)) {
|
||||
@ -499,15 +620,19 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
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();
|
||||
@ -535,7 +660,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$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)) {
|
||||
@ -564,7 +689,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
*/
|
||||
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;
|
||||
@ -597,10 +722,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$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) {
|
||||
@ -615,6 +742,80 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
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
|
||||
{
|
||||
try {
|
||||
@ -624,6 +825,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
return false;
|
||||
}
|
||||
|
||||
return property_exists($coverage, 'coverage') && $coverage->coverage === true;
|
||||
return $coverage->coverage === true;
|
||||
}
|
||||
}
|
||||
|
||||
33
src/Plugins/Tia/CachedTestResult.php
Normal file
33
src/Plugins/Tia/CachedTestResult.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Support\Tia;
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
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
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
@ -87,6 +165,24 @@ final readonly class ChangedFiles
|
||||
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
|
||||
{
|
||||
$process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot);
|
||||
42
src/Plugins/Tia/Configuration.php
Normal file
42
src/Plugins/Tia/Configuration.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Support\Tia;
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Captures environmental inputs that, when changed, make the TIA graph stale.
|
||||
@ -19,7 +19,7 @@ final readonly class Fingerprint
|
||||
private const int SCHEMA_VERSION = 2;
|
||||
|
||||
/**
|
||||
* @param non-empty-string $projectRoot
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
public static function compute(string $projectRoot): array
|
||||
{
|
||||
@ -2,7 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Support\Tia;
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Support\Container;
|
||||
|
||||
/**
|
||||
* File-level Test Impact Analysis graph.
|
||||
@ -45,9 +47,22 @@ final class Graph
|
||||
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
|
||||
@ -88,38 +103,104 @@ final class Graph
|
||||
/**
|
||||
* 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.
|
||||
* @return array<int, string> Relative test file paths.
|
||||
*/
|
||||
public function affected(array $changedFiles): array
|
||||
{
|
||||
$changedIds = [];
|
||||
// Normalise all changed paths once.
|
||||
$normalised = [];
|
||||
|
||||
foreach ($changedFiles as $file) {
|
||||
$rel = $this->relative($file);
|
||||
|
||||
if ($rel === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($this->fileIds[$rel])) {
|
||||
$changedIds[$this->fileIds[$rel]] = true;
|
||||
if ($rel !== null) {
|
||||
$normalised[] = $rel;
|
||||
}
|
||||
}
|
||||
|
||||
$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 ($ids as $id) {
|
||||
if (isset($changedIds[$id])) {
|
||||
$affected[] = $testFile;
|
||||
$affectedSet[$testFile] = true;
|
||||
|
||||
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 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
|
||||
{
|
||||
$this->fingerprint = $fingerprint;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, int|string|null>
|
||||
*/
|
||||
public function fingerprint(): array
|
||||
{
|
||||
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->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->fileIds = array_flip($graph->files);
|
||||
$graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : [];
|
||||
$graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : [];
|
||||
|
||||
return $graph;
|
||||
}
|
||||
@ -233,9 +398,9 @@ final class Graph
|
||||
$payload = [
|
||||
'schema' => 1,
|
||||
'fingerprint' => $this->fingerprint,
|
||||
'recorded_at_sha' => $this->recordedAtSha,
|
||||
'files' => $this->files,
|
||||
'edges' => $this->edges,
|
||||
'baselines' => $this->baselines,
|
||||
];
|
||||
|
||||
$tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp';
|
||||
@ -2,10 +2,9 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Support\Tia;
|
||||
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;
|
||||
@ -69,12 +61,20 @@ final class Recorder
|
||||
$this->driver = 'pcov';
|
||||
$this->driverAvailable = true;
|
||||
} elseif (function_exists('xdebug_start_code_coverage')) {
|
||||
// Probe: Xdebug silently emits a warning and refuses to start
|
||||
// when not in coverage mode. Suppress + check for mode errors.
|
||||
$ok = @\xdebug_start_code_coverage();
|
||||
// Xdebug is loaded. Probe whether coverage mode is active by
|
||||
// attempting a start — it emits E_WARNING when the mode is off.
|
||||
// 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) {
|
||||
@\xdebug_stop_code_coverage(false);
|
||||
return true;
|
||||
});
|
||||
\xdebug_start_code_coverage();
|
||||
restore_error_handler();
|
||||
|
||||
if ($probeOk) {
|
||||
\xdebug_stop_code_coverage(false);
|
||||
$this->driver = 'xdebug';
|
||||
$this->driverAvailable = true;
|
||||
}
|
||||
@ -195,31 +195,23 @@ final class Recorder
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$reflection = new ReflectionClass($className);
|
||||
} catch (ReflectionException) {
|
||||
return null;
|
||||
}
|
||||
$reflection = new ReflectionClass($className);
|
||||
|
||||
if ($reflection->hasProperty('__filename')) {
|
||||
try {
|
||||
$property = $reflection->getProperty('__filename');
|
||||
$property = $reflection->getProperty('__filename');
|
||||
|
||||
if ($property->isStatic()) {
|
||||
$value = $property->getValue();
|
||||
if ($property->isStatic()) {
|
||||
$value = $property->getValue();
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
if (is_string($value)) {
|
||||
return $value;
|
||||
}
|
||||
} catch (ReflectionException) {
|
||||
// fall through to getFileName()
|
||||
}
|
||||
}
|
||||
|
||||
$file = $reflection->getFileName();
|
||||
|
||||
return $file !== false && $file !== '' ? $file : null;
|
||||
return is_string($file) ? $file : null;
|
||||
}
|
||||
|
||||
/**
|
||||
119
src/Plugins/Tia/ResultCollector.php
Normal file
119
src/Plugins/Tia/ResultCollector.php
Normal 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;
|
||||
}
|
||||
}
|
||||
119
src/Plugins/Tia/WatchDefaults/Browser.php
Normal file
119
src/Plugins/Tia/WatchDefaults/Browser.php
Normal 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)));
|
||||
}
|
||||
}
|
||||
53
src/Plugins/Tia/WatchDefaults/Inertia.php
Normal file
53
src/Plugins/Tia/WatchDefaults/Inertia.php
Normal 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
81
src/Plugins/Tia/WatchDefaults/Laravel.php
Normal file
81
src/Plugins/Tia/WatchDefaults/Laravel.php
Normal 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
38
src/Plugins/Tia/WatchDefaults/Livewire.php
Normal file
38
src/Plugins/Tia/WatchDefaults/Livewire.php
Normal 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
53
src/Plugins/Tia/WatchDefaults/Php.php
Normal file
53
src/Plugins/Tia/WatchDefaults/Php.php
Normal 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
75
src/Plugins/Tia/WatchDefaults/Symfony.php
Normal file
75
src/Plugins/Tia/WatchDefaults/Symfony.php
Normal 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],
|
||||
];
|
||||
}
|
||||
}
|
||||
28
src/Plugins/Tia/WatchDefaults/WatchDefault.php
Normal file
28
src/Plugins/Tia/WatchDefaults/WatchDefault.php
Normal 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;
|
||||
}
|
||||
188
src/Plugins/Tia/WatchPatterns.php
Normal file
188
src/Plugins/Tia/WatchPatterns.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Support\Tia\Recorder;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use PHPUnit\Event\Test\Finished;
|
||||
use PHPUnit\Event\Test\FinishedSubscriber;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Subscribers;
|
||||
|
||||
use Pest\Support\Tia\Recorder;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use PHPUnit\Event\Code\TestMethod;
|
||||
use PHPUnit\Event\Test\Prepared;
|
||||
use PHPUnit\Event\Test\PreparedSubscriber;
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
86
src/Subscribers/EnsureTiaResultsAreCollected.php
Normal file
86
src/Subscribers/EnsureTiaResultsAreCollected.php
Normal 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
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\TestCaseFilters;
|
||||
|
||||
use Pest\Contracts\TestCaseFilter;
|
||||
use Pest\Support\Tia\Graph;
|
||||
use Pest\Plugins\Tia\Graph;
|
||||
|
||||
/**
|
||||
* Accepts a test file in one of three cases:
|
||||
|
||||
@ -37,6 +37,7 @@ arch('contracts')
|
||||
->toOnlyUse([
|
||||
'NunoMaduro\Collision\Contracts',
|
||||
'Pest\Factories\TestCaseMethodFactory',
|
||||
'Pest\Plugins\Tia\CachedTestResult',
|
||||
'Symfony\Component\Console',
|
||||
'Pest\Arch\Contracts',
|
||||
'Pest\PendingCalls',
|
||||
|
||||
Reference in New Issue
Block a user