mirror of
https://github.com/pestphp/pest.git
synced 2026-04-20 22:20:17 +02:00
feat(tia): continues to work on poc
This commit is contained in:
@ -6,11 +6,7 @@ namespace Pest\Concerns;
|
||||
|
||||
use Closure;
|
||||
use Pest\Exceptions\DatasetArgumentsMismatch;
|
||||
use Pest\Contracts\Plugins\AfterEachable;
|
||||
use Pest\Contracts\Plugins\BeforeEachable;
|
||||
use Pest\Contracts\Plugins\Runnable;
|
||||
use Pest\Panic;
|
||||
use Pest\Plugin\Loader;
|
||||
use Pest\Preset;
|
||||
use Pest\Support\ChainableClosure;
|
||||
use Pest\Support\ExceptionTrace;
|
||||
@ -231,13 +227,6 @@ trait Testable
|
||||
{
|
||||
TestSuite::getInstance()->test = $this;
|
||||
|
||||
/** @var BeforeEachable $plugin */
|
||||
foreach (Loader::getPlugins(BeforeEachable::class) as $plugin) {
|
||||
if ($plugin->beforeEach(self::$__filename, $this::class.'::'.$this->name()) === false) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name());
|
||||
|
||||
$description = $method->description;
|
||||
@ -313,15 +302,6 @@ trait Testable
|
||||
*/
|
||||
protected function tearDown(...$arguments): void
|
||||
{
|
||||
/** @var AfterEachable $plugin */
|
||||
foreach (Loader::getPlugins(AfterEachable::class) as $plugin) {
|
||||
if ($plugin->afterEach(self::$__filename, $this::class.'::'.$this->name()) === false) {
|
||||
TestSuite::getInstance()->test = null;
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename);
|
||||
|
||||
if ($this->__afterEach instanceof Closure) {
|
||||
@ -347,15 +327,6 @@ trait Testable
|
||||
*/
|
||||
private function __runTest(Closure $closure, ...$args): mixed
|
||||
{
|
||||
/** @var Runnable $plugin */
|
||||
foreach (Loader::getPlugins(Runnable::class) as $plugin) {
|
||||
if ($plugin->run(self::$__filename, $this::class.'::'.$this->name()) === false) {
|
||||
$this->addToAssertionCount(1);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
$arguments = $this->__resolveTestArguments($args);
|
||||
$this->__ensureDatasetArgumentNameAndNumberMatches($arguments);
|
||||
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Contracts\Plugins;
|
||||
|
||||
/**
|
||||
* Called at the start of `tearDown`. Return `false` to skip the framework
|
||||
* tearDown, afterEach chain, and method-level cleanup.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface AfterEachable
|
||||
{
|
||||
/**
|
||||
* @param string $filename Absolute path of the test file.
|
||||
* @param string $testId Fully-qualified `Class::method` identifier.
|
||||
*/
|
||||
public function afterEach(string $filename, string $testId): bool;
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Contracts\Plugins;
|
||||
|
||||
/**
|
||||
* Plugins implementing this interface are called before each test's `setUp`.
|
||||
*
|
||||
* Return `false` to skip the test entirely — `setUp`, body and `tearDown`
|
||||
* are all bypassed and the test counts as passed with one synthetic
|
||||
* assertion. Any other return value lets the test proceed normally.
|
||||
*
|
||||
* Resolution happens once per process via `Loader::getPlugins`; the per-test
|
||||
* call is a cheap iteration over the cached list.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface BeforeEachable
|
||||
{
|
||||
/**
|
||||
* @param string $filename Absolute path of the test file.
|
||||
* @param string $testId Fully-qualified `Class::method` identifier.
|
||||
*/
|
||||
public function beforeEach(string $filename, string $testId): bool;
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Contracts\Plugins;
|
||||
|
||||
/**
|
||||
* Called around the test body (`__runTest`). Return `false` to skip the
|
||||
* closure — a synthetic assertion is registered so PHPUnit does not flag the
|
||||
* test as risky.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
interface Runnable
|
||||
{
|
||||
/**
|
||||
* @param string $filename Absolute path of the test file.
|
||||
* @param string $testId Fully-qualified `Class::method` identifier.
|
||||
*/
|
||||
public function run(string $filename, string $testId): bool;
|
||||
}
|
||||
@ -13,6 +13,7 @@ use Pest\Plugins\Actions\CallsBoot;
|
||||
use Pest\Plugins\Actions\CallsHandleArguments;
|
||||
use Pest\Plugins\Actions\CallsHandleOriginalArguments;
|
||||
use Pest\Plugins\Actions\CallsTerminable;
|
||||
use Pest\Plugins\Tia;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Support\Reflection;
|
||||
use Pest\Support\View;
|
||||
@ -64,7 +65,9 @@ final readonly class Kernel
|
||||
->add(TestSuite::class, $testSuite)
|
||||
->add(InputInterface::class, $input)
|
||||
->add(OutputInterface::class, $output)
|
||||
->add(Container::class, $container);
|
||||
->add(Container::class, $container)
|
||||
->add(Tia\Recorder::class, new Tia\Recorder)
|
||||
->add(Tia\WatchPatterns::class, new Tia\WatchPatterns);
|
||||
|
||||
$kernel = new self(
|
||||
new Application,
|
||||
|
||||
@ -5,20 +5,15 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins;
|
||||
|
||||
use Pest\Contracts\Plugins\AddsOutput;
|
||||
use Pest\Contracts\Plugins\AfterEachable;
|
||||
use Pest\Contracts\Plugins\BeforeEachable;
|
||||
use Pest\Contracts\Plugins\HandlesArguments;
|
||||
use Pest\Contracts\Plugins\Runnable;
|
||||
use Pest\Contracts\Plugins\Terminable;
|
||||
use Pest\Exceptions\NoDirtyTestsFound;
|
||||
use Pest\Panic;
|
||||
use Pest\Support\Container;
|
||||
use Pest\Plugins\Tia\ChangedFiles;
|
||||
use Pest\Plugins\Tia\Fingerprint;
|
||||
use Pest\Plugins\Tia\Graph;
|
||||
use Pest\Plugins\Tia\Recorder;
|
||||
use Pest\Plugins\Tia\State;
|
||||
use Pest\TestCaseFilters\TiaTestCaseFilter;
|
||||
use Pest\Plugins\Tia\WatchPatterns;
|
||||
use Pest\Support\Container;
|
||||
use Pest\TestSuite;
|
||||
use Symfony\Component\Console\Output\OutputInterface;
|
||||
use Throwable;
|
||||
@ -30,7 +25,7 @@ use Throwable;
|
||||
* -----
|
||||
* - **Record** — no graph (or fingerprint / recording commit drifted). The
|
||||
* full suite runs with PCOV / Xdebug capture per test; the resulting
|
||||
* `test → [source_file, …]` edges land in `.pest/cache/tia.json`.
|
||||
* `test → [source_file, …]` edges land in `.temp/tia.json`.
|
||||
* - **Replay** — graph valid. We diff the working tree against the recording
|
||||
* commit, intersect changed files with graph edges, and run only the
|
||||
* affected tests. Newly-added tests unknown to the graph are always
|
||||
@ -53,7 +48,7 @@ use Throwable;
|
||||
* - **Worker, record**: boots through `bin/worker.php`, which re-runs
|
||||
* `CallsHandleArguments`. We detect the worker context + recording flag,
|
||||
* activate the `Recorder`, and flush the partial graph on `terminate()`
|
||||
* into `.pest/cache/tia-worker-<TEST_TOKEN>.json`.
|
||||
* into `.temp/tia-worker-<TEST_TOKEN>.json`.
|
||||
* - **Worker, replay**: nothing to do; args already narrowed.
|
||||
*
|
||||
* Guardrails
|
||||
@ -66,7 +61,7 @@ use Throwable;
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArguments, Runnable, Terminable
|
||||
final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
{
|
||||
use Concerns\HandleArguments;
|
||||
|
||||
@ -74,11 +69,21 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
|
||||
private const string REBUILD_OPTION = '--tia-rebuild';
|
||||
|
||||
private const string CACHE_PATH = '.pest/cache/tia.json';
|
||||
/**
|
||||
* TIA cache lives inside Pest's `.temp/` directory (same location as
|
||||
* PHPUnit's result cache). This directory is gitignored by default in
|
||||
* Pest's own `.gitignore`, so the graph is never committed.
|
||||
*/
|
||||
private const string TEMP_DIR = __DIR__
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'..'
|
||||
.DIRECTORY_SEPARATOR.'.temp';
|
||||
|
||||
private const string AFFECTED_PATH = '.pest/cache/tia-affected.json';
|
||||
private const string CACHE_FILE = 'tia.json';
|
||||
|
||||
private const string WORKER_CACHE_PREFIX = '.pest/cache/tia-worker-';
|
||||
private const string AFFECTED_FILE = 'tia-affected.json';
|
||||
|
||||
private const string WORKER_PREFIX = 'tia-worker-';
|
||||
|
||||
/**
|
||||
* Global flag toggled by the parent process so workers know to record.
|
||||
@ -87,13 +92,50 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
|
||||
/**
|
||||
* Global flag that tells workers to install the TIA filter (replay mode).
|
||||
* Workers read the affected set from `.pest/cache/tia-affected.json`.
|
||||
* Workers read the affected set from `.temp/tia-affected.json`.
|
||||
*/
|
||||
private const string REPLAYING_GLOBAL = 'TIA_REPLAYING';
|
||||
|
||||
private bool $graphWritten = false;
|
||||
|
||||
public function __construct(private readonly OutputInterface $output) {}
|
||||
private static function tempDir(): string
|
||||
{
|
||||
$dir = (string) realpath(self::TEMP_DIR);
|
||||
|
||||
if ($dir === '' || $dir === '.') {
|
||||
// .temp doesn't exist yet — create it.
|
||||
@mkdir(self::TEMP_DIR, 0755, true);
|
||||
$dir = (string) realpath(self::TEMP_DIR);
|
||||
}
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
private static function cachePath(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::CACHE_FILE;
|
||||
}
|
||||
|
||||
private static function affectedPath(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::AFFECTED_FILE;
|
||||
}
|
||||
|
||||
private static function workerPath(string $token): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.$token.'.json';
|
||||
}
|
||||
|
||||
private static function workerGlob(): string
|
||||
{
|
||||
return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.'*.json';
|
||||
}
|
||||
|
||||
public function __construct(
|
||||
private readonly OutputInterface $output,
|
||||
private readonly Recorder $recorder,
|
||||
private readonly WatchPatterns $watchPatterns,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
@ -134,28 +176,13 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
return $this->handleParent($arguments, $projectRoot, $forceRebuild);
|
||||
}
|
||||
|
||||
public function beforeEach(string $filename, string $testId): bool
|
||||
{
|
||||
return ! State::instance()->shouldReplayFromCache($filename, $testId);
|
||||
}
|
||||
|
||||
public function run(string $filename, string $testId): bool
|
||||
{
|
||||
return ! State::instance()->shouldReplayFromCache($filename, $testId);
|
||||
}
|
||||
|
||||
public function afterEach(string $filename, string $testId): bool
|
||||
{
|
||||
return ! State::instance()->shouldReplayFromCache($filename, $testId);
|
||||
}
|
||||
|
||||
public function terminate(): void
|
||||
{
|
||||
if ($this->graphWritten) {
|
||||
return;
|
||||
}
|
||||
|
||||
$recorder = Recorder::instance();
|
||||
$recorder = $this->recorder;
|
||||
|
||||
if (! $recorder->isActive()) {
|
||||
return;
|
||||
@ -180,7 +207,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
}
|
||||
|
||||
// Non-parallel record path: straight into the main cache.
|
||||
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH;
|
||||
$cachePath = self::cachePath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
@ -198,7 +225,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> graph recorded (%d test files) at %s',
|
||||
count($perTest),
|
||||
self::CACHE_PATH,
|
||||
self::CACHE_FILE,
|
||||
));
|
||||
|
||||
$recorder->reset();
|
||||
@ -226,7 +253,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
return $exitCode;
|
||||
}
|
||||
|
||||
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH;
|
||||
$cachePath = self::cachePath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot);
|
||||
$graph->setFingerprint(Fingerprint::compute($projectRoot));
|
||||
@ -273,7 +300,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
' <fg=green>TIA</> graph recorded (%d test files, %d worker partials) at %s',
|
||||
count($finalised),
|
||||
count($partials),
|
||||
self::CACHE_PATH,
|
||||
self::CACHE_FILE,
|
||||
));
|
||||
|
||||
return $exitCode;
|
||||
@ -288,9 +315,9 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
// Initialise watch patterns (defaults + any user additions from
|
||||
// tests/Pest.php which has already been loaded by BootFiles at
|
||||
// this point).
|
||||
WatchPatterns::instance()->useDefaults($projectRoot);
|
||||
$this->watchPatterns->useDefaults($projectRoot);
|
||||
|
||||
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH;
|
||||
$cachePath = self::cachePath();
|
||||
$fingerprint = Fingerprint::compute($projectRoot);
|
||||
|
||||
$graph = $forceRebuild ? null : Graph::load($projectRoot, $cachePath);
|
||||
@ -342,7 +369,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$recorder = Recorder::instance();
|
||||
$recorder = $this->recorder;
|
||||
|
||||
if (! $recorder->driverAvailable()) {
|
||||
// Driver availability is per-process. If the driver is missing
|
||||
@ -358,8 +385,8 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
|
||||
private function installWorkerReplayFilter(string $projectRoot): void
|
||||
{
|
||||
$cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH;
|
||||
$affectedPath = $projectRoot.DIRECTORY_SEPARATOR.self::AFFECTED_PATH;
|
||||
$cachePath = self::cachePath();
|
||||
$affectedPath = self::affectedPath();
|
||||
|
||||
$graph = Graph::load($projectRoot, $cachePath);
|
||||
|
||||
@ -387,73 +414,11 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
}
|
||||
}
|
||||
|
||||
State::instance()->activate(
|
||||
$projectRoot,
|
||||
$graph,
|
||||
$affectedSet,
|
||||
$this->loadPreviousDefects($projectRoot),
|
||||
TestSuite::getInstance()->tests->addTestCaseFilter(
|
||||
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads PHPUnit's own result cache and returns the test ids that failed
|
||||
* or errored in the previous run. These are excluded from replay so the
|
||||
* user sees current state rather than a stale pass.
|
||||
*
|
||||
* @return array<string, true>
|
||||
*/
|
||||
private function loadPreviousDefects(string $projectRoot): array
|
||||
{
|
||||
// PHPUnit writes the cache under either `<projectRoot>/.phpunit.result.cache`
|
||||
// (legacy) or `<cacheDirectory>/test-results`. Pest's Cache plugin
|
||||
// additionally defaults `cacheDirectory` to
|
||||
// `vendor/pestphp/pest/.temp` when the user hasn't configured one.
|
||||
// We probe the common locations; if we miss the file, replay falls
|
||||
// back to its safe default (still runs the test).
|
||||
$candidates = [
|
||||
$projectRoot.'/.phpunit.result.cache',
|
||||
$projectRoot.'/.phpunit.cache/test-results',
|
||||
$projectRoot.'/.pest/cache/test-results',
|
||||
$projectRoot.'/vendor/pestphp/pest/.temp/test-results',
|
||||
];
|
||||
|
||||
$path = null;
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
if (is_file($candidate)) {
|
||||
$path = $candidate;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($path === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$raw = @file_get_contents($path);
|
||||
|
||||
if ($raw === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$data = json_decode($raw, true);
|
||||
|
||||
if (! is_array($data) || ! isset($data['defects']) || ! is_array($data['defects'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$out = [];
|
||||
|
||||
foreach ($data['defects'] as $id => $_status) {
|
||||
if (is_string($id)) {
|
||||
$out[$id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $arguments
|
||||
* @return array<int, string>
|
||||
@ -471,48 +436,31 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
}
|
||||
|
||||
$changed = $changedFiles->since($graph->recordedAtSha()) ?? [];
|
||||
|
||||
// Even with zero changes, we still run through the suite so the user
|
||||
// sees the previous results reflected (cached passes replay as
|
||||
// instant passes; failures re-run to surface current state). This
|
||||
// matches the UX of test runners like NCrunch where every run
|
||||
// produces a full report regardless of what actually executed.
|
||||
$affected = $changed === [] ? [] : $graph->affected($changed);
|
||||
|
||||
$testSuite = TestSuite::getInstance();
|
||||
$totalKnown = count($graph->allTestFiles());
|
||||
$affectedCount = count($affected);
|
||||
$cachedCount = $totalKnown - $affectedCount;
|
||||
|
||||
if (! Parallel::isEnabled()) {
|
||||
// Series mode: activate replay state. Tests still appear in the
|
||||
// run (correct counts, coverage aggregation, event timeline);
|
||||
// unaffected ones short-circuit inside `Testable::__runTest`
|
||||
// and replay their previous passing status.
|
||||
$testSuite = TestSuite::getInstance();
|
||||
$affectedSet = array_fill_keys($affected, true);
|
||||
|
||||
State::instance()->activate(
|
||||
$projectRoot,
|
||||
$graph,
|
||||
$affectedSet,
|
||||
$this->loadPreviousDefects($projectRoot),
|
||||
if (! Parallel::isEnabled()) {
|
||||
$testSuite->tests->addTestCaseFilter(
|
||||
new TiaTestCaseFilter($projectRoot, $graph, $affectedSet),
|
||||
);
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> %d changed file(s) → %d affected, remaining tests replay cached result.',
|
||||
' <fg=green>TIA</> %d changed file(s) → %d affected, %d cached.',
|
||||
count($changed),
|
||||
count($affected),
|
||||
$affectedCount,
|
||||
$cachedCount,
|
||||
));
|
||||
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
// Parallel mode. Paratest's CLI only accepts a single positional
|
||||
// `<path>`, so we cannot pass the affected set as multiple args.
|
||||
// Instead, persist the affected set to a cache file and flip a
|
||||
// global that tells each worker to install the TIA filter on boot.
|
||||
//
|
||||
// Cost trade-off: each worker still discovers the full test tree,
|
||||
// but the filter drops unaffected tests before they ever run. Narrow
|
||||
// CLI handoff would be ideal; it requires generating a temporary
|
||||
// phpunit.xml and is out of scope for the MVP.
|
||||
// Parallel: persist affected set so workers can install the filter.
|
||||
if (! $this->persistAffectedSet($projectRoot, $affected)) {
|
||||
$this->output->writeln(
|
||||
' <fg=red>TIA</> failed to persist affected set — running full suite.',
|
||||
@ -524,9 +472,10 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
Parallel::setGlobal(self::REPLAYING_GLOBAL, '1');
|
||||
|
||||
$this->output->writeln(sprintf(
|
||||
' <fg=green>TIA</> %d changed file(s) → %d affected, remaining tests replay cached result (parallel).',
|
||||
' <fg=green>TIA</> %d changed file(s) → %d affected, %d cached (parallel).',
|
||||
count($changed),
|
||||
count($affected),
|
||||
$affectedCount,
|
||||
$cachedCount,
|
||||
));
|
||||
|
||||
return $arguments;
|
||||
@ -537,7 +486,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
*/
|
||||
private function persistAffectedSet(string $projectRoot, array $affected): bool
|
||||
{
|
||||
$path = $projectRoot.DIRECTORY_SEPARATOR.self::AFFECTED_PATH;
|
||||
$path = self::affectedPath();
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
@ -588,15 +537,19 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
return $arguments;
|
||||
}
|
||||
|
||||
$recorder = Recorder::instance();
|
||||
$recorder = $this->recorder;
|
||||
|
||||
if (! $recorder->driverAvailable()) {
|
||||
$this->output->writeln(
|
||||
' <fg=red>TIA</> No coverage driver is available. '.
|
||||
'Install ext-pcov or enable Xdebug in coverage mode, then rerun with `--tia`.',
|
||||
);
|
||||
$this->output->writeln([
|
||||
'',
|
||||
' <fg=white;options=bold;bg=red> ERROR </> No coverage driver is available.',
|
||||
'',
|
||||
' TIA requires ext-pcov or Xdebug with coverage mode enabled to',
|
||||
' record the dependency graph. Install one and rerun with `--tia`.',
|
||||
'',
|
||||
]);
|
||||
|
||||
return $arguments;
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$recorder->activate();
|
||||
@ -624,7 +577,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
$token = (string) getmypid();
|
||||
}
|
||||
|
||||
$path = $projectRoot.DIRECTORY_SEPARATOR.self::WORKER_CACHE_PREFIX.$token.'.json';
|
||||
$path = self::workerPath($token);
|
||||
$dir = dirname($path);
|
||||
|
||||
if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) {
|
||||
@ -653,7 +606,7 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
*/
|
||||
private function collectWorkerPartials(string $projectRoot): array
|
||||
{
|
||||
$pattern = $projectRoot.DIRECTORY_SEPARATOR.self::WORKER_CACHE_PREFIX.'*.json';
|
||||
$pattern = self::workerGlob();
|
||||
$matches = glob($pattern);
|
||||
|
||||
return $matches === false ? [] : $matches;
|
||||
@ -686,10 +639,12 @@ final class Tia implements AddsOutput, AfterEachable, BeforeEachable, HandlesArg
|
||||
$out = [];
|
||||
|
||||
foreach ($data as $test => $sources) {
|
||||
if (! is_string($test) || ! is_array($sources)) {
|
||||
if (! is_string($test)) {
|
||||
continue;
|
||||
}
|
||||
if (! is_array($sources)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$clean = [];
|
||||
|
||||
foreach ($sources as $source) {
|
||||
|
||||
@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Support\Container;
|
||||
|
||||
/**
|
||||
* User-facing TIA configuration, returned by `pest()->tia()`.
|
||||
*
|
||||
@ -31,7 +33,9 @@ final class Configuration
|
||||
*/
|
||||
public function watch(array $patterns): self
|
||||
{
|
||||
WatchPatterns::instance()->add($patterns);
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$watchPatterns->add($patterns);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Support\Container;
|
||||
|
||||
/**
|
||||
* File-level Test Impact Analysis graph.
|
||||
*
|
||||
@ -127,7 +129,8 @@ final class Graph
|
||||
}
|
||||
|
||||
// 2. Watch-pattern lookup (non-PHP assets → test directories).
|
||||
$watchPatterns = WatchPatterns::instance();
|
||||
/** @var WatchPatterns $watchPatterns */
|
||||
$watchPatterns = Container::getInstance()->get(WatchPatterns::class);
|
||||
$normalised = [];
|
||||
|
||||
foreach ($changedFiles as $file) {
|
||||
|
||||
@ -5,7 +5,6 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use ReflectionClass;
|
||||
use ReflectionException;
|
||||
|
||||
/**
|
||||
* Captures per-test file coverage using the PCOV driver.
|
||||
@ -18,8 +17,6 @@ use ReflectionException;
|
||||
*/
|
||||
final class Recorder
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
|
||||
/**
|
||||
* Test file currently being recorded, or `null` when idle.
|
||||
*/
|
||||
@ -47,11 +44,6 @@ final class Recorder
|
||||
|
||||
private string $driver = 'none';
|
||||
|
||||
public static function instance(): self
|
||||
{
|
||||
return self::$instance ??= new self;
|
||||
}
|
||||
|
||||
public function activate(): void
|
||||
{
|
||||
$this->active = true;
|
||||
|
||||
@ -1,159 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
/**
|
||||
* Shared TIA replay state consulted by Pest's `Testable` trait at runtime.
|
||||
*
|
||||
* Why a singleton: the plugin runs in `handleArguments` (before tests are
|
||||
* discovered), but the actual replay decision has to happen when each test
|
||||
* boots (`setUp` / `__runTest`). Those call sites are inside a trait that
|
||||
* has no easy way to inject dependencies, so they reach into this state
|
||||
* holder.
|
||||
*
|
||||
* Decision: a test file replays its previous pass iff
|
||||
* 1. TIA replay mode is active,
|
||||
* 2. the file is **known** to the dependency graph,
|
||||
* 3. the file is **not** in the affected set (its deps are unchanged),
|
||||
* 4. it was **not** in the previous run's defect list (only cached passes
|
||||
* replay; previously-failing tests rerun so users see current state).
|
||||
*
|
||||
* Points 1-3 live in this class. Point 4 uses PHPUnit's own
|
||||
* `DefaultResultCache`, queried at decision time.
|
||||
*
|
||||
* @internal
|
||||
*/
|
||||
final class State
|
||||
{
|
||||
private static ?self $instance = null;
|
||||
|
||||
private bool $replayMode = false;
|
||||
|
||||
/**
|
||||
* Keys are project-relative test file paths. Affected = must rerun.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
private array $affectedFiles = [];
|
||||
|
||||
/**
|
||||
* Keys are project-relative test file paths. Known = recorded in graph.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
private array $knownFiles = [];
|
||||
|
||||
/**
|
||||
* Test ids (class::method) that were in the previous run's defect list.
|
||||
*
|
||||
* @var array<string, true>
|
||||
*/
|
||||
private array $previousDefects = [];
|
||||
|
||||
/**
|
||||
* Canonicalised project root used for relative-path calculations.
|
||||
*/
|
||||
private string $projectRoot = '';
|
||||
|
||||
public static function instance(): self
|
||||
{
|
||||
return self::$instance ??= new self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns on replay mode with the given graph + affected set.
|
||||
*
|
||||
* @param array<string, true> $affectedFiles
|
||||
* @param array<string, true> $previousDefects
|
||||
*/
|
||||
public function activate(string $projectRoot, Graph $graph, array $affectedFiles, array $previousDefects): void
|
||||
{
|
||||
$real = @realpath($projectRoot);
|
||||
|
||||
$this->projectRoot = $real !== false ? $real : $projectRoot;
|
||||
$this->replayMode = true;
|
||||
$this->affectedFiles = $affectedFiles;
|
||||
$this->previousDefects = $previousDefects;
|
||||
|
||||
// Pre-compute the known set from the graph so per-test lookups stay
|
||||
// O(1). Iterating edges once here beats calling `Graph::knowsTest`
|
||||
// from every test's `setUp`.
|
||||
$this->knownFiles = [];
|
||||
|
||||
foreach ($graph->allTestFiles() as $rel) {
|
||||
$this->knownFiles[$rel] = true;
|
||||
}
|
||||
}
|
||||
|
||||
public function isReplayMode(): bool
|
||||
{
|
||||
return $this->replayMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` when the given absolute test file should replay its
|
||||
* previous passing result instead of re-executing. `$testId` may be
|
||||
* `null` when the caller cannot cheaply determine it (e.g. early in
|
||||
* `setUp` before PHPUnit has published the name) — in that case we
|
||||
* replay iff the file is safe at the file level, and `__runTest` will
|
||||
* repeat the check with a proper id.
|
||||
*/
|
||||
public function shouldReplayFromCache(string $absoluteTestFile, ?string $testId = null): bool
|
||||
{
|
||||
if (! $this->replayMode) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rel = $this->relative($absoluteTestFile);
|
||||
|
||||
if ($rel === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! isset($this->knownFiles[$rel])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($this->affectedFiles[$rel])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($testId !== null && isset($this->previousDefects[$testId])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function reset(): void
|
||||
{
|
||||
$this->replayMode = false;
|
||||
$this->affectedFiles = [];
|
||||
$this->knownFiles = [];
|
||||
$this->previousDefects = [];
|
||||
$this->projectRoot = '';
|
||||
}
|
||||
|
||||
private function relative(string $path): ?string
|
||||
{
|
||||
if ($path === '' || $this->projectRoot === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$real = @realpath($path);
|
||||
|
||||
if ($real === false) {
|
||||
$real = $path;
|
||||
}
|
||||
|
||||
$root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
|
||||
|
||||
if (! str_starts_with($real, $root)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root)));
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia\WatchDefaults;
|
||||
|
||||
use Composer\InstalledVersions;
|
||||
use Pest\Browser\Support\BrowserTestIdentifier;
|
||||
use Pest\Factories\TestCaseFactory;
|
||||
use Pest\TestSuite;
|
||||
|
||||
@ -72,7 +73,7 @@ final readonly class Browser implements WatchDefault
|
||||
// Scan TestRepository via BrowserTestIdentifier if pest-plugin-browser
|
||||
// is installed to find tests using `visit()` outside the conventional
|
||||
// Browser/ folder.
|
||||
if (class_exists(\Pest\Browser\Support\BrowserTestIdentifier::class)) {
|
||||
if (class_exists(BrowserTestIdentifier::class)) {
|
||||
$repo = TestSuite::getInstance()->tests;
|
||||
|
||||
foreach ($repo->getFilenames() as $filename) {
|
||||
@ -83,7 +84,7 @@ final readonly class Browser implements WatchDefault
|
||||
}
|
||||
|
||||
foreach ($factory->methods as $method) {
|
||||
if (\Pest\Browser\Support\BrowserTestIdentifier::isBrowserTest($method)) {
|
||||
if (BrowserTestIdentifier::isBrowserTest($method)) {
|
||||
$rel = $this->fileRelative($projectRoot, $filename);
|
||||
|
||||
if ($rel !== null) {
|
||||
|
||||
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||
namespace Pest\Plugins\Tia;
|
||||
|
||||
use Pest\Plugins\Tia\WatchDefaults\WatchDefault;
|
||||
use Pest\TestSuite;
|
||||
|
||||
/**
|
||||
* Maps non-PHP file globs to the test directories they should invalidate.
|
||||
@ -41,13 +42,6 @@ final class WatchPatterns
|
||||
*/
|
||||
private array $patterns = [];
|
||||
|
||||
private static ?self $instance = null;
|
||||
|
||||
public static function instance(): self
|
||||
{
|
||||
return self::$instance ??= new self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes every registered `WatchDefault` and merges the patterns of
|
||||
* those that apply. Called once during Tia plugin boot, after BootFiles
|
||||
@ -56,7 +50,7 @@ final class WatchPatterns
|
||||
*/
|
||||
public function useDefaults(string $projectRoot): void
|
||||
{
|
||||
$testPath = \Pest\TestSuite::getInstance()->testPath;
|
||||
$testPath = TestSuite::getInstance()->testPath;
|
||||
|
||||
foreach (self::DEFAULTS as $class) {
|
||||
$default = new $class;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Pest\Plugins\Tia;
|
||||
namespace Pest\TestCaseFilters;
|
||||
|
||||
use Pest\Contracts\TestCaseFilter;
|
||||
use Pest\Plugins\Tia\Graph;
|
||||
Reference in New Issue
Block a user