From c440031e2878c26beaa6fc2c19a58c9a35cb7ccd Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Wed, 15 Apr 2026 17:31:53 -0700 Subject: [PATCH 001/160] feat(tia): adds poc --- composer.json | 1 + src/Bootstrappers/BootSubscribers.php | 2 + src/Plugins/Tia.php | 629 ++++++++++++++++++ .../EnsureTiaCoverageIsFlushed.php | 23 + .../EnsureTiaCoverageIsRecorded.php | 36 + src/Support/Tia/ChangedFiles.php | 219 ++++++ src/Support/Tia/Fingerprint.php | 95 +++ src/Support/Tia/Graph.php | 320 +++++++++ src/Support/Tia/Recorder.php | 237 +++++++ src/TestCaseFilters/TiaTestCaseFilter.php | 65 ++ tests/Arch.php | 3 + 11 files changed, 1630 insertions(+) create mode 100644 src/Plugins/Tia.php create mode 100644 src/Subscribers/EnsureTiaCoverageIsFlushed.php create mode 100644 src/Subscribers/EnsureTiaCoverageIsRecorded.php create mode 100644 src/Support/Tia/ChangedFiles.php create mode 100644 src/Support/Tia/Fingerprint.php create mode 100644 src/Support/Tia/Graph.php create mode 100644 src/Support/Tia/Recorder.php create mode 100644 src/TestCaseFilters/TiaTestCaseFilter.php diff --git a/composer.json b/composer.json index 6557a253c..4b2c4e284 100644 --- a/composer.json +++ b/composer.json @@ -123,6 +123,7 @@ "Pest\\Plugins\\Verbose", "Pest\\Plugins\\Version", "Pest\\Plugins\\Shard", + "Pest\\Plugins\\Tia", "Pest\\Plugins\\Parallel" ] }, diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php index 7877b2372..749a6b5de 100644 --- a/src/Bootstrappers/BootSubscribers.php +++ b/src/Bootstrappers/BootSubscribers.php @@ -25,6 +25,8 @@ Subscribers\EnsureIgnorableTestCasesAreIgnored::class, Subscribers\EnsureKernelDumpIsFlushed::class, Subscribers\EnsureTeamCityEnabled::class, + Subscribers\EnsureTiaCoverageIsRecorded::class, + Subscribers\EnsureTiaCoverageIsFlushed::class, ]; /** diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php new file mode 100644 index 000000000..db9d09788 --- /dev/null +++ b/src/Plugins/Tia.php @@ -0,0 +1,629 @@ +.json`. + * - **Worker, replay**: nothing to do; args already narrowed. + * + * Guardrails + * ---------- + * - `--tia` combined with `--coverage` is refused: both paths drive the + * same coverage driver and would corrupt each other's data. + * - If no coverage driver is available during record, we skip gracefully; + * the suite still runs normally. + * - A stale recording SHA (rebase / force-push) triggers a rebuild. + * + * @internal + */ +final class Tia implements AddsOutput, HandlesArguments, Terminable +{ + use Concerns\HandleArguments; + + private const string OPTION = '--tia'; + + private const string REBUILD_OPTION = '--tia-rebuild'; + + private const string CACHE_PATH = '.pest/cache/tia.json'; + + private const string AFFECTED_PATH = '.pest/cache/tia-affected.json'; + + private const string WORKER_CACHE_PREFIX = '.pest/cache/tia-worker-'; + + /** + * Global flag toggled by the parent process so workers know to record. + */ + private const string RECORDING_GLOBAL = 'TIA_RECORDING'; + + /** + * Global flag that tells workers to install the TIA filter (replay mode). + * Workers read the affected set from `.pest/cache/tia-affected.json`. + */ + private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; + + private bool $graphWritten = false; + + public function __construct(private readonly OutputInterface $output) {} + + /** + * {@inheritDoc} + */ + public function handleArguments(array $arguments): array + { + $isWorker = Parallel::isWorker(); + $recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'; + $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1'; + + $enabled = $this->hasArgument(self::OPTION, $arguments); + $forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments); + + if (! $enabled && ! $forceRebuild && ! $recordingGlobal && ! $replayingGlobal) { + return $arguments; + } + + $arguments = $this->popArgument(self::OPTION, $arguments); + $arguments = $this->popArgument(self::REBUILD_OPTION, $arguments); + + if ($this->coverageReportActive()) { + if (! $isWorker) { + $this->output->writeln( + ' TIA `--coverage` is active — TIA disabled to avoid '. + 'conflicting with PHPUnit\'s own coverage collection.', + ); + } + + return $arguments; + } + + $projectRoot = TestSuite::getInstance()->rootPath; + + if ($isWorker) { + return $this->handleWorker($arguments, $projectRoot, $recordingGlobal, $replayingGlobal); + } + + return $this->handleParent($arguments, $projectRoot, $forceRebuild); + } + + public function terminate(): void + { + if ($this->graphWritten) { + return; + } + + $recorder = Recorder::instance(); + + if (! $recorder->isActive()) { + return; + } + + $this->graphWritten = true; + + $projectRoot = TestSuite::getInstance()->rootPath; + $perTest = $recorder->perTestFiles(); + + if ($perTest === []) { + $recorder->reset(); + + return; + } + + if (Parallel::isWorker()) { + $this->flushWorkerPartial($projectRoot, $perTest); + $recorder->reset(); + + return; + } + + // Non-parallel record path: straight into the main cache. + $cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; + + $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); + $graph->setFingerprint(Fingerprint::compute($projectRoot)); + $graph->setRecordedAtSha((new ChangedFiles($projectRoot))->currentSha()); + $graph->replaceEdges($perTest); + $graph->pruneMissingTests(); + + if (! $graph->save($cachePath)) { + $this->output->writeln(' TIA failed to write graph to '.$cachePath); + $recorder->reset(); + + return; + } + + $this->output->writeln(sprintf( + ' TIA graph recorded (%d test files) at %s', + count($perTest), + self::CACHE_PATH, + )); + + $recorder->reset(); + } + + /** + * Runs after paratest finishes in the parent process. If we were + * recording across workers, merge their partial graphs into the main + * cache now. + */ + public function addOutput(int $exitCode): int + { + if (Parallel::isWorker()) { + return $exitCode; + } + + if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') { + return $exitCode; + } + + $projectRoot = TestSuite::getInstance()->rootPath; + $partials = $this->collectWorkerPartials($projectRoot); + + if ($partials === []) { + return $exitCode; + } + + $cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; + + $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); + $graph->setFingerprint(Fingerprint::compute($projectRoot)); + $graph->setRecordedAtSha((new ChangedFiles($projectRoot))->currentSha()); + + $merged = []; + + foreach ($partials as $partialPath) { + $data = $this->readPartial($partialPath); + + if ($data === null) { + continue; + } + + foreach ($data as $testFile => $sources) { + if (! isset($merged[$testFile])) { + $merged[$testFile] = []; + } + + foreach ($sources as $source) { + $merged[$testFile][$source] = true; + } + } + + @unlink($partialPath); + } + + $finalised = []; + + foreach ($merged as $testFile => $sourceSet) { + $finalised[$testFile] = array_keys($sourceSet); + } + + $graph->replaceEdges($finalised); + $graph->pruneMissingTests(); + + if (! $graph->save($cachePath)) { + $this->output->writeln(' TIA failed to write graph to '.$cachePath); + + return $exitCode; + } + + $this->output->writeln(sprintf( + ' TIA graph recorded (%d test files, %d worker partials) at %s', + count($finalised), + count($partials), + self::CACHE_PATH, + )); + + return $exitCode; + } + + /** + * @param array $arguments + * @return array + */ + private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array + { + $cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; + $fingerprint = Fingerprint::compute($projectRoot); + + $graph = $forceRebuild ? null : Graph::load($projectRoot, $cachePath); + + if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) { + $this->output->writeln( + ' TIA environment fingerprint changed — graph will be rebuilt.', + ); + $graph = null; + } + + if ($graph instanceof Graph) { + $changedFiles = new ChangedFiles($projectRoot); + + if ($changedFiles->gitAvailable() + && $graph->recordedAtSha() !== null + && $changedFiles->since($graph->recordedAtSha()) === null) { + $this->output->writeln( + ' TIA recorded commit is no longer reachable — graph will be rebuilt.', + ); + $graph = null; + } + } + + if ($graph instanceof Graph) { + return $this->enterReplayMode($graph, $projectRoot, $arguments); + } + + return $this->enterRecordMode($projectRoot, $arguments); + } + + /** + * @param array $arguments + * @return array + */ + private function handleWorker(array $arguments, string $projectRoot, bool $recordingGlobal, bool $replayingGlobal): array + { + if ($replayingGlobal) { + // Replay in a worker: load the graph and the affected set that + // the parent persisted, then install the per-file filter so + // whichever tests paratest happens to hand this worker are + // accepted / rejected consistently with the series path. + $this->installWorkerReplayFilter($projectRoot); + + return $arguments; + } + + if (! $recordingGlobal) { + return $arguments; + } + + $recorder = Recorder::instance(); + + if (! $recorder->driverAvailable()) { + // Driver availability is per-process. If the driver is missing + // here, silently skip — the parent has already warned during + // its own boot. + return $arguments; + } + + $recorder->activate(); + + return $arguments; + } + + private function installWorkerReplayFilter(string $projectRoot): void + { + $cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; + $affectedPath = $projectRoot.DIRECTORY_SEPARATOR.self::AFFECTED_PATH; + + $graph = Graph::load($projectRoot, $cachePath); + + if (! $graph instanceof Graph) { + return; + } + + $raw = @file_get_contents($affectedPath); + + if ($raw === false) { + return; + } + + $decoded = json_decode($raw, true); + + if (! is_array($decoded)) { + return; + } + + $affectedSet = []; + + foreach ($decoded as $rel) { + if (is_string($rel)) { + $affectedSet[$rel] = true; + } + } + + TestSuite::getInstance()->tests->addTestCaseFilter( + new TiaTestCaseFilter($projectRoot, $graph, $affectedSet), + ); + } + + /** + * @param array $arguments + * @return array + */ + private function enterReplayMode(Graph $graph, string $projectRoot, array $arguments): array + { + $changedFiles = new ChangedFiles($projectRoot); + + if (! $changedFiles->gitAvailable()) { + $this->output->writeln( + ' TIA git unavailable — running full suite.', + ); + + return $arguments; + } + + $changed = $changedFiles->since($graph->recordedAtSha()) ?? []; + + if ($changed === []) { + $this->output->writeln(' TIA no changes detected.'); + + Panic::with(new NoDirtyTestsFound); + } + + $affected = $graph->affected($changed); + + $testSuite = TestSuite::getInstance(); + + 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( + ' TIA %d changed file(s) → %d known test file(s) + any new/unknown tests.', + count($changed), + count($affected), + )); + + return $arguments; + } + + // Parallel mode. Paratest's CLI only accepts a single positional + // ``, so we cannot pass the affected set as multiple args. + // Instead, persist the affected set to a cache file and flip a + // global that tells each worker to install the TIA filter on boot. + // + // Cost trade-off: each worker still discovers the full test tree, + // but the filter drops unaffected tests before they ever run. Narrow + // CLI handoff would be ideal; it requires generating a temporary + // phpunit.xml and is out of scope for the MVP. + if (! $this->persistAffectedSet($projectRoot, $affected)) { + $this->output->writeln( + ' TIA failed to persist affected set — running full suite.', + ); + + return $arguments; + } + + Parallel::setGlobal(self::REPLAYING_GLOBAL, '1'); + + $this->output->writeln(sprintf( + ' TIA %d changed file(s) → %d known test file(s) + any new/unknown tests (parallel).', + count($changed), + count($affected), + )); + + return $arguments; + } + + /** + * @param array $affected Project-relative paths. + */ + private function persistAffectedSet(string $projectRoot, array $affected): bool + { + $path = $projectRoot.DIRECTORY_SEPARATOR.self::AFFECTED_PATH; + $dir = dirname($path); + + if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { + return false; + } + + $json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES); + + if ($json === false) { + return false; + } + + $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; + + if (@file_put_contents($tmp, $json) === false) { + return false; + } + + if (! @rename($tmp, $path)) { + @unlink($tmp); + + return false; + } + + return true; + } + + /** + * @param array $arguments + * @return array + */ + private function enterRecordMode(string $projectRoot, array $arguments): array + { + if (Parallel::isEnabled()) { + // Parent driving `--parallel`: workers will do the actual + // recording. We only advertise the intent through a global. + // Clean up any stale partial files from a previous interrupted + // run so the merge step doesn't confuse itself. + $this->purgeWorkerPartials($projectRoot); + + Parallel::setGlobal(self::RECORDING_GLOBAL, '1'); + + $this->output->writeln( + ' TIA recording dependency graph in parallel (first run) — '. + 'subsequent `--tia` runs will only re-execute affected tests.', + ); + + return $arguments; + } + + $recorder = Recorder::instance(); + + if (! $recorder->driverAvailable()) { + $this->output->writeln( + ' TIA No coverage driver is available. '. + 'Install ext-pcov or enable Xdebug in coverage mode, then rerun with `--tia`.', + ); + + return $arguments; + } + + $recorder->activate(); + + $this->output->writeln(sprintf( + ' TIA recording dependency graph via %s (first run) — '. + 'subsequent `--tia` runs will only re-execute affected tests.', + $recorder->driver(), + )); + + return $arguments; + } + + /** + * @param array> $perTest + */ + private function flushWorkerPartial(string $projectRoot, array $perTest): void + { + $token = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? getmypid(); + // Defensive: token might arrive as int or string depending on paratest + // version. Cast + filter to keep filenames sane. + $token = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $token); + + if ($token === '') { + $token = (string) getmypid(); + } + + $path = $projectRoot.DIRECTORY_SEPARATOR.self::WORKER_CACHE_PREFIX.$token.'.json'; + $dir = dirname($path); + + if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { + return; + } + + $json = json_encode($perTest, JSON_UNESCAPED_SLASHES); + + if ($json === false) { + return; + } + + $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; + + if (@file_put_contents($tmp, $json) === false) { + return; + } + + if (! @rename($tmp, $path)) { + @unlink($tmp); + } + } + + /** + * @return array + */ + private function collectWorkerPartials(string $projectRoot): array + { + $pattern = $projectRoot.DIRECTORY_SEPARATOR.self::WORKER_CACHE_PREFIX.'*.json'; + $matches = glob($pattern); + + return $matches === false ? [] : $matches; + } + + private function purgeWorkerPartials(string $projectRoot): void + { + foreach ($this->collectWorkerPartials($projectRoot) as $path) { + @unlink($path); + } + } + + /** + * @return array>|null + */ + private function readPartial(string $path): ?array + { + $raw = @file_get_contents($path); + + if ($raw === false) { + return null; + } + + $data = json_decode($raw, true); + + if (! is_array($data)) { + return null; + } + + $out = []; + + foreach ($data as $test => $sources) { + if (! is_string($test) || ! is_array($sources)) { + continue; + } + + $clean = []; + + foreach ($sources as $source) { + if (is_string($source)) { + $clean[] = $source; + } + } + + $out[$test] = $clean; + } + + return $out; + } + + private function coverageReportActive(): bool + { + try { + /** @var Coverage $coverage */ + $coverage = Container::getInstance()->get(Coverage::class); + } catch (Throwable) { + return false; + } + + return property_exists($coverage, 'coverage') && $coverage->coverage === true; + } +} diff --git a/src/Subscribers/EnsureTiaCoverageIsFlushed.php b/src/Subscribers/EnsureTiaCoverageIsFlushed.php new file mode 100644 index 000000000..0ccdddc15 --- /dev/null +++ b/src/Subscribers/EnsureTiaCoverageIsFlushed.php @@ -0,0 +1,23 @@ +endTest(); + } +} diff --git a/src/Subscribers/EnsureTiaCoverageIsRecorded.php b/src/Subscribers/EnsureTiaCoverageIsRecorded.php new file mode 100644 index 000000000..6be7bdd74 --- /dev/null +++ b/src/Subscribers/EnsureTiaCoverageIsRecorded.php @@ -0,0 +1,36 @@ +isActive()) { + return; + } + + $test = $event->test(); + + if (! $test instanceof TestMethod) { + return; + } + + $recorder->beginTest($test->className(), $test->methodName(), $test->file()); + } +} diff --git a/src/Support/Tia/ChangedFiles.php b/src/Support/Tia/ChangedFiles.php new file mode 100644 index 000000000..8249d9ddf --- /dev/null +++ b/src/Support/Tia/ChangedFiles.php @@ -0,0 +1,219 @@ +..HEAD` captures committed + * changes on top of the recording point. + * 2. `git status --short` captures unstaged + staged + untracked changes on + * top of that. + * + * We return relative paths to the project root. Deletions are included so the + * caller can decide whether to invalidate: a deleted source file may still + * appear in the graph and should mark its dependents as affected. + * + * @internal + */ +final readonly class ChangedFiles +{ + public function __construct(private string $projectRoot) {} + + /** + * @return array|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. + */ + public function since(?string $sha): ?array + { + if (! $this->gitAvailable()) { + return null; + } + + $files = []; + + if ($sha !== null && $sha !== '') { + if (! $this->shaIsReachable($sha)) { + return null; + } + + $files = array_merge($files, $this->diffSinceSha($sha)); + } + + $files = array_merge($files, $this->workingTreeChanges()); + + // Normalise + dedupe, filtering out paths that can never belong to the + // graph: vendor (caught by the fingerprint instead), cache dirs, and + // anything starting with a dot we don't care about. + $unique = []; + + foreach ($files as $file) { + if ($file === '') { + continue; + } + if ($this->shouldIgnore($file)) { + continue; + } + $unique[$file] = true; + } + + return array_keys($unique); + } + + private function shouldIgnore(string $path): bool + { + static $prefixes = [ + '.pest/', + '.phpunit.cache/', + '.phpunit.result.cache', + 'vendor/', + 'node_modules/', + ]; + + foreach ($prefixes as $prefix) { + if (str_starts_with($path, (string) $prefix)) { + return true; + } + } + + return false; + } + + public function gitAvailable(): bool + { + $process = new Process(['git', 'rev-parse', '--git-dir'], $this->projectRoot); + $process->run(); + + return $process->isSuccessful(); + } + + private function shaIsReachable(string $sha): bool + { + $process = new Process( + ['git', 'merge-base', '--is-ancestor', $sha, 'HEAD'], + $this->projectRoot, + ); + $process->run(); + + // Exit 0 → ancestor; 1 → not ancestor; anything else → git error + // (e.g. unknown commit after a rebase/gc). Treat non-zero as + // "unreachable" and force a rebuild. + return $process->getExitCode() === 0; + } + + /** + * @return array + */ + private function diffSinceSha(string $sha): array + { + $process = new Process( + ['git', 'diff', '--name-only', $sha.'..HEAD'], + $this->projectRoot, + ); + $process->run(); + + if (! $process->isSuccessful()) { + return []; + } + + return $this->splitLines($process->getOutput()); + } + + /** + * @return array + */ + private function workingTreeChanges(): array + { + // `-z` produces NUL-terminated records with no path quoting, so paths + // that contain spaces, tabs, unicode or other special characters + // are passed through verbatim. Without `-z`, git wraps such paths in + // quotes with backslash escapes, which would corrupt our lookup keys. + // + // Record format: `XY ` for most entries, and + // `R ` for renames/copies (two NUL-separated + // fields). + $process = new Process( + ['git', 'status', '--porcelain', '-z', '--untracked-files=all'], + $this->projectRoot, + ); + $process->run(); + + if (! $process->isSuccessful()) { + return []; + } + + $output = $process->getOutput(); + + if ($output === '') { + return []; + } + + $records = explode("\x00", rtrim($output, "\x00")); + $files = []; + $count = count($records); + + for ($i = 0; $i < $count; $i++) { + $record = $records[$i]; + + if (strlen($record) < 4) { + continue; + } + + $status = substr($record, 0, 2); + $path = substr($record, 3); + + // Renames/copies emit two records: the new path first, then the + // original. Consume both. + if ($status[0] === 'R' || $status[0] === 'C') { + $files[] = $path; + + if (isset($records[$i + 1]) && $records[$i + 1] !== '') { + $files[] = $records[$i + 1]; + $i++; + } + + continue; + } + + $files[] = $path; + } + + return $files; + } + + public function currentSha(): ?string + { + if (! $this->gitAvailable()) { + return null; + } + + $process = new Process(['git', 'rev-parse', 'HEAD'], $this->projectRoot); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + $sha = trim($process->getOutput()); + + return $sha === '' ? null : $sha; + } + + /** + * @return array + */ + private function splitLines(string $output): array + { + $lines = preg_split('/\R+/', trim($output), flags: PREG_SPLIT_NO_EMPTY); + + return $lines === false ? [] : $lines; + } +} diff --git a/src/Support/Tia/Fingerprint.php b/src/Support/Tia/Fingerprint.php new file mode 100644 index 000000000..fe2cb9f04 --- /dev/null +++ b/src/Support/Tia/Fingerprint.php @@ -0,0 +1,95 @@ + self::SCHEMA_VERSION, + 'php' => PHP_VERSION, + 'pest' => self::readPestVersion($projectRoot), + 'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'), + 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), + 'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), + 'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'), + // Pest's generated classes bake the code-generation logic in — if + // TestCaseFactory changes (new attribute, different method + // signature, etc.) every previously-recorded edge is stale. + // Hashing the factory sources makes path-repo / dev-main installs + // automatically rebuild their graphs when Pest itself is edited. + 'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'), + 'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'), + ]; + } + + /** + * @param array $a + * @param array $b + */ + public static function matches(array $a, array $b): bool + { + ksort($a); + ksort($b); + + return $a === $b; + } + + private static function hashIfExists(string $path): ?string + { + if (! is_file($path)) { + return null; + } + + $hash = @hash_file('xxh128', $path); + + return $hash === false ? null : $hash; + } + + private static function readPestVersion(string $projectRoot): string + { + $installed = $projectRoot.'/vendor/composer/installed.json'; + + if (! is_file($installed)) { + return 'unknown'; + } + + $raw = @file_get_contents($installed); + + if ($raw === false) { + return 'unknown'; + } + + $data = json_decode($raw, true); + + if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) { + return 'unknown'; + } + + foreach ($data['packages'] as $package) { + if (is_array($package) && ($package['name'] ?? null) === 'pestphp/pest') { + return (string) ($package['version'] ?? 'unknown'); + } + } + + return 'unknown'; + } +} diff --git a/src/Support/Tia/Graph.php b/src/Support/Tia/Graph.php new file mode 100644 index 000000000..91d7f6fc7 --- /dev/null +++ b/src/Support/Tia/Graph.php @@ -0,0 +1,320 @@ +` so that subsequent runs + * can skip tests whose dependencies have not changed. Paths are stored relative + * to the project root and source files are deduplicated via an index so that + * the on-disk JSON stays compact for large suites. + * + * @internal + */ +final class Graph +{ + /** + * Relative path of each known source file, indexed by numeric id. + * + * @var array + */ + private array $files = []; + + /** + * Reverse lookup: source file → numeric id. + * + * @var array + */ + private array $fileIds = []; + + /** + * Edges: test file (relative) → list of source file ids. + * + * @var array> + */ + private array $edges = []; + + /** + * Environment fingerprint captured at record time. + * + * @var array + */ + private array $fingerprint = []; + + /** + * Commit SHA the graph was recorded against (if in a git repo). + */ + private ?string $recordedAtSha = null; + + /** + * Canonicalised project root. Resolved through `realpath()` so paths + * captured by coverage drivers (always real filesystem targets) match + * regardless of whether the user's CWD is a symlink or has trailing + * separators. + */ + private readonly string $projectRoot; + + public function __construct(string $projectRoot) + { + $real = @realpath($projectRoot); + + $this->projectRoot = $real !== false ? $real : $projectRoot; + } + + /** + * Records that a test file depends on the given source file. + */ + public function link(string $testFile, string $sourceFile): void + { + $testRel = $this->relative($testFile); + $sourceRel = $this->relative($sourceFile); + + if ($sourceRel === null || $testRel === null) { + return; + } + + if (! isset($this->fileIds[$sourceRel])) { + $id = count($this->files); + $this->files[$id] = $sourceRel; + $this->fileIds[$sourceRel] = $id; + } + + $this->edges[$testRel][] = $this->fileIds[$sourceRel]; + } + + /** + * Returns the set of test files whose dependencies intersect $changedFiles. + * + * @param array $changedFiles Absolute or relative paths. + * @return array Relative test file paths. + */ + public function affected(array $changedFiles): array + { + $changedIds = []; + + foreach ($changedFiles as $file) { + $rel = $this->relative($file); + + if ($rel === null) { + continue; + } + + if (isset($this->fileIds[$rel])) { + $changedIds[$this->fileIds[$rel]] = true; + } + } + + $affected = []; + + foreach ($this->edges as $testFile => $ids) { + foreach ($ids as $id) { + if (isset($changedIds[$id])) { + $affected[] = $testFile; + + break; + } + } + } + + return $affected; + } + + /** + * Returns `true` if the given test file has any recorded dependencies. + */ + public function knowsTest(string $testFile): bool + { + $rel = $this->relative($testFile); + + return $rel !== null && isset($this->edges[$rel]); + } + + public function setFingerprint(array $fingerprint): void + { + $this->fingerprint = $fingerprint; + } + + public function fingerprint(): array + { + return $this->fingerprint; + } + + public function setRecordedAtSha(?string $sha): void + { + $this->recordedAtSha = $sha; + } + + public function recordedAtSha(): ?string + { + return $this->recordedAtSha; + } + + /** + * Replaces edges for the given test files. Used during a partial record + * run so that existing edges for other tests are preserved. + * + * @param array> $testToFiles + */ + public function replaceEdges(array $testToFiles): void + { + foreach ($testToFiles as $testFile => $sources) { + $testRel = $this->relative($testFile); + + if ($testRel === null) { + continue; + } + + $this->edges[$testRel] = []; + + foreach ($sources as $source) { + $this->link($testFile, $source); + } + + // Deduplicate ids for this test. + $this->edges[$testRel] = array_values(array_unique($this->edges[$testRel])); + } + } + + /** + * Drops edges whose test file no longer exists on disk. Prevents the graph + * from keeping stale entries for deleted / renamed tests that would later + * be flagged as affected and confuse PHPUnit's discovery. + */ + public function pruneMissingTests(): void + { + $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + foreach (array_keys($this->edges) as $testRel) { + if (! is_file($root.$testRel)) { + unset($this->edges[$testRel]); + } + } + } + + public static function load(string $projectRoot, string $path): ?self + { + if (! is_file($path)) { + return null; + } + + $raw = @file_get_contents($path); + + if ($raw === false) { + return null; + } + + $data = json_decode($raw, true); + + if (! is_array($data) || ($data['schema'] ?? null) !== 1) { + return null; + } + + $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'] : []; + + return $graph; + } + + public function save(string $path): bool + { + $dir = dirname($path); + + if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { + return false; + } + + $payload = [ + 'schema' => 1, + 'fingerprint' => $this->fingerprint, + 'recorded_at_sha' => $this->recordedAtSha, + 'files' => $this->files, + 'edges' => $this->edges, + ]; + + $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; + + $json = json_encode($payload, JSON_UNESCAPED_SLASHES); + + if ($json === false) { + return false; + } + + if (@file_put_contents($tmp, $json) === false) { + return false; + } + + if (! @rename($tmp, $path)) { + @unlink($tmp); + + return false; + } + + return true; + } + + /** + * Normalises a path to be relative to the project root; returns `null` for + * paths we should ignore (outside the project, unknown, virtual, vendor). + * + * Accepts both absolute paths (from Xdebug/PCOV coverage) and + * project-relative paths (from `git diff`) — we normalise without relying + * on `realpath()` of relative paths because the current working directory + * is not guaranteed to be the project root. + */ + private function relative(string $path): ?string + { + if ($path === '' || $path === 'unknown') { + return null; + } + + if (str_contains($path, "eval()'d")) { + return null; + } + + $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + $isAbsolute = str_starts_with($path, DIRECTORY_SEPARATOR) + || (strlen($path) >= 2 && $path[1] === ':'); // Windows drive + + if ($isAbsolute) { + $real = @realpath($path); + + if ($real === false) { + $real = $path; + } + + if (! str_starts_with($real, $root)) { + return null; + } + + // Always normalise to forward slashes. Windows' native separator + // would otherwise produce keys that never match paths reported + // by `git` (which always uses forward slashes). + $relative = str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); + } else { + // Normalise directory separators and strip any "./" prefix. + $relative = str_replace(DIRECTORY_SEPARATOR, '/', $path); + + while (str_starts_with($relative, './')) { + $relative = substr($relative, 2); + } + } + + // Vendor packages are pinned by composer.lock. Any upgrade bumps the + // fingerprint and invalidates the graph wholesale, so there is no + // reason to track individual vendor files — doing so inflates the + // graph by orders of magnitude on Laravel-style projects. + if (str_starts_with($relative, 'vendor/')) { + return null; + } + + return $relative; + } +} diff --git a/src/Support/Tia/Recorder.php b/src/Support/Tia/Recorder.php new file mode 100644 index 000000000..e18508b38 --- /dev/null +++ b/src/Support/Tia/Recorder.php @@ -0,0 +1,237 @@ +. + * + * @var array> + */ + private array $perTestFiles = []; + + /** + * Cached class → test file resolution. + * + * @var array + */ + private array $classFileCache = []; + + private bool $active = false; + + private bool $driverChecked = false; + + private bool $driverAvailable = false; + + private string $driver = 'none'; + + public static function instance(): self + { + return self::$instance ??= new self; + } + + public function activate(): void + { + $this->active = true; + } + + public function isActive(): bool + { + return $this->active; + } + + public function driverAvailable(): bool + { + if (! $this->driverChecked) { + if (function_exists('pcov\\start')) { + $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(); + + if ($ok === null || $ok) { + @\xdebug_stop_code_coverage(false); + $this->driver = 'xdebug'; + $this->driverAvailable = true; + } + } + + $this->driverChecked = true; + } + + return $this->driverAvailable; + } + + public function driver(): string + { + $this->driverAvailable(); + + return $this->driver; + } + + public function beginTest(string $className, string $methodName, string $fallbackFile): void + { + if (! $this->active || ! $this->driverAvailable()) { + return; + } + + $file = $this->resolveTestFile($className, $fallbackFile); + + if ($file === null) { + return; + } + + $this->currentTestFile = $file; + + if ($this->driver === 'pcov') { + \pcov\clear(); + \pcov\start(); + + return; + } + + // Xdebug + \xdebug_start_code_coverage(); + } + + public function endTest(): void + { + if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) { + return; + } + + if ($this->driver === 'pcov') { + \pcov\stop(); + /** @var array $data */ + $data = \pcov\collect(\pcov\inclusive); + } else { + /** @var array $data */ + $data = \xdebug_get_code_coverage(); + // `true` resets Xdebug's internal buffer so the next `start()` + // does not accumulate earlier tests' coverage into the current + // one — otherwise the graph becomes progressively polluted. + \xdebug_stop_code_coverage(true); + } + + foreach (array_keys($data) as $sourceFile) { + $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; + } + + $this->currentTestFile = null; + } + + /** + * @return array> absolute test file → list of absolute source files. + */ + public function perTestFiles(): array + { + $out = []; + + foreach ($this->perTestFiles as $testFile => $sources) { + $out[$testFile] = array_keys($sources); + } + + return $out; + } + + private function resolveTestFile(string $className, string $fallbackFile): ?string + { + if (array_key_exists($className, $this->classFileCache)) { + $file = $this->classFileCache[$className]; + } else { + $file = $this->readPestFilename($className); + $this->classFileCache[$className] = $file; + } + + if ($file !== null) { + return $file; + } + + if ($fallbackFile !== '' && $fallbackFile !== 'unknown' && ! str_contains($fallbackFile, "eval()'d")) { + return $fallbackFile; + } + + return null; + } + + /** + * Resolves the file that *defines* the test class. + * + * Order of preference: + * 1. Pest's generated `$__filename` static — the original `*.php` file + * containing the `test()` calls (the eval'd class itself has no file). + * 2. `ReflectionClass::getFileName()` — the concrete class's file. This + * is intentionally more specific than `ReflectionMethod::getFileName()` + * (which would return the *trait* file for methods brought in via + * `uses SharedTestBehavior`). + */ + private function readPestFilename(string $className): ?string + { + if (! class_exists($className, false)) { + return null; + } + + try { + $reflection = new ReflectionClass($className); + } catch (ReflectionException) { + return null; + } + + if ($reflection->hasProperty('__filename')) { + try { + $property = $reflection->getProperty('__filename'); + + if ($property->isStatic()) { + $value = $property->getValue(); + + if (is_string($value) && $value !== '') { + return $value; + } + } + } catch (ReflectionException) { + // fall through to getFileName() + } + } + + $file = $reflection->getFileName(); + + return $file !== false && $file !== '' ? $file : null; + } + + /** + * Clears all captured state. Useful for long-running hosts (daemons, + * PHP-FPM, watchers) that invoke Pest multiple times in a single process + * — without this, coverage from run N would bleed into run N+1. + */ + public function reset(): void + { + $this->currentTestFile = null; + $this->perTestFiles = []; + $this->classFileCache = []; + $this->active = false; + } +} diff --git a/src/TestCaseFilters/TiaTestCaseFilter.php b/src/TestCaseFilters/TiaTestCaseFilter.php new file mode 100644 index 000000000..7838f3460 --- /dev/null +++ b/src/TestCaseFilters/TiaTestCaseFilter.php @@ -0,0 +1,65 @@ + $affectedTestFiles Keys are project-relative test file paths. + */ + public function __construct( + private string $projectRoot, + private Graph $graph, + private array $affectedTestFiles, + ) {} + + public function accept(string $testCaseFilename): bool + { + $rel = $this->relative($testCaseFilename); + + if ($rel === null) { + return true; + } + + if (! $this->graph->knowsTest($rel)) { + return true; + } + + return isset($this->affectedTestFiles[$rel]); + } + + private function relative(string $path): ?string + { + $real = @realpath($path); + + if ($real === false) { + $real = $path; + } + + $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; + + if (! str_starts_with($real, $root)) { + return null; + } + + return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); + } +} diff --git a/tests/Arch.php b/tests/Arch.php index d0565216f..3eca267a8 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -7,6 +7,9 @@ 'debug_backtrace', 'var_export', 'xdebug_info', + 'xdebug_start_code_coverage', + 'xdebug_stop_code_coverage', + 'xdebug_get_code_coverage', ]); arch()->preset()->strict()->ignoring([ From 4b9bb77b5434248fdf0a3294609cfe95c9f793d9 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 16 Apr 2026 06:17:14 -0700 Subject: [PATCH 002/160] feat(tia): adds poc --- src/Concerns/Testable.php | 29 ++++ src/Plugins/Tia.php | 107 +++++++++--- src/{Support => Plugins}/Tia/ChangedFiles.php | 2 +- src/{Support => Plugins}/Tia/Fingerprint.php | 2 +- src/{Support => Plugins}/Tia/Graph.php | 10 +- src/{Support => Plugins}/Tia/Recorder.php | 2 +- src/Plugins/Tia/State.php | 158 ++++++++++++++++++ .../Tia}/TiaTestCaseFilter.php | 4 +- .../EnsureTiaCoverageIsFlushed.php | 2 +- .../EnsureTiaCoverageIsRecorded.php | 2 +- 10 files changed, 289 insertions(+), 29 deletions(-) rename src/{Support => Plugins}/Tia/ChangedFiles.php (99%) rename src/{Support => Plugins}/Tia/Fingerprint.php (99%) rename src/{Support => Plugins}/Tia/Graph.php (97%) rename src/{Support => Plugins}/Tia/Recorder.php (99%) create mode 100644 src/Plugins/Tia/State.php rename src/{TestCaseFilters => Plugins/Tia}/TiaTestCaseFilter.php (96%) diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 3f7e3b779..d6428c53c 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -12,6 +12,7 @@ use Pest\Support\ExceptionTrace; use Pest\Support\Reflection; use Pest\Support\Shell; +use Pest\Plugins\Tia\State as TiaState; use Pest\TestSuite; use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\IncompleteTest; @@ -227,6 +228,16 @@ protected function setUp(...$arguments): void { TestSuite::getInstance()->test = $this; + // TIA replay fast-path. When the file is known to the dependency graph + // and none of its deps changed since recording, skip both the + // framework `setUp()` (Laravel app bootstrap, DB refresh, etc.) and + // the user `beforeEach` chain. The matching short-circuit inside + // `__runTest()` ensures the test body never executes, so no + // initialisation is needed. + if (TiaState::instance()->shouldReplayFromCache(self::$__filename, $this::class.'::'.$this->name())) { + return; + } + $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); $description = $method->description; @@ -302,6 +313,15 @@ public function __initializeTestCase(): void */ protected function tearDown(...$arguments): void { + // TIA replay: setUp was skipped, the closure never ran — there is + // no matching cleanup to perform here. Keep the framework invariant + // of clearing the "current test" pointer and bail out. + if (TiaState::instance()->shouldReplayFromCache(self::$__filename, $this::class.'::'.$this->name())) { + TestSuite::getInstance()->test = null; + + return; + } + $afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename); if ($this->__afterEach instanceof Closure) { @@ -327,6 +347,15 @@ protected function tearDown(...$arguments): void */ private function __runTest(Closure $closure, ...$args): mixed { + // TIA replay: the file's deps haven't changed and it last passed. + // Bypass the closure entirely and register a synthetic assertion so + // PHPUnit does not emit a "risky: no assertions" warning. + if (TiaState::instance()->shouldReplayFromCache(self::$__filename, $this::class.'::'.$this->name())) { + $this->addToAssertionCount(1); + + return null; + } + $arguments = $this->__resolveTestArguments($args); $this->__ensureDatasetArgumentNameAndNumberMatches($arguments); diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index db9d09788..81e820d6a 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -10,11 +10,11 @@ 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\TestCaseFilters\TiaTestCaseFilter; +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\TestSuite; use Symfony\Component\Console\Output\OutputInterface; use Throwable; @@ -363,11 +363,73 @@ private function installWorkerReplayFilter(string $projectRoot): void } } - TestSuite::getInstance()->tests->addTestCaseFilter( - new TiaTestCaseFilter($projectRoot, $graph, $affectedSet), + State::instance()->activate( + $projectRoot, + $graph, + $affectedSet, + $this->loadPreviousDefects($projectRoot), ); } + /** + * 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 + */ + private function loadPreviousDefects(string $projectRoot): array + { + // PHPUnit writes the cache under either `/.phpunit.result.cache` + // (legacy) or `/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 $arguments * @return array @@ -386,28 +448,31 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum $changed = $changedFiles->since($graph->recordedAtSha()) ?? []; - if ($changed === []) { - $this->output->writeln(' TIA no changes detected.'); - - Panic::with(new NoDirtyTestsFound); - } - - $affected = $graph->affected($changed); + // 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(); 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. + // Series mode: activate replay state. Tests still appear in the + // run (correct counts, coverage aggregation, event timeline); + // unaffected ones short-circuit inside `Testable::__runTest` + // and replay their previous passing status. $affectedSet = array_fill_keys($affected, true); - $testSuite->tests->addTestCaseFilter( - new TiaTestCaseFilter($projectRoot, $graph, $affectedSet), + State::instance()->activate( + $projectRoot, + $graph, + $affectedSet, + $this->loadPreviousDefects($projectRoot), ); $this->output->writeln(sprintf( - ' TIA %d changed file(s) → %d known test file(s) + any new/unknown tests.', + ' TIA %d changed file(s) → %d affected, remaining tests replay cached result.', count($changed), count($affected), )); @@ -435,7 +500,7 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum Parallel::setGlobal(self::REPLAYING_GLOBAL, '1'); $this->output->writeln(sprintf( - ' TIA %d changed file(s) → %d known test file(s) + any new/unknown tests (parallel).', + ' TIA %d changed file(s) → %d affected, remaining tests replay cached result (parallel).', count($changed), count($affected), )); diff --git a/src/Support/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php similarity index 99% rename from src/Support/Tia/ChangedFiles.php rename to src/Plugins/Tia/ChangedFiles.php index 8249d9ddf..d2a568aa8 100644 --- a/src/Support/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pest\Support\Tia; +namespace Pest\Plugins\Tia; use Symfony\Component\Process\Process; diff --git a/src/Support/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php similarity index 99% rename from src/Support/Tia/Fingerprint.php rename to src/Plugins/Tia/Fingerprint.php index fe2cb9f04..39561468f 100644 --- a/src/Support/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -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. diff --git a/src/Support/Tia/Graph.php b/src/Plugins/Tia/Graph.php similarity index 97% rename from src/Support/Tia/Graph.php rename to src/Plugins/Tia/Graph.php index 91d7f6fc7..6fd9a3bf7 100644 --- a/src/Support/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pest\Support\Tia; +namespace Pest\Plugins\Tia; /** * File-level Test Impact Analysis graph. @@ -132,6 +132,14 @@ public function knowsTest(string $testFile): bool return $rel !== null && isset($this->edges[$rel]); } + /** + * @return array All project-relative test files the graph knows. + */ + public function allTestFiles(): array + { + return array_keys($this->edges); + } + public function setFingerprint(array $fingerprint): void { $this->fingerprint = $fingerprint; diff --git a/src/Support/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php similarity index 99% rename from src/Support/Tia/Recorder.php rename to src/Plugins/Tia/Recorder.php index e18508b38..bdb7909ca 100644 --- a/src/Support/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pest\Support\Tia; +namespace Pest\Plugins\Tia; use ReflectionClass; use ReflectionException; diff --git a/src/Plugins/Tia/State.php b/src/Plugins/Tia/State.php new file mode 100644 index 000000000..9fee2dbdd --- /dev/null +++ b/src/Plugins/Tia/State.php @@ -0,0 +1,158 @@ + + */ + private array $affectedFiles = []; + + /** + * Keys are project-relative test file paths. Known = recorded in graph. + * + * @var array + */ + private array $knownFiles = []; + + /** + * Test ids (class::method) that were in the previous run's defect list. + * + * @var array + */ + 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 $affectedFiles + */ + 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))); + } +} diff --git a/src/TestCaseFilters/TiaTestCaseFilter.php b/src/Plugins/Tia/TiaTestCaseFilter.php similarity index 96% rename from src/TestCaseFilters/TiaTestCaseFilter.php rename to src/Plugins/Tia/TiaTestCaseFilter.php index 7838f3460..d210b4beb 100644 --- a/src/TestCaseFilters/TiaTestCaseFilter.php +++ b/src/Plugins/Tia/TiaTestCaseFilter.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Pest\TestCaseFilters; +namespace Pest\Plugins\Tia; use Pest\Contracts\TestCaseFilter; -use Pest\Support\Tia\Graph; +use Pest\Plugins\Tia\Graph; /** * Accepts a test file in one of three cases: diff --git a/src/Subscribers/EnsureTiaCoverageIsFlushed.php b/src/Subscribers/EnsureTiaCoverageIsFlushed.php index 0ccdddc15..1b3e8fa06 100644 --- a/src/Subscribers/EnsureTiaCoverageIsFlushed.php +++ b/src/Subscribers/EnsureTiaCoverageIsFlushed.php @@ -4,7 +4,7 @@ namespace Pest\Subscribers; -use Pest\Support\Tia\Recorder; +use Pest\Plugins\Tia\Recorder; use PHPUnit\Event\Test\Finished; use PHPUnit\Event\Test\FinishedSubscriber; diff --git a/src/Subscribers/EnsureTiaCoverageIsRecorded.php b/src/Subscribers/EnsureTiaCoverageIsRecorded.php index 6be7bdd74..0d388c988 100644 --- a/src/Subscribers/EnsureTiaCoverageIsRecorded.php +++ b/src/Subscribers/EnsureTiaCoverageIsRecorded.php @@ -4,7 +4,7 @@ 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; From 1d81069a2a05a426ace97890118b8ee74d82531d Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 16 Apr 2026 06:32:24 -0700 Subject: [PATCH 003/160] feat(tia): continues to work on poc --- src/Concerns/Testable.php | 42 ++++++++++++------------ src/Contracts/Plugins/AfterEachable.php | 20 +++++++++++ src/Contracts/Plugins/BeforeEachable.php | 26 +++++++++++++++ src/Contracts/Plugins/Runnable.php | 21 ++++++++++++ src/Plugins/Tia.php | 20 ++++++++++- 5 files changed, 107 insertions(+), 22 deletions(-) create mode 100644 src/Contracts/Plugins/AfterEachable.php create mode 100644 src/Contracts/Plugins/BeforeEachable.php create mode 100644 src/Contracts/Plugins/Runnable.php diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index d6428c53c..a7fe07961 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -6,13 +6,16 @@ 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; use Pest\Support\Reflection; use Pest\Support\Shell; -use Pest\Plugins\Tia\State as TiaState; use Pest\TestSuite; use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\IncompleteTest; @@ -228,14 +231,11 @@ protected function setUp(...$arguments): void { TestSuite::getInstance()->test = $this; - // TIA replay fast-path. When the file is known to the dependency graph - // and none of its deps changed since recording, skip both the - // framework `setUp()` (Laravel app bootstrap, DB refresh, etc.) and - // the user `beforeEach` chain. The matching short-circuit inside - // `__runTest()` ensures the test body never executes, so no - // initialisation is needed. - if (TiaState::instance()->shouldReplayFromCache(self::$__filename, $this::class.'::'.$this->name())) { - return; + /** @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()); @@ -313,13 +313,13 @@ public function __initializeTestCase(): void */ protected function tearDown(...$arguments): void { - // TIA replay: setUp was skipped, the closure never ran — there is - // no matching cleanup to perform here. Keep the framework invariant - // of clearing the "current test" pointer and bail out. - if (TiaState::instance()->shouldReplayFromCache(self::$__filename, $this::class.'::'.$this->name())) { - TestSuite::getInstance()->test = null; + /** @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; + return; + } } $afterEach = TestSuite::getInstance()->afterEach->get(self::$__filename); @@ -347,13 +347,13 @@ protected function tearDown(...$arguments): void */ private function __runTest(Closure $closure, ...$args): mixed { - // TIA replay: the file's deps haven't changed and it last passed. - // Bypass the closure entirely and register a synthetic assertion so - // PHPUnit does not emit a "risky: no assertions" warning. - if (TiaState::instance()->shouldReplayFromCache(self::$__filename, $this::class.'::'.$this->name())) { - $this->addToAssertionCount(1); + /** @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; + return null; + } } $arguments = $this->__resolveTestArguments($args); diff --git a/src/Contracts/Plugins/AfterEachable.php b/src/Contracts/Plugins/AfterEachable.php new file mode 100644 index 000000000..c0c4a257d --- /dev/null +++ b/src/Contracts/Plugins/AfterEachable.php @@ -0,0 +1,20 @@ +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) { From 184f5d2742186a850df8ee3777d2b9a4c9e2464e Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 16 Apr 2026 06:59:59 -0700 Subject: [PATCH 004/160] feat(tia): continues to work on poc --- src/Configuration.php | 8 + src/Plugins/Tia.php | 5 + src/Plugins/Tia/Configuration.php | 38 ++++ src/Plugins/Tia/Graph.php | 32 ++- src/Plugins/Tia/WatchDefaults/Browser.php | 118 +++++++++++ src/Plugins/Tia/WatchDefaults/Inertia.php | 53 +++++ src/Plugins/Tia/WatchDefaults/Laravel.php | 81 ++++++++ src/Plugins/Tia/WatchDefaults/Livewire.php | 38 ++++ src/Plugins/Tia/WatchDefaults/Php.php | 53 +++++ src/Plugins/Tia/WatchDefaults/Symfony.php | 75 +++++++ .../Tia/WatchDefaults/WatchDefault.php | 28 +++ src/Plugins/Tia/WatchPatterns.php | 194 ++++++++++++++++++ 12 files changed, 720 insertions(+), 3 deletions(-) create mode 100644 src/Plugins/Tia/Configuration.php create mode 100644 src/Plugins/Tia/WatchDefaults/Browser.php create mode 100644 src/Plugins/Tia/WatchDefaults/Inertia.php create mode 100644 src/Plugins/Tia/WatchDefaults/Laravel.php create mode 100644 src/Plugins/Tia/WatchDefaults/Livewire.php create mode 100644 src/Plugins/Tia/WatchDefaults/Php.php create mode 100644 src/Plugins/Tia/WatchDefaults/Symfony.php create mode 100644 src/Plugins/Tia/WatchDefaults/WatchDefault.php create mode 100644 src/Plugins/Tia/WatchPatterns.php diff --git a/src/Configuration.php b/src/Configuration.php index 4261f3ef3..a37871eef 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -119,6 +119,14 @@ public function browser(): Browser\Configuration return new Browser\Configuration; } + /** + * Gets the TIA (Test Impact Analysis) configuration. + */ + public function tia(): Plugins\Tia\Configuration + { + return new Plugins\Tia\Configuration; + } + /** * Proxies calls to the uses method. * diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 33070b0be..0df1bfdff 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -284,6 +284,11 @@ public function addOutput(int $exitCode): int */ private function handleParent(array $arguments, string $projectRoot, bool $forceRebuild): array { + // 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); + $cachePath = $projectRoot.DIRECTORY_SEPARATOR.self::CACHE_PATH; $fingerprint = Fingerprint::compute($projectRoot); diff --git a/src/Plugins/Tia/Configuration.php b/src/Plugins/Tia/Configuration.php new file mode 100644 index 000000000..d711e5611 --- /dev/null +++ b/src/Plugins/Tia/Configuration.php @@ -0,0 +1,38 @@ +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 $patterns glob → project-relative test dir + * @return $this + */ + public function watch(array $patterns): self + { + WatchPatterns::instance()->add($patterns); + + return $this; + } +} diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 6fd9a3bf7..08d46e08c 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -88,11 +88,18 @@ public function link(string $testFile, string $sourceFile): void /** * 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 $changedFiles Absolute or relative paths. * @return array Relative test file paths. */ public function affected(array $changedFiles): array { + // 1. Coverage-edge lookup (PHP → PHP). $changedIds = []; foreach ($changedFiles as $file) { @@ -107,19 +114,38 @@ public function affected(array $changedFiles): array } } - $affected = []; + $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). + $watchPatterns = WatchPatterns::instance(); + $normalised = []; + + foreach ($changedFiles as $file) { + $rel = $this->relative($file); + + if ($rel !== null) { + $normalised[] = $rel; + } + } + + $dirs = $watchPatterns->matchedDirectories($this->projectRoot, $normalised); + $allTestFiles = array_keys($this->edges); + + foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) { + $affectedSet[$testFile] = true; + } + + return array_keys($affectedSet); } /** diff --git a/src/Plugins/Tia/WatchDefaults/Browser.php b/src/Plugins/Tia/WatchDefaults/Browser.php new file mode 100644 index 000000000..2c712f198 --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Browser.php @@ -0,0 +1,118 @@ +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 + */ + 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(\Pest\Browser\Support\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 (\Pest\Browser\Support\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))); + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Inertia.php b/src/Plugins/Tia/WatchDefaults/Inertia.php new file mode 100644 index 000000000..9f0e32f19 --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Inertia.php @@ -0,0 +1,53 @@ + [$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], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Laravel.php b/src/Plugins/Tia/WatchDefaults/Laravel.php new file mode 100644 index 000000000..3810c0cfe --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Laravel.php @@ -0,0 +1,81 @@ + [$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], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Livewire.php b/src/Plugins/Tia/WatchDefaults/Livewire.php new file mode 100644 index 000000000..3a37c487b --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Livewire.php @@ -0,0 +1,38 @@ + [$testPath], + 'resources/views/components/**/*.blade.php' => [$testPath], + + // Livewire JS interop / Alpine plugins. + 'resources/js/**/*.js' => [$testPath], + 'resources/js/**/*.ts' => [$testPath], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Php.php b/src/Plugins/Tia/WatchDefaults/Php.php new file mode 100644 index 000000000..389966cc6 --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Php.php @@ -0,0 +1,53 @@ + [$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], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/Symfony.php b/src/Plugins/Tia/WatchDefaults/Symfony.php new file mode 100644 index 000000000..a3d4b0b3e --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/Symfony.php @@ -0,0 +1,75 @@ + [$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], + ]; + } +} diff --git a/src/Plugins/Tia/WatchDefaults/WatchDefault.php b/src/Plugins/Tia/WatchDefaults/WatchDefault.php new file mode 100644 index 000000000..ce84b5aea --- /dev/null +++ b/src/Plugins/Tia/WatchDefaults/WatchDefault.php @@ -0,0 +1,28 @@ +> glob → list of project-relative test dirs + */ + public function defaults(string $projectRoot, string $testPath): array; +} diff --git a/src/Plugins/Tia/WatchPatterns.php b/src/Plugins/Tia/WatchPatterns.php new file mode 100644 index 000000000..6bff58105 --- /dev/null +++ b/src/Plugins/Tia/WatchPatterns.php @@ -0,0 +1,194 @@ +tia()->watch(…)`. + * + * @internal + */ +final class WatchPatterns +{ + /** + * All known default providers, in evaluation order. + * + * @var array> + */ + private const array DEFAULTS = [ + WatchDefaults\Php::class, + WatchDefaults\Laravel::class, + WatchDefaults\Symfony::class, + WatchDefaults\Livewire::class, + WatchDefaults\Inertia::class, + WatchDefaults\Browser::class, + ]; + + /** + * @var array> glob → list of project-relative test dirs + */ + private array $patterns = []; + + private static ?self $instance = null; + + public static function instance(): self + { + return self::$instance ??= new self; + } + + /** + * Probes every registered `WatchDefault` and merges the patterns of + * those that apply. Called once during Tia plugin boot, after BootFiles + * has loaded `tests/Pest.php` (so user-added `pest()->tia()->watch()` + * calls are already in `$this->patterns`). + */ + public function useDefaults(string $projectRoot): void + { + $testPath = \Pest\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 $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 $changedFiles Project-relative paths. + * @return array 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 $directories Project-relative dirs. + * @param array $allTestFiles Project-relative test files from graph. + * @return array + */ + 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); + } +} From f52a455773769664286ae639e5514976190a7735 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 16 Apr 2026 07:10:48 -0700 Subject: [PATCH 005/160] fix --- src/Plugins/Tia.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 0df1bfdff..02dd0887e 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -18,6 +18,7 @@ use Pest\Plugins\Tia\Graph; use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\State; +use Pest\Plugins\Tia\WatchPatterns; use Pest\TestSuite; use Symfony\Component\Console\Output\OutputInterface; use Throwable; From 494cc6e2a4dfb16c66920f32f139cd8ac24f99a5 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 16 Apr 2026 07:15:44 -0700 Subject: [PATCH 006/160] feat(tia): continues to work on poc --- src/Plugins/Tia.php | 2 +- src/Plugins/Tia/Fingerprint.php | 2 +- src/Plugins/Tia/Graph.php | 6 +++++ src/Plugins/Tia/Recorder.php | 42 ++++++++++++++++----------------- src/Plugins/Tia/State.php | 1 + 5 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 02dd0887e..156247d8d 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -713,6 +713,6 @@ private function coverageReportActive(): bool return false; } - return property_exists($coverage, 'coverage') && $coverage->coverage === true; + return $coverage->coverage === true; } } diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 39561468f..a978f8d81 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -19,7 +19,7 @@ private const int SCHEMA_VERSION = 2; /** - * @param non-empty-string $projectRoot + * @return array */ public static function compute(string $projectRoot): array { diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 08d46e08c..c55d3abfd 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -166,11 +166,17 @@ public function allTestFiles(): array return array_keys($this->edges); } + /** + * @param array $fingerprint + */ public function setFingerprint(array $fingerprint): void { $this->fingerprint = $fingerprint; } + /** + * @return array + */ public function fingerprint(): array { return $this->fingerprint; diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index bdb7909ca..48a88cae2 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -69,12 +69,20 @@ public function driverAvailable(): bool $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(); - - if ($ok === null || $ok) { - @\xdebug_stop_code_coverage(false); + // 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; + + return true; + }); + \xdebug_start_code_coverage(); + restore_error_handler(); + + if ($probeOk) { + \xdebug_stop_code_coverage(false); $this->driver = 'xdebug'; $this->driverAvailable = true; } @@ -195,31 +203,23 @@ private function readPestFilename(string $className): ?string 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; } /** diff --git a/src/Plugins/Tia/State.php b/src/Plugins/Tia/State.php index 9fee2dbdd..48b829e7b 100644 --- a/src/Plugins/Tia/State.php +++ b/src/Plugins/Tia/State.php @@ -66,6 +66,7 @@ public static function instance(): self * Turns on replay mode with the given graph + affected set. * * @param array $affectedFiles + * @param array $previousDefects */ public function activate(string $projectRoot, Graph $graph, array $affectedFiles, array $previousDefects): void { From f09d6f206468fb1851d2d2ead154136897d9785c Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 16 Apr 2026 08:19:44 -0700 Subject: [PATCH 007/160] feat(tia): continues to work on poc --- src/Concerns/Testable.php | 29 --- src/Contracts/Plugins/AfterEachable.php | 20 -- src/Contracts/Plugins/BeforeEachable.php | 26 -- src/Contracts/Plugins/Runnable.php | 21 -- src/Kernel.php | 5 +- src/Plugins/Tia.php | 245 +++++++----------- src/Plugins/Tia/ChangedFiles.php | 6 +- src/Plugins/Tia/Configuration.php | 6 +- src/Plugins/Tia/Graph.php | 7 +- src/Plugins/Tia/Recorder.php | 8 - src/Plugins/Tia/State.php | 159 ------------ src/Plugins/Tia/WatchDefaults/Browser.php | 5 +- .../Tia/WatchDefaults/WatchDefault.php | 2 +- src/Plugins/Tia/WatchPatterns.php | 14 +- .../EnsureTiaCoverageIsFlushed.php | 6 +- .../EnsureTiaCoverageIsRecorded.php | 10 +- .../TiaTestCaseFilter.php | 2 +- 17 files changed, 135 insertions(+), 436 deletions(-) delete mode 100644 src/Contracts/Plugins/AfterEachable.php delete mode 100644 src/Contracts/Plugins/BeforeEachable.php delete mode 100644 src/Contracts/Plugins/Runnable.php delete mode 100644 src/Plugins/Tia/State.php rename src/{Plugins/Tia => TestCaseFilters}/TiaTestCaseFilter.php (98%) diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index a7fe07961..3f7e3b779 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -6,11 +6,7 @@ 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 @@ protected function setUp(...$arguments): void { 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 @@ public function __initializeTestCase(): void */ 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 @@ protected function tearDown(...$arguments): void */ 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); diff --git a/src/Contracts/Plugins/AfterEachable.php b/src/Contracts/Plugins/AfterEachable.php deleted file mode 100644 index c0c4a257d..000000000 --- a/src/Contracts/Plugins/AfterEachable.php +++ /dev/null @@ -1,20 +0,0 @@ -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, diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 156247d8d..6ee4f568d 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -5,20 +5,15 @@ 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 @@ * ----- * - **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 @@ * - **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-.json`. + * into `.temp/tia-worker-.json`. * - **Worker, replay**: nothing to do; args already narrowed. * * Guardrails @@ -66,7 +61,7 @@ * * @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 CACHE_FILE = 'tia.json'; - private const string AFFECTED_PATH = '.pest/cache/tia-affected.json'; + private const string AFFECTED_FILE = 'tia-affected.json'; - private const string WORKER_CACHE_PREFIX = '.pest/cache/tia-worker-'; + 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 @@ public function handleArguments(array $arguments): array 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 @@ public function terminate(): void } // 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 @@ public function terminate(): void $this->output->writeln(sprintf( ' TIA graph recorded (%d test files) at %s', count($perTest), - self::CACHE_PATH, + self::CACHE_FILE, )); $recorder->reset(); @@ -226,7 +253,7 @@ public function addOutput(int $exitCode): int 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 @@ public function addOutput(int $exitCode): int ' 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 @@ private function handleParent(array $arguments, string $projectRoot, bool $force // 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 @@ private function handleWorker(array $arguments, string $projectRoot, bool $recor 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 @@ private function handleWorker(array $arguments, string $projectRoot, bool $recor 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 @@ private function installWorkerReplayFilter(string $projectRoot): void } } - 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 - */ - private function loadPreviousDefects(string $projectRoot): array - { - // PHPUnit writes the cache under either `/.phpunit.result.cache` - // (legacy) or `/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 $arguments * @return array @@ -471,48 +436,31 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum } $changed = $changedFiles->since($graph->recordedAtSha()) ?? []; - - // Even with zero changes, we still run through the suite so the user - // sees the previous results reflected (cached passes replay as - // instant passes; failures re-run to surface current state). This - // matches the UX of test runners like NCrunch where every run - // produces a full report regardless of what actually executed. $affected = $changed === [] ? [] : $graph->affected($changed); + $totalKnown = count($graph->allTestFiles()); + $affectedCount = count($affected); + $cachedCount = $totalKnown - $affectedCount; + $testSuite = TestSuite::getInstance(); + $affectedSet = array_fill_keys($affected, true); if (! Parallel::isEnabled()) { - // Series mode: activate replay state. Tests still appear in the - // run (correct counts, coverage aggregation, event timeline); - // unaffected ones short-circuit inside `Testable::__runTest` - // and replay their previous passing status. - $affectedSet = array_fill_keys($affected, true); - - State::instance()->activate( - $projectRoot, - $graph, - $affectedSet, - $this->loadPreviousDefects($projectRoot), + $testSuite->tests->addTestCaseFilter( + new TiaTestCaseFilter($projectRoot, $graph, $affectedSet), ); $this->output->writeln(sprintf( - ' TIA %d changed file(s) → %d affected, remaining tests replay cached result.', + ' 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 - // ``, 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( ' TIA failed to persist affected set — running full suite.', @@ -524,9 +472,10 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum Parallel::setGlobal(self::REPLAYING_GLOBAL, '1'); $this->output->writeln(sprintf( - ' TIA %d changed file(s) → %d affected, remaining tests replay cached result (parallel).', + ' TIA %d changed file(s) → %d affected, %d cached (parallel).', count($changed), - count($affected), + $affectedCount, + $cachedCount, )); return $arguments; @@ -537,7 +486,7 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum */ 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 @@ private function enterRecordMode(string $projectRoot, array $arguments): array return $arguments; } - $recorder = Recorder::instance(); + $recorder = $this->recorder; if (! $recorder->driverAvailable()) { - $this->output->writeln( - ' TIA No coverage driver is available. '. - 'Install ext-pcov or enable Xdebug in coverage mode, then rerun with `--tia`.', - ); + $this->output->writeln([ + '', + ' 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 @@ private function flushWorkerPartial(string $projectRoot, array $perTest): void $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 @@ private function flushWorkerPartial(string $projectRoot, array $perTest): void */ 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 @@ private function readPartial(string $path): ?array $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) { diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index d2a568aa8..18571adad 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -28,9 +28,9 @@ public function __construct(private string $projectRoot) {} /** * @return array|null `null` when git is unavailable, or when - * the recorded SHA is no longer reachable - * from HEAD (rebase / force-push) — in - * that case the graph should be rebuilt. + * the recorded SHA is no longer reachable + * from HEAD (rebase / force-push) — in + * that case the graph should be rebuilt. */ public function since(?string $sha): ?array { diff --git a/src/Plugins/Tia/Configuration.php b/src/Plugins/Tia/Configuration.php index d711e5611..0a0fe4013 100644 --- a/src/Plugins/Tia/Configuration.php +++ b/src/Plugins/Tia/Configuration.php @@ -4,6 +4,8 @@ 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; } diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index c55d3abfd..490c753bb 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -4,6 +4,8 @@ namespace Pest\Plugins\Tia; +use Pest\Support\Container; + /** * File-level Test Impact Analysis graph. * @@ -127,7 +129,8 @@ public function affected(array $changedFiles): array } // 2. Watch-pattern lookup (non-PHP assets → test directories). - $watchPatterns = WatchPatterns::instance(); + /** @var WatchPatterns $watchPatterns */ + $watchPatterns = Container::getInstance()->get(WatchPatterns::class); $normalised = []; foreach ($changedFiles as $file) { @@ -159,7 +162,7 @@ public function knowsTest(string $testFile): bool } /** - * @return array All project-relative test files the graph knows. + * @return array All project-relative test files the graph knows. */ public function allTestFiles(): array { diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index 48a88cae2..635b9088c 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -5,7 +5,6 @@ namespace Pest\Plugins\Tia; use ReflectionClass; -use ReflectionException; /** * Captures per-test file coverage using the PCOV driver. @@ -18,8 +17,6 @@ */ 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; diff --git a/src/Plugins/Tia/State.php b/src/Plugins/Tia/State.php deleted file mode 100644 index 48b829e7b..000000000 --- a/src/Plugins/Tia/State.php +++ /dev/null @@ -1,159 +0,0 @@ - - */ - private array $affectedFiles = []; - - /** - * Keys are project-relative test file paths. Known = recorded in graph. - * - * @var array - */ - private array $knownFiles = []; - - /** - * Test ids (class::method) that were in the previous run's defect list. - * - * @var array - */ - 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 $affectedFiles - * @param array $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))); - } -} diff --git a/src/Plugins/Tia/WatchDefaults/Browser.php b/src/Plugins/Tia/WatchDefaults/Browser.php index 2c712f198..d4f18a670 100644 --- a/src/Plugins/Tia/WatchDefaults/Browser.php +++ b/src/Plugins/Tia/WatchDefaults/Browser.php @@ -5,6 +5,7 @@ namespace Pest\Plugins\Tia\WatchDefaults; use Composer\InstalledVersions; +use Pest\Browser\Support\BrowserTestIdentifier; use Pest\Factories\TestCaseFactory; use Pest\TestSuite; @@ -72,7 +73,7 @@ private function detectBrowserTestDirs(string $projectRoot, string $testPath): a // 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 @@ private function detectBrowserTestDirs(string $projectRoot, string $testPath): a } foreach ($factory->methods as $method) { - if (\Pest\Browser\Support\BrowserTestIdentifier::isBrowserTest($method)) { + if (BrowserTestIdentifier::isBrowserTest($method)) { $rel = $this->fileRelative($projectRoot, $filename); if ($rel !== null) { diff --git a/src/Plugins/Tia/WatchDefaults/WatchDefault.php b/src/Plugins/Tia/WatchDefaults/WatchDefault.php index ce84b5aea..44d8d39fd 100644 --- a/src/Plugins/Tia/WatchDefaults/WatchDefault.php +++ b/src/Plugins/Tia/WatchDefaults/WatchDefault.php @@ -22,7 +22,7 @@ interface WatchDefault public function applicable(): bool; /** - * @return array> glob → list of project-relative test dirs + * @return array> glob → list of project-relative test dirs */ public function defaults(string $projectRoot, string $testPath): array; } diff --git a/src/Plugins/Tia/WatchPatterns.php b/src/Plugins/Tia/WatchPatterns.php index 6bff58105..71591bbff 100644 --- a/src/Plugins/Tia/WatchPatterns.php +++ b/src/Plugins/Tia/WatchPatterns.php @@ -5,6 +5,7 @@ namespace Pest\Plugins\Tia; use Pest\Plugins\Tia\WatchDefaults\WatchDefault; +use Pest\TestSuite; /** * Maps non-PHP file globs to the test directories they should invalidate. @@ -37,17 +38,10 @@ final class WatchPatterns ]; /** - * @var array> glob → list of project-relative test dirs + * @var array> glob → list of project-relative test dirs */ private array $patterns = []; - private static ?self $instance = null; - - public static function instance(): self - { - return self::$instance ??= new self; - } - /** * Probes every registered `WatchDefault` and merges the patterns of * those that apply. Called once during Tia plugin boot, after BootFiles @@ -56,7 +50,7 @@ public static function instance(): self */ public function useDefaults(string $projectRoot): void { - $testPath = \Pest\TestSuite::getInstance()->testPath; + $testPath = TestSuite::getInstance()->testPath; foreach (self::DEFAULTS as $class) { $default = new $class; @@ -94,7 +88,7 @@ public function add(array $patterns): void * * @param string $projectRoot Absolute path. * @param array $changedFiles Project-relative paths. - * @return array Project-relative test directories. + * @return array Project-relative test directories. */ public function matchedDirectories(string $projectRoot, array $changedFiles): array { diff --git a/src/Subscribers/EnsureTiaCoverageIsFlushed.php b/src/Subscribers/EnsureTiaCoverageIsFlushed.php index 1b3e8fa06..2b10ebfe5 100644 --- a/src/Subscribers/EnsureTiaCoverageIsFlushed.php +++ b/src/Subscribers/EnsureTiaCoverageIsFlushed.php @@ -14,10 +14,12 @@ * * @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(); } } diff --git a/src/Subscribers/EnsureTiaCoverageIsRecorded.php b/src/Subscribers/EnsureTiaCoverageIsRecorded.php index 0d388c988..f7ef815fe 100644 --- a/src/Subscribers/EnsureTiaCoverageIsRecorded.php +++ b/src/Subscribers/EnsureTiaCoverageIsRecorded.php @@ -15,13 +15,13 @@ * * @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 @@ public function notify(Prepared $event): void return; } - $recorder->beginTest($test->className(), $test->methodName(), $test->file()); + $this->recorder->beginTest($test->className(), $test->methodName(), $test->file()); } } diff --git a/src/Plugins/Tia/TiaTestCaseFilter.php b/src/TestCaseFilters/TiaTestCaseFilter.php similarity index 98% rename from src/Plugins/Tia/TiaTestCaseFilter.php rename to src/TestCaseFilters/TiaTestCaseFilter.php index d210b4beb..682b31fda 100644 --- a/src/Plugins/Tia/TiaTestCaseFilter.php +++ b/src/TestCaseFilters/TiaTestCaseFilter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Pest\Plugins\Tia; +namespace Pest\TestCaseFilters; use Pest\Contracts\TestCaseFilter; use Pest\Plugins\Tia\Graph; From d379128cc44f52ae4dd8c56534d6bd2687179a15 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 16 Apr 2026 08:34:41 -0700 Subject: [PATCH 008/160] feat(tia): continues to work on poc --- src/Plugins/Tia/Graph.php | 65 +++++++++++++++++++++++++++++++-------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 490c753bb..dcbf45024 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -101,18 +101,29 @@ public function link(string $testFile, string $sourceFile): void */ public function affected(array $changedFiles): array { - // 1. Coverage-edge lookup (PHP → PHP). - $changedIds = []; + // Normalise all changed paths once. + $normalised = []; foreach ($changedFiles as $file) { $rel = $this->relative($file); - if ($rel === null) { - continue; + if ($rel !== null) { + $normalised[] = $rel; } + } + // 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; } } @@ -131,15 +142,6 @@ public function affected(array $changedFiles): array // 2. Watch-pattern lookup (non-PHP assets → test directories). /** @var WatchPatterns $watchPatterns */ $watchPatterns = Container::getInstance()->get(WatchPatterns::class); - $normalised = []; - - foreach ($changedFiles as $file) { - $rel = $this->relative($file); - - if ($rel !== null) { - $normalised[] = $rel; - } - } $dirs = $watchPatterns->matchedDirectories($this->projectRoot, $normalised); $allTestFiles = array_keys($this->edges); @@ -148,6 +150,43 @@ public function affected(array $changedFiles): array $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); } From c7e32f5d3309fe29bfc8bad05fce5927118ae531 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 16 Apr 2026 10:59:06 -0700 Subject: [PATCH 009/160] feat(tia): continues to work on poc --- src/Bootstrappers/BootSubscribers.php | 1 + src/Concerns/Testable.php | 46 +++++ src/Contracts/Plugins/BeforeEachable.php | 25 +++ src/Kernel.php | 3 +- src/Plugins/Tia.php | 179 ++++++++++++++++-- src/Plugins/Tia/CachedTestResult.php | 33 ++++ src/Plugins/Tia/ChangedFiles.php | 96 ++++++++++ src/Plugins/Tia/Graph.php | 99 +++++++++- src/Plugins/Tia/ResultCollector.php | 119 ++++++++++++ .../EnsureTiaResultsAreCollected.php | 86 +++++++++ tests/Arch.php | 1 + 11 files changed, 668 insertions(+), 20 deletions(-) create mode 100644 src/Contracts/Plugins/BeforeEachable.php create mode 100644 src/Plugins/Tia/CachedTestResult.php create mode 100644 src/Plugins/Tia/ResultCollector.php create mode 100644 src/Subscribers/EnsureTiaResultsAreCollected.php diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php index 749a6b5de..c01af8f5f 100644 --- a/src/Bootstrappers/BootSubscribers.php +++ b/src/Bootstrappers/BootSubscribers.php @@ -27,6 +27,7 @@ Subscribers\EnsureTeamCityEnabled::class, Subscribers\EnsureTiaCoverageIsRecorded::class, Subscribers\EnsureTiaCoverageIsFlushed::class, + Subscribers\EnsureTiaResultsAreCollected::class, ]; /** diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 3f7e3b779..384889340 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -5,8 +5,11 @@ 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 @@ protected function setUp(...$arguments): void { 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 @@ public function __initializeTestCase(): void */ 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 @@ protected function tearDown(...$arguments): void */ private function __runTest(Closure $closure, ...$args): mixed { + if ($this->__cachedPass) { + $this->addToAssertionCount(1); + + return null; + } + $arguments = $this->__resolveTestArguments($args); $this->__ensureDatasetArgumentNameAndNumberMatches($arguments); diff --git a/src/Contracts/Plugins/BeforeEachable.php b/src/Contracts/Plugins/BeforeEachable.php new file mode 100644 index 000000000..436cfcec5 --- /dev/null +++ b/src/Contracts/Plugins/BeforeEachable.php @@ -0,0 +1,25 @@ +add(OutputInterface::class, $output) ->add(Container::class, $container) ->add(Tia\Recorder::class, new Tia\Recorder) - ->add(Tia\WatchPatterns::class, new Tia\WatchPatterns); + ->add(Tia\WatchPatterns::class, new Tia\WatchPatterns) + ->add(Tia\ResultCollector::class, new Tia\ResultCollector); $kernel = new self( new Application, diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 6ee4f568d..578711e95 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -5,12 +5,15 @@ 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\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; @@ -61,7 +64,7 @@ * * @internal */ -final class Tia implements AddsOutput, HandlesArguments, Terminable +final class Tia implements AddsOutput, BeforeEachable, HandlesArguments, Terminable { use Concerns\HandleArguments; @@ -98,6 +101,28 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private bool $graphWritten = false; + 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 + */ + private array $affectedFiles = []; + private static function tempDir(): string { $dir = (string) realpath(self::TEMP_DIR); @@ -137,6 +162,34 @@ public function __construct( 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} */ @@ -211,7 +264,7 @@ public function terminate(): void $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(); @@ -242,6 +295,20 @@ public function addOutput(int $exitCode): int 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; } @@ -257,7 +324,7 @@ public function addOutput(int $exitCode): int $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 = []; @@ -317,6 +384,11 @@ private function handleParent(array $arguments, string $projectRoot, bool $force // 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); @@ -331,10 +403,11 @@ private function handleParent(array $arguments, string $projectRoot, bool $force 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( ' TIA recorded commit is no longer reachable — graph will be rebuilt.', ); @@ -355,6 +428,8 @@ private function handleParent(array $arguments, string $projectRoot, bool $force */ 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 @@ -435,7 +510,15 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum return $arguments; } - $changed = $changedFiles->since($graph->recordedAtSha()) ?? []; + $changed = $changedFiles->since($graph->recordedAtSha($this->branch)) ?? []; + + // 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)); + $affected = $changed === [] ? [] : $graph->affected($changed); $totalKnown = count($graph->allTestFiles()); @@ -445,13 +528,13 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum $testSuite = TestSuite::getInstance(); $affectedSet = array_fill_keys($affected, true); - if (! Parallel::isEnabled()) { - $testSuite->tests->addTestCaseFilter( - new TiaTestCaseFilter($projectRoot, $graph, $affectedSet), - ); + $this->replayRan = true; + $this->replayGraph = $graph; + $this->affectedFiles = $affectedSet; + if (! Parallel::isEnabled()) { $this->output->writeln(sprintf( - ' TIA %d changed file(s) → %d affected, %d cached.', + ' TIA %d changed file(s) → %d affected, %d replayed.', count($changed), $affectedCount, $cachedCount, @@ -659,6 +742,80 @@ private function readPartial(string $path): ?array 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 { diff --git a/src/Plugins/Tia/CachedTestResult.php b/src/Plugins/Tia/CachedTestResult.php new file mode 100644 index 000000000..d63bec73c --- /dev/null +++ b/src/Plugins/Tia/CachedTestResult.php @@ -0,0 +1,33 @@ +status === 0; + } +} diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 18571adad..226513fee 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -32,6 +32,84 @@ public function __construct(private string $projectRoot) {} * 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 $files project-relative paths. + * @param array $lastRunTree path → content hash from last run. + * @return array + */ + 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 $files + * @return array 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|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 { if (! $this->gitAvailable()) { @@ -87,6 +165,24 @@ private function shouldIgnore(string $path): bool 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); diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index dcbf45024..02102df81 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -47,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, + * results: array + * }> */ - private ?string $recordedAtSha = null; + private array $baselines = []; /** * Canonicalised project root. Resolved through `realpath()` so paths @@ -224,14 +237,84 @@ 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 + { + $baseline = $this->baselineFor($branch, $fallbackBranch); + + return $baseline['sha']; + } + + public function setRecordedAtSha(string $branch, ?string $sha): void + { + $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 $tree project-relative path → content hash + */ + public function setLastRunTree(string $branch, array $tree): void + { + $this->ensureBaseline($branch); + $this->baselines[$branch]['tree'] = $tree; + } + + /** + * @return array + */ + public function lastRunTree(string $branch, string $fallbackBranch = 'main'): array + { + return $this->baselineFor($branch, $fallbackBranch)['tree']; + } + + /** + * @return array{sha: ?string, tree: array, results: array} + */ + private function baselineFor(string $branch, string $fallbackBranch): array { - $this->recordedAtSha = $sha; + 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' => []]; } - public function recordedAtSha(): ?string + private function ensureBaseline(string $branch): void { - return $this->recordedAtSha; + if (! isset($this->baselines[$branch])) { + $this->baselines[$branch] = ['sha' => null, 'tree' => [], 'results' => []]; + } } /** @@ -296,10 +379,10 @@ public static function load(string $projectRoot, string $path): ?self $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; } @@ -315,9 +398,9 @@ public function save(string $path): bool $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'; diff --git a/src/Plugins/Tia/ResultCollector.php b/src/Plugins/Tia/ResultCollector.php new file mode 100644 index 000000000..acf8ebbef --- /dev/null +++ b/src/Plugins/Tia/ResultCollector.php @@ -0,0 +1,119 @@ + + */ + 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 + */ + 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; + } +} diff --git a/src/Subscribers/EnsureTiaResultsAreCollected.php b/src/Subscribers/EnsureTiaResultsAreCollected.php new file mode 100644 index 000000000..4643c2bb1 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultsAreCollected.php @@ -0,0 +1,86 @@ +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 + } +} diff --git a/tests/Arch.php b/tests/Arch.php index 3eca267a8..56c451075 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -37,6 +37,7 @@ ->toOnlyUse([ 'NunoMaduro\Collision\Contracts', 'Pest\Factories\TestCaseMethodFactory', + 'Pest\Plugins\Tia\CachedTestResult', 'Symfony\Component\Console', 'Pest\Arch\Contracts', 'Pest\PendingCalls', From 42d1092a9e95ee9d17258aba28c5c65213369073 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 16 Apr 2026 13:40:24 -0700 Subject: [PATCH 010/160] feat(tia): continues to work on poc --- src/Plugins/Tia.php | 17 +++--- src/TestCaseFilters/TiaTestCaseFilter.php | 65 ----------------------- 2 files changed, 11 insertions(+), 71 deletions(-) delete mode 100644 src/TestCaseFilters/TiaTestCaseFilter.php diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 578711e95..32c9a03e6 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -14,7 +14,6 @@ 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; @@ -435,7 +434,7 @@ private function handleWorker(array $arguments, string $projectRoot, bool $recor // the parent persisted, then install the per-file filter so // whichever tests paratest happens to hand this worker are // accepted / rejected consistently with the series path. - $this->installWorkerReplayFilter($projectRoot); + $this->installWorkerReplay($projectRoot); return $arguments; } @@ -458,7 +457,14 @@ private function handleWorker(array $arguments, string $projectRoot, bool $recor return $arguments; } - private function installWorkerReplayFilter(string $projectRoot): void + /** + * Wires worker-side replay. Mirrors the series path: sets `replayGraph` + * + `affectedFiles` so the `BeforeEachable` hook in `beforeEach()` can + * answer per-test. Unaffected tests replay their cached status (pass, + * fail, skip, todo, incomplete) so the user sees the full suite report + * in parallel runs exactly like in series. + */ + private function installWorkerReplay(string $projectRoot): void { $cachePath = self::cachePath(); $affectedPath = self::affectedPath(); @@ -489,9 +495,8 @@ private function installWorkerReplayFilter(string $projectRoot): void } } - TestSuite::getInstance()->tests->addTestCaseFilter( - new TiaTestCaseFilter($projectRoot, $graph, $affectedSet), - ); + $this->replayGraph = $graph; + $this->affectedFiles = $affectedSet; } /** diff --git a/src/TestCaseFilters/TiaTestCaseFilter.php b/src/TestCaseFilters/TiaTestCaseFilter.php deleted file mode 100644 index 682b31fda..000000000 --- a/src/TestCaseFilters/TiaTestCaseFilter.php +++ /dev/null @@ -1,65 +0,0 @@ - $affectedTestFiles Keys are project-relative test file paths. - */ - public function __construct( - private string $projectRoot, - private Graph $graph, - private array $affectedTestFiles, - ) {} - - public function accept(string $testCaseFilename): bool - { - $rel = $this->relative($testCaseFilename); - - if ($rel === null) { - return true; - } - - if (! $this->graph->knowsTest($rel)) { - return true; - } - - return isset($this->affectedTestFiles[$rel]); - } - - private function relative(string $path): ?string - { - $real = @realpath($path); - - if ($real === false) { - $real = $path; - } - - $root = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR; - - if (! str_starts_with($real, $root)) { - return null; - } - - return str_replace(DIRECTORY_SEPARATOR, '/', substr($real, strlen($root))); - } -} From 9c8033d60cf616ef0006329d5f4a87ad946f64a0 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 16 Apr 2026 14:24:20 -0700 Subject: [PATCH 011/160] feat(tia): continues to work on poc --- src/Bootstrappers/BootSubscribers.php | 7 ++ src/Concerns/Testable.php | 43 ++++++----- src/Contracts/Plugins/BeforeEachable.php | 25 ------ src/Plugins/Tia.php | 76 +++++++++++++------ src/Plugins/Tia/CachedTestResult.php | 33 -------- src/Plugins/Tia/ChangedFiles.php | 14 +++- src/Plugins/Tia/Graph.php | 19 ++++- src/Plugins/Tia/ResultCollector.php | 12 ++- ...sureTiaAssertionsAreRecordedOnFinished.php | 34 +++++++++ .../EnsureTiaResultIsRecordedOnErrored.php | 22 ++++++ .../EnsureTiaResultIsRecordedOnFailed.php | 22 ++++++ .../EnsureTiaResultIsRecordedOnIncomplete.php | 22 ++++++ .../EnsureTiaResultIsRecordedOnPassed.php | 22 ++++++ .../EnsureTiaResultIsRecordedOnRisky.php | 22 ++++++ .../EnsureTiaResultIsRecordedOnSkipped.php | 22 ++++++ .../EnsureTiaResultsAreCollected.php | 75 +++--------------- tests/Arch.php | 1 - 17 files changed, 300 insertions(+), 171 deletions(-) delete mode 100644 src/Contracts/Plugins/BeforeEachable.php delete mode 100644 src/Plugins/Tia/CachedTestResult.php create mode 100644 src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php create mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php create mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php create mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php create mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php create mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php create mode 100644 src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php diff --git a/src/Bootstrappers/BootSubscribers.php b/src/Bootstrappers/BootSubscribers.php index c01af8f5f..f605d9be6 100644 --- a/src/Bootstrappers/BootSubscribers.php +++ b/src/Bootstrappers/BootSubscribers.php @@ -28,6 +28,13 @@ Subscribers\EnsureTiaCoverageIsRecorded::class, Subscribers\EnsureTiaCoverageIsFlushed::class, Subscribers\EnsureTiaResultsAreCollected::class, + Subscribers\EnsureTiaResultIsRecordedOnPassed::class, + Subscribers\EnsureTiaResultIsRecordedOnFailed::class, + Subscribers\EnsureTiaResultIsRecordedOnErrored::class, + Subscribers\EnsureTiaResultIsRecordedOnSkipped::class, + Subscribers\EnsureTiaResultIsRecordedOnIncomplete::class, + Subscribers\EnsureTiaResultIsRecordedOnRisky::class, + Subscribers\EnsureTiaAssertionsAreRecordedOnFinished::class, ]; /** diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 384889340..69c48debc 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -5,17 +5,17 @@ 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\Plugins\Tia; use Pest\Preset; +use Pest\Support\Container; use Pest\Support\ChainableClosure; use Pest\Support\ExceptionTrace; use Pest\Support\Reflection; use Pest\Support\Shell; use Pest\TestSuite; +use PHPUnit\Framework\AssertionFailedError; use PHPUnit\Framework\Attributes\PostCondition; use PHPUnit\Framework\IncompleteTest; use PHPUnit\Framework\SkippedTest; @@ -238,27 +238,30 @@ protected function setUp(...$arguments): void $this->__cachedPass = false; - /** @var BeforeEachable $plugin */ - foreach (Loader::getPlugins(BeforeEachable::class) as $plugin) { - $cached = $plugin->beforeEach(self::$__filename, $this::class.'::'.$this->name()); + /** @var Tia $tia */ + $tia = Container::getInstance()->get(Tia::class); + $cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name()); - if ($cached instanceof CachedTestResult) { - if ($cached->isSuccess()) { - $this->__cachedPass = true; + if ($cached !== null) { + if ($cached->isSuccess()) { + $this->__cachedPass = true; - return; - } + 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'), - }; + // Non-success: throw the matching PHPUnit exception. Runner + // catches it and marks the test with the correct status so + // skips, failures, incompletes and todos appear in output + // exactly as they did in the cached run. + if ($cached->isSkipped()) { + $this->markTestSkipped($cached->message()); } + + if ($cached->isIncomplete()) { + $this->markTestIncomplete($cached->message()); + } + + throw new AssertionFailedError($cached->message() ?: 'Cached failure'); } $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); diff --git a/src/Contracts/Plugins/BeforeEachable.php b/src/Contracts/Plugins/BeforeEachable.php deleted file mode 100644 index 436cfcec5..000000000 --- a/src/Contracts/Plugins/BeforeEachable.php +++ /dev/null @@ -1,25 +0,0 @@ -replayGraph === null) { return null; @@ -186,7 +209,13 @@ public function beforeEach(string $filename, string $testId): ?CachedTestResult // 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); + $result = $this->replayGraph->getResult($this->branch, $testId); + + if ($result !== null) { + $this->replayedCount++; + } + + return $result; } /** @@ -301,6 +330,7 @@ public function addOutput(int $exitCode): int // times even though nothing new changed. if ($this->replayRan) { $this->bumpRecordedSha(); + $this->emitReplaySummary(); } // Snapshot per-test results (status + message) from PHPUnit's result @@ -526,11 +556,9 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum $affected = $changed === [] ? [] : $graph->affected($changed); - $totalKnown = count($graph->allTestFiles()); - $affectedCount = count($affected); - $cachedCount = $totalKnown - $affectedCount; + $this->changedFileCount = count($changed); + $this->affectedTestCount = count($affected); - $testSuite = TestSuite::getInstance(); $affectedSet = array_fill_keys($affected, true); $this->replayRan = true; @@ -538,13 +566,6 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum $this->affectedFiles = $affectedSet; if (! Parallel::isEnabled()) { - $this->output->writeln(sprintf( - ' TIA %d changed file(s) → %d affected, %d replayed.', - count($changed), - $affectedCount, - $cachedCount, - )); - return $arguments; } @@ -559,13 +580,6 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum Parallel::setGlobal(self::REPLAYING_GLOBAL, '1'); - $this->output->writeln(sprintf( - ' TIA %d changed file(s) → %d affected, %d cached (parallel).', - count($changed), - $affectedCount, - $cachedCount, - )); - return $arguments; } @@ -761,6 +775,22 @@ private function readPartial(string $path): ?array * compares against this baseline so identical files are skipped even if * git still reports them as modified. */ + /** + * Prints the post-run TIA summary. Runs after the test report so the + * replayed count reflects what actually happened (cache hits counted + * inside `getCachedResult`) rather than a graph-level estimate that + * ignores any CLI path filter the user passed in. + */ + private function emitReplaySummary(): void + { + $this->output->writeln(sprintf( + ' TIA %d changed file(s) → %d affected, %d replayed.', + $this->changedFileCount, + $this->affectedTestCount, + $this->replayedCount, + )); + } + private function bumpRecordedSha(): void { $projectRoot = TestSuite::getInstance()->rootPath; diff --git a/src/Plugins/Tia/CachedTestResult.php b/src/Plugins/Tia/CachedTestResult.php deleted file mode 100644 index d63bec73c..000000000 --- a/src/Plugins/Tia/CachedTestResult.php +++ /dev/null @@ -1,33 +0,0 @@ -status === 0; - } -} diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 226513fee..3a7a62f4c 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -60,8 +60,13 @@ public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): a $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; if (! is_file($absolute)) { - // File deleted since last run — definitely changed. - $remaining[] = $file; + // File is absent now. If the snapshot recorded it as absent + // too (sentinel ''), state is identical to last run — treat + // as unchanged. Otherwise it was present last run and got + // deleted since — that's a real change. + if ($lastRunTree[$file] !== '') { + $remaining[] = $file; + } continue; } @@ -92,6 +97,11 @@ public function snapshotTree(array $files): array $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; if (! is_file($absolute)) { + // Record the deletion with an empty-string sentinel so the + // next run recognises "still deleted" as unchanged rather + // than re-flagging the file as a fresh change. + $out[$file] = ''; + continue; } diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 02102df81..24643bb3b 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -5,6 +5,7 @@ namespace Pest\Plugins\Tia; use Pest\Support\Container; +use PHPUnit\Framework\TestStatus\TestStatus; /** * File-level Test Impact Analysis graph. @@ -264,7 +265,7 @@ public function setResult(string $branch, string $testId, int $status, string $m ]; } - public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?CachedTestResult + public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus { $baseline = $this->baselineFor($branch, $fallbackBranch); @@ -274,7 +275,21 @@ public function getResult(string $branch, string $testId, string $fallbackBranch $r = $baseline['results'][$testId]; - return new CachedTestResult($r['status'], $r['message'], $r['time']); + // PHPUnit's `TestStatus::from(int)` ignores messages, so reconstruct + // each variant via its specific factory. Keeps the stored message + // intact (important for skips/failures shown to the user). + return match ($r['status']) { + 0 => TestStatus::success(), + 1 => TestStatus::skipped($r['message']), + 2 => TestStatus::incomplete($r['message']), + 3 => TestStatus::notice($r['message']), + 4 => TestStatus::deprecation($r['message']), + 5 => TestStatus::risky($r['message']), + 6 => TestStatus::warning($r['message']), + 7 => TestStatus::failure($r['message']), + 8 => TestStatus::error($r['message']), + default => TestStatus::unknown(), + }; } /** diff --git a/src/Plugins/Tia/ResultCollector.php b/src/Plugins/Tia/ResultCollector.php index acf8ebbef..a2fd50f53 100644 --- a/src/Plugins/Tia/ResultCollector.php +++ b/src/Plugins/Tia/ResultCollector.php @@ -14,7 +14,7 @@ final class ResultCollector { /** - * @var array + * @var array */ private array $results = []; @@ -83,13 +83,20 @@ public function testRisky(string $message): void } /** - * @return array + * @return array */ public function all(): array { return $this->results; } + public function recordAssertions(string $testId, int $assertions): void + { + if (isset($this->results[$testId])) { + $this->results[$testId]['assertions'] = $assertions; + } + } + public function reset(): void { $this->results = []; @@ -111,6 +118,7 @@ private function record(int $status, string $message): void 'status' => $status, 'message' => $message, 'time' => $time, + 'assertions' => 0, ]; $this->currentTestId = null; diff --git a/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php new file mode 100644 index 000000000..122f72130 --- /dev/null +++ b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php @@ -0,0 +1,34 @@ +test(); + + if ($test instanceof TestMethod) { + $this->collector->recordAssertions( + $test->className().'::'.$test->methodName(), + $event->numberOfAssertionsPerformed(), + ); + } + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php b/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php new file mode 100644 index 000000000..c6cd4a270 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php @@ -0,0 +1,22 @@ +collector->testErrored($event->throwable()->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php new file mode 100644 index 000000000..c46cf8a0b --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php @@ -0,0 +1,22 @@ +collector->testFailed($event->throwable()->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php b/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php new file mode 100644 index 000000000..fe91ecbb8 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php @@ -0,0 +1,22 @@ +collector->testIncomplete($event->throwable()->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php new file mode 100644 index 000000000..739b213ad --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php @@ -0,0 +1,22 @@ +collector->testPassed(); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php b/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php new file mode 100644 index 000000000..8554816e0 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php @@ -0,0 +1,22 @@ +collector->testRisky($event->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php b/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php new file mode 100644 index 000000000..1b94cf663 --- /dev/null +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php @@ -0,0 +1,22 @@ +collector->testSkipped($event->message()); + } +} diff --git a/src/Subscribers/EnsureTiaResultsAreCollected.php b/src/Subscribers/EnsureTiaResultsAreCollected.php index 4643c2bb1..aec17fea8 100644 --- a/src/Subscribers/EnsureTiaResultsAreCollected.php +++ b/src/Subscribers/EnsureTiaResultsAreCollected.php @@ -6,81 +6,30 @@ 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. + * Starts a per-test recording window on Prepared. Sibling subscribers + * (`EnsureTia*`) close it with the outcome and the assertion count so the + * graph can persist everything needed for faithful replay. + * + * Why one subscriber per event: PHPUnit's `TypeMap::map()` picks only the + * first subscriber interface it finds on a class, so one class cannot fan + * out to multiple events — each event needs its own subscriber class. * * @internal */ -final class EnsureTiaResultsAreCollected implements - ConsideredRiskySubscriber, - ErroredSubscriber, - FailedSubscriber, - MarkedIncompleteSubscriber, - PassedSubscriber, - PreparedSubscriber, - SkippedSubscriber +final class EnsureTiaResultsAreCollected implements PreparedSubscriber { public function __construct(private readonly ResultCollector $collector) {} - public function notify(Prepared|Passed|Failed|Errored|Skipped|MarkedIncomplete|ConsideredRisky $event): void + public function notify(Prepared $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()); + $test = $event->test(); - return; + if ($test instanceof TestMethod) { + $this->collector->testPrepared($test->className().'::'.$test->methodName()); } - - 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 } } diff --git a/tests/Arch.php b/tests/Arch.php index 56c451075..3eca267a8 100644 --- a/tests/Arch.php +++ b/tests/Arch.php @@ -37,7 +37,6 @@ ->toOnlyUse([ 'NunoMaduro\Collision\Contracts', 'Pest\Factories\TestCaseMethodFactory', - 'Pest\Plugins\Tia\CachedTestResult', 'Symfony\Component\Console', 'Pest\Arch\Contracts', 'Pest\PendingCalls', From 47f1fc2d94fc71f8964fe361a19612dc3a13d00c Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 20 Apr 2026 10:05:55 -0700 Subject: [PATCH 012/160] feat(tia): continues to work on poc --- src/Plugins/Tia.php | 169 ++++++++++++++++++++++++++-- src/Plugins/Tia/ResultCollector.php | 14 +++ 2 files changed, 173 insertions(+), 10 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index bc7d5d94d..a6726f0d3 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -86,6 +86,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private const string WORKER_PREFIX = 'tia-worker-'; + private const string WORKER_RESULTS_PREFIX = 'tia-worker-results-'; + /** * Global flag toggled by the parent process so workers know to record. */ @@ -174,6 +176,16 @@ private static function workerGlob(): string return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_PREFIX.'*.json'; } + private static function workerResultsPath(string $token): string + { + return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.$token.'.json'; + } + + private static function workerResultsGlob(): string + { + return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.'*.json'; + } + public function __construct( private readonly OutputInterface $output, private readonly Recorder $recorder, @@ -263,6 +275,14 @@ public function terminate(): void return; } + // Worker in replay mode: flush the ResultCollector + replay counter + // into a partial so the parent can merge them into the graph after + // paratest returns. Parent's own ResultCollector is empty in parallel + // runs because workers — not the parent — execute the tests. + if (Parallel::isWorker() && $this->replayGraph !== null) { + $this->flushWorkerReplay(); + } + $recorder = $this->recorder; if (! $recorder->isActive()) { @@ -329,6 +349,14 @@ public function addOutput(int $exitCode): int // twice in a row would re-execute the same affected tests both // times even though nothing new changed. if ($this->replayRan) { + // In parallel runs the workers executed the tests, so their + // ResultCollector + replay counter live in other processes. Pull + // those partials in before both the summary and the graph + // snapshot so the parent state reflects the whole run. + if (Parallel::isEnabled()) { + $this->mergeWorkerReplayPartials(); + } + $this->bumpRecordedSha(); $this->emitReplaySummary(); } @@ -578,6 +606,10 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum return $arguments; } + // Clear stale partials from a previous interrupted run so the merge + // pass doesn't pick up results from an unrelated invocation. + $this->purgeWorkerPartials($projectRoot); + Parallel::setGlobal(self::REPLAYING_GLOBAL, '1'); return $arguments; @@ -670,16 +702,7 @@ private function enterRecordMode(string $projectRoot, array $arguments): array */ private function flushWorkerPartial(string $projectRoot, array $perTest): void { - $token = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? getmypid(); - // Defensive: token might arrive as int or string depending on paratest - // version. Cast + filter to keep filenames sane. - $token = preg_replace('/[^A-Za-z0-9_-]/', '', (string) $token); - - if ($token === '') { - $token = (string) getmypid(); - } - - $path = self::workerPath($token); + $path = self::workerPath($this->workerToken()); $dir = dirname($path); if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { @@ -719,6 +742,132 @@ private function purgeWorkerPartials(string $projectRoot): void foreach ($this->collectWorkerPartials($projectRoot) as $path) { @unlink($path); } + + foreach ($this->collectWorkerReplayPartials() as $path) { + @unlink($path); + } + } + + /** + * Worker-side flush of replay state (collected results + cache-hit + * counter) into a per-worker partial file. Parent merges them in + * `addOutput` so the graph snapshot + summary reflect the full run. + */ + private function flushWorkerReplay(): void + { + /** @var ResultCollector $collector */ + $collector = Container::getInstance()->get(ResultCollector::class); + + $results = $collector->all(); + + if ($results === [] && $this->replayedCount === 0) { + return; + } + + $token = $this->workerToken(); + $path = self::workerResultsPath($token); + $dir = dirname($path); + + if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { + return; + } + + $json = json_encode([ + 'results' => $results, + 'replayed' => $this->replayedCount, + ], JSON_UNESCAPED_SLASHES); + + if ($json === false) { + return; + } + + $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; + + if (@file_put_contents($tmp, $json) === false) { + return; + } + + if (! @rename($tmp, $path)) { + @unlink($tmp); + } + } + + /** + * @return array + */ + private function collectWorkerReplayPartials(): array + { + $matches = glob(self::workerResultsGlob()); + + return $matches === false ? [] : $matches; + } + + /** + * Parent-side merge of per-worker replay partials. Feeds the results into + * the parent's `ResultCollector` so the existing snapshot pass persists + * them, and rolls up the cache-hit counts so the summary is accurate. + */ + private function mergeWorkerReplayPartials(): void + { + /** @var ResultCollector $collector */ + $collector = Container::getInstance()->get(ResultCollector::class); + + foreach ($this->collectWorkerReplayPartials() as $path) { + $raw = @file_get_contents($path); + + if ($raw === false) { + @unlink($path); + + continue; + } + + $decoded = json_decode($raw, true); + @unlink($path); + + if (! is_array($decoded)) { + continue; + } + + if (isset($decoded['replayed']) && is_int($decoded['replayed'])) { + $this->replayedCount += $decoded['replayed']; + } + + if (isset($decoded['results']) && is_array($decoded['results'])) { + $normalised = []; + + /** @var mixed $result */ + foreach ($decoded['results'] as $testId => $result) { + if (! is_string($testId) || ! is_array($result)) { + continue; + } + + $normalised[$testId] = [ + 'status' => is_int($result['status'] ?? null) ? $result['status'] : 0, + 'message' => is_string($result['message'] ?? null) ? $result['message'] : '', + 'time' => is_float($result['time'] ?? null) || is_int($result['time'] ?? null) ? (float) $result['time'] : 0.0, + 'assertions' => is_int($result['assertions'] ?? null) ? $result['assertions'] : 0, + ]; + } + + if ($normalised !== []) { + $collector->merge($normalised); + } + } + } + } + + private function workerToken(): string + { + $raw = $_SERVER['TEST_TOKEN'] ?? $_ENV['TEST_TOKEN'] ?? null; + + $token = is_scalar($raw) ? (string) $raw : (string) getmypid(); + $token = preg_replace('/[^A-Za-z0-9_-]/', '', $token); + + if ($token === null || $token === '') { + return (string) getmypid(); + } + + return $token; } /** diff --git a/src/Plugins/Tia/ResultCollector.php b/src/Plugins/Tia/ResultCollector.php index a2fd50f53..ceabb0d45 100644 --- a/src/Plugins/Tia/ResultCollector.php +++ b/src/Plugins/Tia/ResultCollector.php @@ -97,6 +97,20 @@ public function recordAssertions(string $testId, int $assertions): void } } + /** + * Injects externally-collected results (e.g. partials flushed by parallel + * workers) into this collector so the parent can persist them in the same + * snapshot pass as non-parallel runs. + * + * @param array $results + */ + public function merge(array $results): void + { + foreach ($results as $testId => $result) { + $this->results[$testId] = $result; + } + } + public function reset(): void { $this->results = []; From 8c849c5f406223974fed66dc7e0dbbbd2a0cc062 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 20 Apr 2026 12:18:32 -0700 Subject: [PATCH 013/160] fix: missing warning --- src/Plugins/Tia.php | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index a6726f0d3..388553e83 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -654,6 +654,20 @@ private function persistAffectedSet(string $projectRoot, array $affected): bool */ private function enterRecordMode(string $projectRoot, array $arguments): array { + $recorder = $this->recorder; + + if (! $recorder->driverAvailable()) { + // Both series and parallel record require the coverage driver. + // Parallel also requires it because workers inherit the parent's + // PHP config — if the parent lacks the driver, workers will too + // and would silently produce no graph. Warn once, up-front, and + // continue running the suite without TIA so the user still gets + // their test results. + $this->emitCoverageDriverMissing(); + + return $arguments; + } + if (Parallel::isEnabled()) { // Parent driving `--parallel`: workers will do the actual // recording. We only advertise the intent through a global. @@ -671,21 +685,6 @@ private function enterRecordMode(string $projectRoot, array $arguments): array return $arguments; } - $recorder = $this->recorder; - - if (! $recorder->driverAvailable()) { - $this->output->writeln([ - '', - ' 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`.', - '', - ]); - - exit(1); - } - $recorder->activate(); $this->output->writeln(sprintf( @@ -697,6 +696,18 @@ private function enterRecordMode(string $projectRoot, array $arguments): array return $arguments; } + private function emitCoverageDriverMissing(): void + { + $this->output->writeln([ + '', + ' WARNING No coverage driver is available — TIA skipped.', + '', + ' TIA needs ext-pcov or Xdebug with coverage mode enabled to record', + ' the dependency graph. Install or enable one and rerun with `--tia`.', + '', + ]); + } + /** * @param array> $perTest */ From 980667e8455c9643089e20c8a63978066e5dfc25 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 20 Apr 2026 12:25:39 -0700 Subject: [PATCH 014/160] wip --- src/Plugins/Tia/ChangedFiles.php | 36 ++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 3a7a62f4c..e24fda26a 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -48,23 +48,37 @@ public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): a return $files; } + // Union: `$files` (what git currently reports) + every path that was + // dirty last run. The second set matters for reverts — when a user + // undoes a local edit, the file matches HEAD again and git reports + // it clean, so it would never enter `$files`. But it has genuinely + // changed vs the snapshot we captured during the bad run, so it + // must be checked. + $candidates = array_fill_keys($files, true); + + foreach (array_keys($lastRunTree) as $snapshotted) { + $candidates[$snapshotted] = true; + } + $remaining = []; - foreach ($files as $file) { - if (! isset($lastRunTree[$file])) { + foreach (array_keys($candidates) as $file) { + $snapshot = $lastRunTree[$file] ?? null; + $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; + $exists = is_file($absolute); + + if ($snapshot === null) { + // File wasn't in last-run tree at all — trust git's signal. $remaining[] = $file; continue; } - $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; - - if (! is_file($absolute)) { - // File is absent now. If the snapshot recorded it as absent - // too (sentinel ''), state is identical to last run — treat - // as unchanged. Otherwise it was present last run and got - // deleted since — that's a real change. - if ($lastRunTree[$file] !== '') { + if (! $exists) { + // Missing now. If the snapshot recorded it as absent too + // (sentinel ''), state is identical to last run — unchanged. + // Otherwise it was present last run and got deleted since. + if ($snapshot !== '') { $remaining[] = $file; } @@ -73,7 +87,7 @@ public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): a $hash = @hash_file('xxh128', $absolute); - if ($hash === false || $hash !== $lastRunTree[$file]) { + if ($hash === false || $hash !== $snapshot) { $remaining[] = $file; } } From adc5aae6f880b66db5b9e9855f6bc9abfb4b6dbe Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 20 Apr 2026 13:00:41 -0700 Subject: [PATCH 015/160] wip --- src/Kernel.php | 1 + src/Plugins/Tia.php | 106 +++++++++++++++--- src/Plugins/Tia/CoverageCollector.php | 152 ++++++++++++++++++++++++++ 3 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 src/Plugins/Tia/CoverageCollector.php diff --git a/src/Kernel.php b/src/Kernel.php index ad5c45125..bf57f51e5 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -67,6 +67,7 @@ public static function boot(TestSuite $testSuite, InputInterface $input, OutputI ->add(OutputInterface::class, $output) ->add(Container::class, $container) ->add(Tia\Recorder::class, new Tia\Recorder) + ->add(Tia\CoverageCollector::class, new Tia\CoverageCollector) ->add(Tia\WatchPatterns::class, new Tia\WatchPatterns) ->add(Tia\ResultCollector::class, new Tia\ResultCollector); diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 388553e83..a2d6ea5b1 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -9,6 +9,7 @@ use Pest\Contracts\Plugins\Terminable; use PHPUnit\Framework\TestStatus\TestStatus; use Pest\Plugins\Tia\ChangedFiles; +use Pest\Plugins\Tia\CoverageCollector; use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Graph; use Pest\Plugins\Tia\Recorder; @@ -99,6 +100,15 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable */ private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; + /** + * Global flag that tells workers to piggyback on PHPUnit's coverage + * driver (set by the parent whenever `--tia --coverage` is used). Workers + * can't infer this from their own argv because paratest forwards only + * `--coverage-php=` — not the `--coverage` flag Pest's Coverage + * plugin inspects. + */ + private const string PIGGYBACK_COVERAGE_GLOBAL = 'TIA_PIGGYBACK_COVERAGE'; + private bool $graphWritten = false; private bool $replayRan = false; @@ -186,9 +196,26 @@ private static function workerResultsGlob(): string return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.'*.json'; } + /** + * True when TIA is piggybacking on PHPUnit's own coverage driver. Toggled + * in `handleArguments` whenever `--tia` runs alongside `--coverage` so + * both the parent and workers read edges from the shared `CodeCoverage` + * instance instead of starting a second PCOV / Xdebug session. + */ + private bool $piggybackCoverage = false; + + /** + * True once we have committed to recording in this process — either by + * activating our own `Recorder` or by delegating to PHPUnit's coverage + * driver via `CoverageCollector`. `terminate()` only flushes when this + * is set, so runs that never entered record mode don't poke the graph. + */ + private bool $recordingActive = false; + public function __construct( private readonly OutputInterface $output, private readonly Recorder $recorder, + private readonly CoverageCollector $coverageCollector, private readonly WatchPatterns $watchPatterns, ) {} @@ -249,16 +276,17 @@ public function handleArguments(array $arguments): array $arguments = $this->popArgument(self::OPTION, $arguments); $arguments = $this->popArgument(self::REBUILD_OPTION, $arguments); - if ($this->coverageReportActive()) { - if (! $isWorker) { - $this->output->writeln( - ' TIA `--coverage` is active — TIA disabled to avoid '. - 'conflicting with PHPUnit\'s own coverage collection.', - ); - } - - return $arguments; - } + // When `--coverage` is active, piggyback on PHPUnit's CodeCoverage + // instead of starting our own PCOV / Xdebug session. Running two + // collectors against the same driver corrupts both — so we let + // PHPUnit drive, and read per-test edges from the shared instance + // at the end of the run via `CoverageCollector`. Workers can't + // detect `--coverage` from their own argv (paratest strips it, + // keeping only `--coverage-php=`) so the parent broadcasts + // via a global. + $this->piggybackCoverage = $isWorker + ? (string) Parallel::getGlobal(self::PIGGYBACK_COVERAGE_GLOBAL) === '1' + : $this->coverageReportActive(); $projectRoot = TestSuite::getInstance()->rootPath; @@ -285,17 +313,20 @@ public function terminate(): void $recorder = $this->recorder; - if (! $recorder->isActive()) { + if (! $this->recordingActive && ! $recorder->isActive()) { return; } $this->graphWritten = true; $projectRoot = TestSuite::getInstance()->rootPath; - $perTest = $recorder->perTestFiles(); + $perTest = $this->piggybackCoverage + ? $this->coverageCollector->perTestFiles() + : $recorder->perTestFiles(); if ($perTest === []) { $recorder->reset(); + $this->coverageCollector->reset(); return; } @@ -303,6 +334,7 @@ public function terminate(): void if (Parallel::isWorker()) { $this->flushWorkerPartial($projectRoot, $perTest); $recorder->reset(); + $this->coverageCollector->reset(); return; } @@ -330,6 +362,7 @@ public function terminate(): void )); $recorder->reset(); + $this->coverageCollector->reset(); } /** @@ -472,10 +505,22 @@ private function handleParent(array $arguments, string $projectRoot, bool $force } } - if ($graph instanceof Graph) { + // Force record mode whenever `--coverage` is active. Replay short- + // circuits tests via cached results, which would make their code + // paths invisible to PHPUnit's coverage driver and tank the report. + // A `--tia --coverage` run is the one the user wants FULL coverage + // from — we just harvest graph edges alongside, to feed future + // `--tia` (no `--coverage`) runs. + if ($graph instanceof Graph && ! $this->piggybackCoverage) { return $this->enterReplayMode($graph, $projectRoot, $arguments); } + if ($graph instanceof Graph && $this->piggybackCoverage) { + $this->output->writeln( + ' TIA `--coverage` active — running full suite and refreshing graph.', + ); + } + return $this->enterRecordMode($projectRoot, $arguments); } @@ -501,6 +546,15 @@ private function handleWorker(array $arguments, string $projectRoot, bool $recor return $arguments; } + // Piggyback: PHPUnit starts its coverage driver, `CoverageCollector` + // harvests the per-test edges in `terminate()`. The Recorder stays + // idle — starting our own driver would corrupt PHPUnit's data. + if ($this->piggybackCoverage) { + $this->recordingActive = true; + + return $arguments; + } + $recorder = $this->recorder; if (! $recorder->driverAvailable()) { @@ -511,6 +565,7 @@ private function handleWorker(array $arguments, string $projectRoot, bool $recor } $recorder->activate(); + $this->recordingActive = true; return $arguments; } @@ -656,7 +711,11 @@ private function enterRecordMode(string $projectRoot, array $arguments): array { $recorder = $this->recorder; - if (! $recorder->driverAvailable()) { + // Piggyback: PHPUnit's coverage driver is already running under + // `--coverage`. We don't need our own driver — `CoverageCollector` + // harvests the per-test edges from PHPUnit's shared `CodeCoverage` + // at terminate time. Skip the driver check entirely in this mode. + if (! $this->piggybackCoverage && ! $recorder->driverAvailable()) { // Both series and parallel record require the coverage driver. // Parallel also requires it because workers inherit the parent's // PHP config — if the parent lacks the driver, workers will too @@ -677,8 +736,24 @@ private function enterRecordMode(string $projectRoot, array $arguments): array Parallel::setGlobal(self::RECORDING_GLOBAL, '1'); + if ($this->piggybackCoverage) { + Parallel::setGlobal(self::PIGGYBACK_COVERAGE_GLOBAL, '1'); + } + + $this->output->writeln($this->piggybackCoverage + ? ' TIA recording dependency graph in parallel via `--coverage` (first run) — '. + 'subsequent `--tia` runs will only re-execute affected tests.' + : ' TIA recording dependency graph in parallel (first run) — '. + 'subsequent `--tia` runs will only re-execute affected tests.'); + + return $arguments; + } + + if ($this->piggybackCoverage) { + $this->recordingActive = true; + $this->output->writeln( - ' TIA recording dependency graph in parallel (first run) — '. + ' TIA recording dependency graph via `--coverage` (first run) — '. 'subsequent `--tia` runs will only re-execute affected tests.', ); @@ -686,6 +761,7 @@ private function enterRecordMode(string $projectRoot, array $arguments): array } $recorder->activate(); + $this->recordingActive = true; $this->output->writeln(sprintf( ' TIA recording dependency graph via %s (first run) — '. diff --git a/src/Plugins/Tia/CoverageCollector.php b/src/Plugins/Tia/CoverageCollector.php new file mode 100644 index 000000000..c1109afac --- /dev/null +++ b/src/Plugins/Tia/CoverageCollector.php @@ -0,0 +1,152 @@ +valueObjectForEvents()->id()`, e.g. `Foo\BarTest::baz`). The + * per-file / per-line coverage map therefore already carries everything + * we need to rebuild TIA edges at the end of the run. + * + * @internal + */ +final class CoverageCollector +{ + /** + * Cached `className → test file` lookups. Class reflection is cheap + * individually but the record run can visit tens of thousands of + * samples, so the cache matters. + * + * @var array + */ + private array $classFileCache = []; + + /** + * Rebuilds the same `absolute test file → list` + * shape that `Recorder::perTestFiles()` exposes, so callers can treat + * the two collectors interchangeably when feeding the graph. + * + * @return array> + */ + public function perTestFiles(): array + { + if (! PhpUnitCodeCoverage::instance()->isActive()) { + return []; + } + + try { + $lineCoverage = PhpUnitCodeCoverage::instance() + ->codeCoverage() + ->getData() + ->lineCoverage(); + } catch (Throwable) { + return []; + } + + /** @var array> $edges */ + $edges = []; + + foreach ($lineCoverage as $sourceFile => $lines) { + // Collect the set of tests that hit any line in this file once, + // then emit one edge per (testFile, sourceFile) pair. Walking + // the lines per test would re-resolve the test file repeatedly. + $testIds = []; + + foreach ($lines as $hits) { + if ($hits === null) { + continue; + } + + foreach ($hits as $id) { + $testIds[$id] = true; + } + } + + foreach (array_keys($testIds) as $testId) { + $testFile = $this->testIdToFile($testId); + + if ($testFile === null) { + continue; + } + + $edges[$testFile][$sourceFile] = true; + } + } + + $out = []; + + foreach ($edges as $testFile => $sources) { + $out[$testFile] = array_keys($sources); + } + + return $out; + } + + public function reset(): void + { + $this->classFileCache = []; + } + + private function testIdToFile(string $testId): ?string + { + // PHPUnit's test id is `ClassName::methodName` with an optional + // `#dataSetName` suffix for data-provider runs. Strip the dataset + // part — we only need the class. + $hash = strpos($testId, '#'); + $identifier = $hash === false ? $testId : substr($testId, 0, $hash); + + if (! str_contains($identifier, '::')) { + return null; + } + + [$className] = explode('::', $identifier, 2); + + if (array_key_exists($className, $this->classFileCache)) { + return $this->classFileCache[$className]; + } + + $file = $this->resolveClassFile($className); + $this->classFileCache[$className] = $file; + + return $file; + } + + private function resolveClassFile(string $className): ?string + { + if (! class_exists($className, false)) { + return null; + } + + $reflection = new ReflectionClass($className); + + // Pest's eval'd test classes expose the original `.php` path on a + // static `$__filename`. The eval'd class itself has no file of its + // own, so prefer this property when present. + if ($reflection->hasProperty('__filename')) { + $property = $reflection->getProperty('__filename'); + + if ($property->isStatic()) { + $value = $property->getValue(); + + if (is_string($value)) { + return $value; + } + } + } + + $file = $reflection->getFileName(); + + return is_string($file) ? $file : null; + } +} From 0d99c33c4edbb712dbd0a04c244b6f483b93c197 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 20 Apr 2026 13:16:59 -0700 Subject: [PATCH 016/160] wip --- src/Plugins/Tia.php | 44 ++++++--- src/Plugins/Tia/CoverageMerger.php | 149 +++++++++++++++++++++++++++++ src/Support/Coverage.php | 6 ++ 3 files changed, 187 insertions(+), 12 deletions(-) create mode 100644 src/Plugins/Tia/CoverageMerger.php diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index a2d6ea5b1..348fa1e10 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -85,6 +85,21 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private const string AFFECTED_FILE = 'tia-affected.json'; + /** + * Cache file holding PHPUnit's `CodeCoverage` object from the last + * `--tia --coverage` run. When the next run replays most tests from + * the TIA graph, only the affected tests produce fresh coverage; the + * rest is merged in from this cache so the report stays complete. + */ + private const string COVERAGE_CACHE_FILE = 'tia-coverage.php'; + + /** + * Marker file dropped by `Tia` to tell `Support\Coverage` to apply the + * merge. Absent on plain `--coverage` runs so non-TIA usage keeps its + * current (narrow) behaviour. + */ + private const string COVERAGE_MARKER_FILE = 'tia-coverage.marker'; + private const string WORKER_PREFIX = 'tia-worker-'; private const string WORKER_RESULTS_PREFIX = 'tia-worker-results-'; @@ -196,6 +211,16 @@ private static function workerResultsGlob(): string return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.'*.json'; } + public static function coverageCachePath(): string + { + return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_CACHE_FILE; + } + + public static function coverageMarkerPath(): string + { + return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_MARKER_FILE; + } + /** * True when TIA is piggybacking on PHPUnit's own coverage driver. Toggled * in `handleArguments` whenever `--tia` runs alongside `--coverage` so @@ -505,20 +530,15 @@ private function handleParent(array $arguments, string $projectRoot, bool $force } } - // Force record mode whenever `--coverage` is active. Replay short- - // circuits tests via cached results, which would make their code - // paths invisible to PHPUnit's coverage driver and tank the report. - // A `--tia --coverage` run is the one the user wants FULL coverage - // from — we just harvest graph edges alongside, to feed future - // `--tia` (no `--coverage`) runs. - if ($graph instanceof Graph && ! $this->piggybackCoverage) { - return $this->enterReplayMode($graph, $projectRoot, $arguments); + // Drop the marker so `Support\Coverage::report()` knows to merge the + // current (narrow) coverage with the cached full-run snapshot. Plain + // `--coverage` runs don't drop it, so their behaviour is untouched. + if ($this->piggybackCoverage) { + @file_put_contents(self::coverageMarkerPath(), ''); } - if ($graph instanceof Graph && $this->piggybackCoverage) { - $this->output->writeln( - ' TIA `--coverage` active — running full suite and refreshing graph.', - ); + if ($graph instanceof Graph) { + return $this->enterReplayMode($graph, $projectRoot, $arguments); } return $this->enterRecordMode($projectRoot, $arguments); diff --git a/src/Plugins/Tia/CoverageMerger.php b/src/Plugins/Tia/CoverageMerger.php new file mode 100644 index 000000000..3094c8bc8 --- /dev/null +++ b/src/Plugins/Tia/CoverageMerger.php @@ -0,0 +1,149 @@ +merge($current); + + // Serialise the merged object back using PHPUnit's own "return + // expression" PHP format. Using `var_export` on the serialised + // payload keeps the file self-contained and independent of + // PHPUnit's internal exporter — the reader only needs to + // `require` it back. + $serialised = "getData(); + $lineCoverage = $cachedData->lineCoverage(); + + foreach ($lineCoverage as $file => $lines) { + foreach ($lines as $line => $ids) { + if ($ids === null || $ids === []) { + continue; + } + + $filtered = array_values(array_diff($ids, $currentIds)); + + if ($filtered !== $ids) { + $lineCoverage[$file][$line] = $filtered; + } + } + } + + $cachedData->setLineCoverage($lineCoverage); + } + + /** + * @return array + */ + private static function collectTestIds(CodeCoverage $coverage): array + { + $ids = []; + + foreach ($coverage->getData()->lineCoverage() as $lines) { + foreach ($lines as $hits) { + if ($hits === null) { + continue; + } + + foreach ($hits as $id) { + $ids[$id] = true; + } + } + } + + return array_keys($ids); + } +} diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php index a36dd8edb..6845ad4eb 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -88,6 +88,12 @@ public static function report(OutputInterface $output, bool $compact = false, bo throw ShouldNotHappen::fromMessage(sprintf('Coverage not found in path: %s.', $reportPath)); } + // If TIA's marker is present, this run executed only the affected + // tests. Merge their fresh coverage slice into the cached full-run + // snapshot (stored by the previous `--tia --coverage` pass) so the + // report reflects the entire suite, not just what re-ran. + \Pest\Plugins\Tia\CoverageMerger::applyIfMarked($reportPath); + /** @var CodeCoverage $codeCoverage */ $codeCoverage = require $reportPath; unlink($reportPath); From 55a3394f8c24772e7429805d3dd6bc7fb91e7cf0 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 20 Apr 2026 13:31:43 -0700 Subject: [PATCH 017/160] wip --- src/Plugins/Tia.php | 69 ++++++++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 20 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 348fa1e10..3503dd1bc 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -328,11 +328,13 @@ public function terminate(): void return; } - // Worker in replay mode: flush the ResultCollector + replay counter - // into a partial so the parent can merge them into the graph after - // paratest returns. Parent's own ResultCollector is empty in parallel - // runs because workers — not the parent — execute the tests. - if (Parallel::isWorker() && $this->replayGraph !== null) { + // Flush the ResultCollector + replay counter from workers into a + // partial so the parent can merge them. Needed during replay so the + // summary is accurate, and also during the initial record run so + // the graph lands with results on first write — otherwise the next + // run would load a graph with edges but empty results, miss the + // cache for every test, and look pointlessly slow. + if (Parallel::isWorker() && ($this->replayGraph !== null || $this->recordingActive)) { $this->flushWorkerReplay(); } @@ -367,9 +369,20 @@ public function terminate(): void // Non-parallel record path: straight into the main cache. $cachePath = self::cachePath(); + $changedFiles = new ChangedFiles($projectRoot); + $currentSha = $changedFiles->currentSha(); + $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); $graph->setFingerprint(Fingerprint::compute($projectRoot)); - $graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha()); + $graph->setRecordedAtSha($this->branch, $currentSha); + // Snapshot whatever is currently dirty in the working tree. Without + // this, the very first `--tia` replay would see those same files + // via `since()` and report them as "changed" — even though they're + // identical to what we just recorded against. + $graph->setLastRunTree( + $this->branch, + $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), + ); $graph->replaceEdges($perTest); $graph->pruneMissingTests(); @@ -406,25 +419,26 @@ public function addOutput(int $exitCode): int // 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) { - // In parallel runs the workers executed the tests, so their - // ResultCollector + replay counter live in other processes. Pull - // those partials in before both the summary and the graph - // snapshot so the parent state reflects the whole run. - if (Parallel::isEnabled()) { - $this->mergeWorkerReplayPartials(); - } + // In parallel runs the workers executed the tests, so their + // ResultCollector + replay counter live in other processes. Pull + // those partials in first — both replay and record paths need them: + // replay to make the summary accurate, record so the initial graph + // lands with results instead of a second "warm-up" run being needed + // before replay is actually fast. + if (Parallel::isEnabled()) { + $this->mergeWorkerReplayPartials(); + } + if ($this->replayRan) { $this->bumpRecordedSha(); $this->emitReplaySummary(); } - // 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') { + // Series path: graph was already written by `terminate()` (or + // nothing to record). Snapshot results now so they ride along. + $this->snapshotTestResults(); + return $exitCode; } @@ -437,9 +451,18 @@ public function addOutput(int $exitCode): int $cachePath = self::cachePath(); + $changedFiles = new ChangedFiles($projectRoot); + $currentSha = $changedFiles->currentSha(); + $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); $graph->setFingerprint(Fingerprint::compute($projectRoot)); - $graph->setRecordedAtSha($this->branch, (new ChangedFiles($projectRoot))->currentSha()); + $graph->setRecordedAtSha($this->branch, $currentSha); + // Snapshot any currently-dirty files so the first replay run + // doesn't mis-report them as changed. See the series record path. + $graph->setLastRunTree( + $this->branch, + $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), + ); $merged = []; @@ -485,6 +508,12 @@ public function addOutput(int $exitCode): int self::CACHE_FILE, )); + // Persist per-test results (merged from worker partials above) into + // the freshly-written graph. Without this the graph would ship with + // edges but no results, and the very next `--tia` run would miss + // cache for every test even though nothing changed. + $this->snapshotTestResults(); + return $exitCode; } From 59e781e77b6995c7cab1bb3cc9b753f7dd75db62 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 20 Apr 2026 13:48:05 -0700 Subject: [PATCH 018/160] wip --- src/Kernel.php | 3 +- src/Plugins/Tia.php | 276 ++++++++++------------------ src/Plugins/Tia/Contracts/State.php | 48 +++++ src/Plugins/Tia/CoverageMerger.php | 101 ++++++---- src/Plugins/Tia/FileState.php | 150 +++++++++++++++ src/Plugins/Tia/Graph.php | 52 ++---- 6 files changed, 383 insertions(+), 247 deletions(-) create mode 100644 src/Plugins/Tia/Contracts/State.php create mode 100644 src/Plugins/Tia/FileState.php diff --git a/src/Kernel.php b/src/Kernel.php index bf57f51e5..e0ff28c82 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -69,7 +69,8 @@ public static function boot(TestSuite $testSuite, InputInterface $input, OutputI ->add(Tia\Recorder::class, new Tia\Recorder) ->add(Tia\CoverageCollector::class, new Tia\CoverageCollector) ->add(Tia\WatchPatterns::class, new Tia\WatchPatterns) - ->add(Tia\ResultCollector::class, new Tia\ResultCollector); + ->add(Tia\ResultCollector::class, new Tia\ResultCollector) + ->add(Tia\Contracts\State::class, new Tia\FileState(__DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'.temp')); $kernel = new self( new Application, diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 3503dd1bc..2f9990ff8 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -9,6 +9,7 @@ use Pest\Contracts\Plugins\Terminable; use PHPUnit\Framework\TestStatus\TestStatus; use Pest\Plugins\Tia\ChangedFiles; +use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\CoverageCollector; use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Graph; @@ -72,37 +73,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private const string REBUILD_OPTION = '--tia-rebuild'; /** - * 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. + * State keys under which TIA persists its blobs. Kept here as constants + * (rather than scattered strings) so the storage layout is visible in + * one place, and so `CoverageMerger` can reference the same keys. */ - private const string TEMP_DIR = __DIR__ - .DIRECTORY_SEPARATOR.'..' - .DIRECTORY_SEPARATOR.'..' - .DIRECTORY_SEPARATOR.'.temp'; + public const string KEY_GRAPH = 'tia.json'; - private const string CACHE_FILE = 'tia.json'; + public const string KEY_AFFECTED = 'tia-affected.json'; - private const string AFFECTED_FILE = 'tia-affected.json'; + private const string KEY_WORKER_EDGES_PREFIX = 'tia-worker-edges-'; + + private const string KEY_WORKER_RESULTS_PREFIX = 'tia-worker-results-'; /** - * Cache file holding PHPUnit's `CodeCoverage` object from the last - * `--tia --coverage` run. When the next run replays most tests from - * the TIA graph, only the affected tests produce fresh coverage; the - * rest is merged in from this cache so the report stays complete. + * Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage` + * run. Stored as bytes so the backend stays JSON/file-agnostic — the + * merger un/serialises rather than `require`-ing a PHP file. */ - private const string COVERAGE_CACHE_FILE = 'tia-coverage.php'; + public const string KEY_COVERAGE_CACHE = 'tia-coverage.bin'; /** - * Marker file dropped by `Tia` to tell `Support\Coverage` to apply the + * Marker key dropped by `Tia` to tell `Support\Coverage` to apply the * merge. Absent on plain `--coverage` runs so non-TIA usage keeps its * current (narrow) behaviour. */ - private const string COVERAGE_MARKER_FILE = 'tia-coverage.marker'; - - private const string WORKER_PREFIX = 'tia-worker-'; - - private const string WORKER_RESULTS_PREFIX = 'tia-worker-results-'; + public const string KEY_COVERAGE_MARKER = 'tia-coverage.marker'; /** * Global flag toggled by the parent process so workers know to record. @@ -168,57 +163,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable */ 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'; - } - - private static function workerResultsPath(string $token): string + private static function workerEdgesKey(string $token): string { - return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.$token.'.json'; + return self::KEY_WORKER_EDGES_PREFIX.$token.'.json'; } - private static function workerResultsGlob(): string + private static function workerResultsKey(string $token): string { - return self::tempDir().DIRECTORY_SEPARATOR.self::WORKER_RESULTS_PREFIX.'*.json'; - } - - public static function coverageCachePath(): string - { - return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_CACHE_FILE; - } - - public static function coverageMarkerPath(): string - { - return self::tempDir().DIRECTORY_SEPARATOR.self::COVERAGE_MARKER_FILE; + return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json'; } /** @@ -242,8 +194,37 @@ public function __construct( private readonly Recorder $recorder, private readonly CoverageCollector $coverageCollector, private readonly WatchPatterns $watchPatterns, + private readonly State $state, ) {} + /** + * Convenience wrapper: load + decode the graph, or return `null` if no + * graph has been stored. Any call that needs to mutate + re-save the + * graph also goes through `saveGraph()` to keep bytes flowing through + * the `State` abstraction rather than filesystem paths. + */ + private function loadGraph(string $projectRoot): ?Graph + { + $json = $this->state->read(self::KEY_GRAPH); + + if ($json === null) { + return null; + } + + return Graph::decode($json, $projectRoot); + } + + private function saveGraph(Graph $graph): bool + { + $json = $graph->encode(); + + if ($json === null) { + return false; + } + + return $this->state->write(self::KEY_GRAPH, $json); + } + /** * Returns the cached result for the given test, or `null` if the test * must run (affected, unknown, or no replay mode active). @@ -367,12 +348,10 @@ public function terminate(): void } // Non-parallel record path: straight into the main cache. - $cachePath = self::cachePath(); - $changedFiles = new ChangedFiles($projectRoot); $currentSha = $changedFiles->currentSha(); - $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); + $graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot); $graph->setFingerprint(Fingerprint::compute($projectRoot)); $graph->setRecordedAtSha($this->branch, $currentSha); // Snapshot whatever is currently dirty in the working tree. Without @@ -386,17 +365,16 @@ public function terminate(): void $graph->replaceEdges($perTest); $graph->pruneMissingTests(); - if (! $graph->save($cachePath)) { - $this->output->writeln(' TIA failed to write graph to '.$cachePath); + if (! $this->saveGraph($graph)) { + $this->output->writeln(' TIA failed to write graph.'); $recorder->reset(); return; } $this->output->writeln(sprintf( - ' TIA graph recorded (%d test files) at %s', + ' TIA graph recorded (%d test files).', count($perTest), - self::CACHE_FILE, )); $recorder->reset(); @@ -443,18 +421,16 @@ public function addOutput(int $exitCode): int } $projectRoot = TestSuite::getInstance()->rootPath; - $partials = $this->collectWorkerPartials($projectRoot); + $partialKeys = $this->collectWorkerEdgesPartials(); - if ($partials === []) { + if ($partialKeys === []) { return $exitCode; } - $cachePath = self::cachePath(); - $changedFiles = new ChangedFiles($projectRoot); $currentSha = $changedFiles->currentSha(); - $graph = Graph::load($projectRoot, $cachePath) ?? new Graph($projectRoot); + $graph = $this->loadGraph($projectRoot) ?? new Graph($projectRoot); $graph->setFingerprint(Fingerprint::compute($projectRoot)); $graph->setRecordedAtSha($this->branch, $currentSha); // Snapshot any currently-dirty files so the first replay run @@ -466,8 +442,8 @@ public function addOutput(int $exitCode): int $merged = []; - foreach ($partials as $partialPath) { - $data = $this->readPartial($partialPath); + foreach ($partialKeys as $key) { + $data = $this->readPartial($key); if ($data === null) { continue; @@ -483,7 +459,7 @@ public function addOutput(int $exitCode): int } } - @unlink($partialPath); + $this->state->delete($key); } $finalised = []; @@ -495,17 +471,16 @@ public function addOutput(int $exitCode): int $graph->replaceEdges($finalised); $graph->pruneMissingTests(); - if (! $graph->save($cachePath)) { - $this->output->writeln(' TIA failed to write graph to '.$cachePath); + if (! $this->saveGraph($graph)) { + $this->output->writeln(' TIA failed to write graph.'); return $exitCode; } $this->output->writeln(sprintf( - ' TIA graph recorded (%d test files, %d worker partials) at %s', + ' TIA graph recorded (%d test files, %d worker partials).', count($finalised), - count($partials), - self::CACHE_FILE, + count($partialKeys), )); // Persist per-test results (merged from worker partials above) into @@ -533,10 +508,9 @@ private function handleParent(array $arguments, string $projectRoot, bool $force // 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); + $graph = $forceRebuild ? null : $this->loadGraph($projectRoot); if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) { $this->output->writeln( @@ -563,7 +537,15 @@ private function handleParent(array $arguments, string $projectRoot, bool $force // current (narrow) coverage with the cached full-run snapshot. Plain // `--coverage` runs don't drop it, so their behaviour is untouched. if ($this->piggybackCoverage) { - @file_put_contents(self::coverageMarkerPath(), ''); + $this->state->write(self::KEY_COVERAGE_MARKER, ''); + } + + // First `--tia --coverage` run has nothing to merge against: if we + // replay, the coverage driver sees only the affected tests and the + // report collapses to near-zero coverage. Fall back to recording + // (full suite) to seed the cache for next time. + if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) { + return $this->enterRecordMode($projectRoot, $arguments); } if ($graph instanceof Graph) { @@ -628,18 +610,15 @@ private function handleWorker(array $arguments, string $projectRoot, bool $recor */ private function installWorkerReplay(string $projectRoot): void { - $cachePath = self::cachePath(); - $affectedPath = self::affectedPath(); - - $graph = Graph::load($projectRoot, $cachePath); + $graph = $this->loadGraph($projectRoot); if (! $graph instanceof Graph) { return; } - $raw = @file_get_contents($affectedPath); + $raw = $this->state->read(self::KEY_AFFECTED); - if ($raw === false) { + if ($raw === null) { return; } @@ -724,32 +703,13 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum */ private function persistAffectedSet(string $projectRoot, array $affected): bool { - $path = self::affectedPath(); - $dir = dirname($path); - - if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { - return false; - } - $json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES); if ($json === false) { return false; } - $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; - - if (@file_put_contents($tmp, $json) === false) { - return false; - } - - if (! @rename($tmp, $path)) { - @unlink($tmp); - - return false; - } - - return true; + return $this->state->write(self::KEY_AFFECTED, $json); } /** @@ -838,49 +798,31 @@ private function emitCoverageDriverMissing(): void */ private function flushWorkerPartial(string $projectRoot, array $perTest): void { - $path = self::workerPath($this->workerToken()); - $dir = dirname($path); - - if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { - return; - } - $json = json_encode($perTest, JSON_UNESCAPED_SLASHES); if ($json === false) { return; } - $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; - - if (@file_put_contents($tmp, $json) === false) { - return; - } - - if (! @rename($tmp, $path)) { - @unlink($tmp); - } + $this->state->write(self::workerEdgesKey($this->workerToken()), $json); } /** - * @return array + * @return list State keys of per-worker edges partials. */ - private function collectWorkerPartials(string $projectRoot): array + private function collectWorkerEdgesPartials(): array { - $pattern = self::workerGlob(); - $matches = glob($pattern); - - return $matches === false ? [] : $matches; + return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX); } private function purgeWorkerPartials(string $projectRoot): void { - foreach ($this->collectWorkerPartials($projectRoot) as $path) { - @unlink($path); + foreach ($this->collectWorkerEdgesPartials() as $key) { + $this->state->delete($key); } - foreach ($this->collectWorkerReplayPartials() as $path) { - @unlink($path); + foreach ($this->collectWorkerReplayPartials() as $key) { + $this->state->delete($key); } } @@ -900,14 +842,6 @@ private function flushWorkerReplay(): void return; } - $token = $this->workerToken(); - $path = self::workerResultsPath($token); - $dir = dirname($path); - - if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { - return; - } - $json = json_encode([ 'results' => $results, 'replayed' => $this->replayedCount, @@ -917,25 +851,15 @@ private function flushWorkerReplay(): void return; } - $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; - - if (@file_put_contents($tmp, $json) === false) { - return; - } - - if (! @rename($tmp, $path)) { - @unlink($tmp); - } + $this->state->write(self::workerResultsKey($this->workerToken()), $json); } /** - * @return array + * @return list State keys of per-worker replay partials. */ private function collectWorkerReplayPartials(): array { - $matches = glob(self::workerResultsGlob()); - - return $matches === false ? [] : $matches; + return $this->state->keysWithPrefix(self::KEY_WORKER_RESULTS_PREFIX); } /** @@ -948,17 +872,15 @@ private function mergeWorkerReplayPartials(): void /** @var ResultCollector $collector */ $collector = Container::getInstance()->get(ResultCollector::class); - foreach ($this->collectWorkerReplayPartials() as $path) { - $raw = @file_get_contents($path); - - if ($raw === false) { - @unlink($path); + foreach ($this->collectWorkerReplayPartials() as $key) { + $raw = $this->state->read($key); + $this->state->delete($key); + if ($raw === null) { continue; } $decoded = json_decode($raw, true); - @unlink($path); if (! is_array($decoded)) { continue; @@ -1009,11 +931,11 @@ private function workerToken(): string /** * @return array>|null */ - private function readPartial(string $path): ?array + private function readPartial(string $key): ?array { - $raw = @file_get_contents($path); + $raw = $this->state->read($key); - if ($raw === false) { + if ($raw === null) { return null; } @@ -1079,9 +1001,8 @@ private function emitReplaySummary(): void private function bumpRecordedSha(): void { $projectRoot = TestSuite::getInstance()->rootPath; - $cachePath = self::cachePath(); - $graph = Graph::load($projectRoot, $cachePath); + $graph = $this->loadGraph($projectRoot); if (! $graph instanceof Graph) { return; @@ -1100,7 +1021,7 @@ private function bumpRecordedSha(): void $workingTreeFiles = $changedFiles->since($currentSha) ?? []; $graph->setLastRunTree($this->branch, $changedFiles->snapshotTree($workingTreeFiles)); - $graph->save($cachePath); + $this->saveGraph($graph); } /** @@ -1119,10 +1040,9 @@ private function snapshotTestResults(): void return; } - $cachePath = self::cachePath(); $projectRoot = TestSuite::getInstance()->rootPath; - $graph = Graph::load($projectRoot, $cachePath); + $graph = $this->loadGraph($projectRoot); if (! $graph instanceof Graph) { return; @@ -1132,7 +1052,7 @@ private function snapshotTestResults(): void $graph->setResult($this->branch, $testId, $result['status'], $result['message'], $result['time']); } - $graph->save($cachePath); + $this->saveGraph($graph); $collector->reset(); } diff --git a/src/Plugins/Tia/Contracts/State.php b/src/Plugins/Tia/Contracts/State.php new file mode 100644 index 000000000..b109e3ec2 --- /dev/null +++ b/src/Plugins/Tia/Contracts/State.php @@ -0,0 +1,48 @@ +.json`, etc.) without + * exposing backend-specific glob semantics. + * + * @return list + */ + public function keysWithPrefix(string $prefix): array; +} diff --git a/src/Plugins/Tia/CoverageMerger.php b/src/Plugins/Tia/CoverageMerger.php index 3094c8bc8..fb4820239 100644 --- a/src/Plugins/Tia/CoverageMerger.php +++ b/src/Plugins/Tia/CoverageMerger.php @@ -5,6 +5,8 @@ namespace Pest\Plugins\Tia; use Pest\Plugins\Tia; +use Pest\Plugins\Tia\Contracts\State; +use Pest\Support\Container; use SebastianBergmann\CodeCoverage\CodeCoverage; use Throwable; @@ -14,7 +16,7 @@ * executing only the affected tests. * * Invoked from `Pest\Support\Coverage::report()` right before the coverage - * file is consumed. A marker file dropped by the `Tia` plugin gates the + * file is consumed. A marker dropped by the `Tia` plugin gates the * behaviour — plain `--coverage` runs (no `--tia`) leave the marker absent * and therefore keep their existing semantics. * @@ -24,19 +26,17 @@ * Its `ProcessedCodeCoverageData` stores, per source file, per line, the * list of test IDs that covered that line. We: * - * 1. Load the cached snapshot (from a previous `--tia --coverage` run). + * 1. Load the cached snapshot from `State` (serialised bytes). * 2. Strip every test id that re-ran this time from the cached map — * the tests that ran now are the ones whose attribution is fresh. * 3. Merge the current run into the stripped cached snapshot via * `CodeCoverage::merge()`. * 4. Write the merged result back to the report path (so Pest's report - * generator sees the full suite) and to the cache path (for the + * generator sees the full suite) and back into `State` (for the * next invocation). - * 5. Remove the marker so subsequent plain `--coverage` runs are - * untouched. * * If no cache exists yet (first `--tia --coverage` run on this machine) - * we simply save the current file as the cache — nothing to merge yet. + * we serialise the current object and save it — nothing to merge yet. * * @internal */ @@ -44,35 +44,33 @@ final class CoverageMerger { public static function applyIfMarked(string $reportPath): void { - $markerPath = Tia::coverageMarkerPath(); + $state = self::state(); - if (! is_file($markerPath)) { + if ($state === null || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) { return; } - @unlink($markerPath); + $state->delete(Tia::KEY_COVERAGE_MARKER); - $cachePath = Tia::coverageCachePath(); + $cachedBytes = $state->read(Tia::KEY_COVERAGE_CACHE); - if (! is_file($cachePath)) { - // First `--tia --coverage` run: nothing cached yet, the current - // report is the full suite itself. Save it verbatim so the next - // run has a snapshot to merge against. - @copy($reportPath, $cachePath); + if ($cachedBytes === null) { + // First `--tia --coverage` run: nothing cached yet, so the + // current file already represents the full suite. Capture it + // verbatim (as serialised bytes) for next time. + $current = self::requireCoverage($reportPath); + + if ($current !== null) { + $state->write(Tia::KEY_COVERAGE_CACHE, serialize($current)); + } return; } - try { - /** @var CodeCoverage $cached */ - $cached = require $cachePath; + $cached = self::unserializeCoverage($cachedBytes); + $current = self::requireCoverage($reportPath); - /** @var CodeCoverage $current */ - $current = require $reportPath; - } catch (Throwable) { - // Corrupt cache or unreadable report — fall back to the plain - // PHPUnit behaviour (the existing `require $reportPath` in the - // caller still runs against the untouched file). + if ($cached === null || $current === null) { return; } @@ -80,15 +78,15 @@ public static function applyIfMarked(string $reportPath): void $cached->merge($current); - // Serialise the merged object back using PHPUnit's own "return - // expression" PHP format. Using `var_export` on the serialised - // payload keeps the file self-contained and independent of - // PHPUnit's internal exporter — the reader only needs to - // `require` it back. - $serialised = "write(Tia::KEY_COVERAGE_CACHE, $serialised); } /** @@ -146,4 +144,43 @@ private static function collectTestIds(CodeCoverage $coverage): array return array_keys($ids); } + + private static function state(): ?State + { + try { + $state = Container::getInstance()->get(State::class); + } catch (Throwable) { + return null; + } + + return $state instanceof State ? $state : null; + } + + private static function requireCoverage(string $reportPath): ?CodeCoverage + { + if (! is_file($reportPath)) { + return null; + } + + try { + /** @var mixed $value */ + $value = require $reportPath; + } catch (Throwable) { + return null; + } + + return $value instanceof CodeCoverage ? $value : null; + } + + private static function unserializeCoverage(string $bytes): ?CodeCoverage + { + try { + /** @var mixed $value */ + $value = @unserialize($bytes); + } catch (Throwable) { + return null; + } + + return $value instanceof CodeCoverage ? $value : null; + } } diff --git a/src/Plugins/Tia/FileState.php b/src/Plugins/Tia/FileState.php new file mode 100644 index 000000000..33060d8e8 --- /dev/null +++ b/src/Plugins/Tia/FileState.php @@ -0,0 +1,150 @@ +rootDir = rtrim($rootDir, DIRECTORY_SEPARATOR); + } + + public function read(string $key): ?string + { + $path = $this->pathFor($key); + + if (! is_file($path)) { + return null; + } + + $bytes = @file_get_contents($path); + + return $bytes === false ? null : $bytes; + } + + public function write(string $key, string $content): bool + { + if (! $this->ensureRoot()) { + return false; + } + + $path = $this->pathFor($key); + $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; + + if (@file_put_contents($tmp, $content) === false) { + return false; + } + + // Atomic rename — on POSIX filesystems this is a single-step + // replacement, so concurrent readers never see a half-written file. + if (! @rename($tmp, $path)) { + @unlink($tmp); + + return false; + } + + return true; + } + + public function delete(string $key): bool + { + $path = $this->pathFor($key); + + if (! is_file($path)) { + return true; + } + + return @unlink($path); + } + + public function exists(string $key): bool + { + return is_file($this->pathFor($key)); + } + + public function keysWithPrefix(string $prefix): array + { + $root = $this->resolvedRoot(); + + if ($root === null) { + return []; + } + + $pattern = $root.DIRECTORY_SEPARATOR.$prefix.'*'; + $matches = glob($pattern); + + if ($matches === false) { + return []; + } + + $keys = []; + + foreach ($matches as $path) { + $keys[] = basename($path); + } + + return $keys; + } + + /** + * Absolute path for `$key`. Not part of the interface — used by the + * coverage merger and similar callers that need direct filesystem + * access (e.g. `require` on a cached PHP file). Consumers that only + * deal in bytes should go through `read()` / `write()`. + */ + public function pathFor(string $key): string + { + return $this->rootDir.DIRECTORY_SEPARATOR.$key; + } + + /** + * Returns the resolved root if it exists already, otherwise `null`. + * Used by read-side helpers so they don't eagerly create the directory + * just to find nothing inside. + */ + private function resolvedRoot(): ?string + { + $resolved = @realpath($this->rootDir); + + return $resolved === false ? null : $resolved; + } + + /** + * Creates the root dir on demand. Returns false only when creation + * fails and the directory still isn't there afterwards. + */ + private function ensureRoot(): bool + { + if (is_dir($this->rootDir)) { + return true; + } + + if (@mkdir($this->rootDir, 0755, true)) { + return true; + } + + return is_dir($this->rootDir); + } +} diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 24643bb3b..5f4efc0a6 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -374,19 +374,15 @@ public function pruneMissingTests(): void } } - public static function load(string $projectRoot, string $path): ?self + /** + * Rebuilds a graph from its JSON representation. Returns `null` when + * the payload is missing, unreadable, or schema-incompatible. Separated + * from transport (state backend, file, etc.) so tests can feed bytes + * directly without touching disk. + */ + public static function decode(string $json, string $projectRoot): ?self { - if (! is_file($path)) { - return null; - } - - $raw = @file_get_contents($path); - - if ($raw === false) { - return null; - } - - $data = json_decode($raw, true); + $data = json_decode($json, true); if (! is_array($data) || ($data['schema'] ?? null) !== 1) { return null; @@ -402,14 +398,14 @@ public static function load(string $projectRoot, string $path): ?self return $graph; } - public function save(string $path): bool + /** + * Serialises the graph to its JSON on-disk form. Returns `null` if the + * payload can't be encoded (extremely rare — pathological UTF-8 only). + * Persistence is the caller's responsibility: write the returned bytes + * through whatever `State` implementation is in play. + */ + public function encode(): ?string { - $dir = dirname($path); - - if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { - return false; - } - $payload = [ 'schema' => 1, 'fingerprint' => $this->fingerprint, @@ -418,25 +414,9 @@ public function save(string $path): bool 'baselines' => $this->baselines, ]; - $tmp = $path.'.'.bin2hex(random_bytes(4)).'.tmp'; - $json = json_encode($payload, JSON_UNESCAPED_SLASHES); - if ($json === false) { - return false; - } - - if (@file_put_contents($tmp, $json) === false) { - return false; - } - - if (! @rename($tmp, $path)) { - @unlink($tmp); - - return false; - } - - return true; + return $json === false ? null : $json; } /** From 2892341c28458d24a900878c2bd42925073a75cf Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 20 Apr 2026 14:28:18 -0700 Subject: [PATCH 019/160] wip --- src/Kernel.php | 7 +- src/Plugins/Tia.php | 33 ++ src/Plugins/Tia/BaselineSync.php | 524 +++++++++++++++++++++++++++++++ 3 files changed, 563 insertions(+), 1 deletion(-) create mode 100644 src/Plugins/Tia/BaselineSync.php diff --git a/src/Kernel.php b/src/Kernel.php index e0ff28c82..965b1f6ba 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -70,7 +70,12 @@ public static function boot(TestSuite $testSuite, InputInterface $input, OutputI ->add(Tia\CoverageCollector::class, new Tia\CoverageCollector) ->add(Tia\WatchPatterns::class, new Tia\WatchPatterns) ->add(Tia\ResultCollector::class, new Tia\ResultCollector) - ->add(Tia\Contracts\State::class, new Tia\FileState(__DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'.temp')); + ->add(Tia\Contracts\State::class, new Tia\FileState(__DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'.temp')) + ->add(Tia\BaselineSync::class, new Tia\BaselineSync( + $container->get(Tia\Contracts\State::class), // @phpstan-ignore argument.type + $output, + $input, + )); $kernel = new self( new Application, diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 2f9990ff8..643f0e8a0 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -8,6 +8,7 @@ use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\Terminable; use PHPUnit\Framework\TestStatus\TestStatus; +use Pest\Plugins\Tia\BaselineSync; use Pest\Plugins\Tia\ChangedFiles; use Pest\Plugins\Tia\Contracts\State; use Pest\Plugins\Tia\CoverageCollector; @@ -72,6 +73,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private const string REBUILD_OPTION = '--tia-rebuild'; + private const string PUBLISH_OPTION = '--tia-publish'; + /** * State keys under which TIA persists its blobs. Kept here as constants * (rather than scattered strings) so the storage layout is visible in @@ -195,6 +198,7 @@ public function __construct( private readonly CoverageCollector $coverageCollector, private readonly WatchPatterns $watchPatterns, private readonly State $state, + private readonly BaselineSync $baselineSync, ) {} /** @@ -272,6 +276,16 @@ public function handleArguments(array $arguments): array $recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'; $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1'; + // `--tia-publish` is its own entry point: it neither records nor + // replays, it just uploads whatever baseline is already on disk + // and exits. Handled before the usual `--tia` gating so users can + // publish without also triggering a suite run. + if (! $isWorker && $this->hasArgument(self::PUBLISH_OPTION, $arguments)) { + $projectRoot = TestSuite::getInstance()->rootPath; + + exit($this->baselineSync->publish($projectRoot)); + } + $enabled = $this->hasArgument(self::OPTION, $arguments); $forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments); @@ -533,6 +547,25 @@ private function handleParent(array $arguments, string $projectRoot, bool $force } } + // No local graph and not being forced to rebuild from scratch: try + // to pull a team-shared baseline so fresh checkouts (new devs, CI + // containers) don't pay the full record cost. If the pull succeeds + // the graph is re-read and re-validated against the local env. + if ($graph === null && ! $forceRebuild) { + if ($this->baselineSync->fetchIfAvailable($projectRoot)) { + $graph = $this->loadGraph($projectRoot); + + if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) { + $this->output->writeln( + ' TIA pulled baseline fingerprint mismatch — discarding.', + ); + $this->state->delete(self::KEY_GRAPH); + $this->state->delete(self::KEY_COVERAGE_CACHE); + $graph = null; + } + } + } + // Drop the marker so `Support\Coverage::report()` knows to merge the // current (narrow) coverage with the cached full-run snapshot. Plain // `--coverage` runs don't drop it, so their behaviour is untouched. diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php new file mode 100644 index 000000000..40039dd75 --- /dev/null +++ b/src/Plugins/Tia/BaselineSync.php @@ -0,0 +1,524 @@ +detectGitHubRepo($projectRoot); + + if ($repo === null) { + return false; + } + + if (! $this->confirm($repo)) { + return false; + } + + $this->output->writeln(sprintf( + ' TIA fetching baseline from %s…', + $repo, + )); + + $graphJson = $this->download($repo, self::GRAPH_ASSET); + + if ($graphJson === null) { + $this->output->writeln( + ' TIA no baseline published yet — recording locally.', + ); + + return false; + } + + if (! $this->state->write(Tia::KEY_GRAPH, $graphJson)) { + return false; + } + + // Coverage cache is optional. The baseline is useful even without + // it (plain `--tia` never needs it) so don't fail the whole sync + // just because this asset is missing or slow. + $coverageBin = $this->download($repo, self::COVERAGE_ASSET); + + if ($coverageBin !== null) { + $this->state->write(Tia::KEY_COVERAGE_CACHE, $coverageBin); + } + + $this->output->writeln(sprintf( + ' TIA baseline ready (%s).', + $this->formatSize(strlen($graphJson) + strlen($coverageBin ?? '')), + )); + + return true; + } + + /** + * Publishes the *local* baseline to GitHub Releases under the + * conventional tag, creating the release on first run or uploading + * into the existing one otherwise. + * + * Uploading from a developer workstation is intentionally discouraged + * — CI is the authoritative publisher because its environment is + * reproducible, its working tree is clean, and its result cache + * isn't contaminated by local flakiness. The prompt here defaults to + * *No* to keep this an explicit, opt-in action. + * + * Returns a CLI-style exit code so the caller can `exit()` on it. + */ + public function publish(string $projectRoot): int + { + $graphBytes = $this->state->read(Tia::KEY_GRAPH); + + if ($graphBytes === null) { + $this->output->writeln([ + '', + ' TIA no local baseline to publish.', + ' Run ./vendor/bin/pest --tia first to record one, then retry.', + '', + ]); + + return 1; + } + + $repo = $this->detectGitHubRepo($projectRoot); + + if ($repo === null) { + $this->output->writeln([ + '', + ' TIA cannot infer a GitHub repo from .git/config.', + ' Publishing is supported only for GitHub-hosted projects.', + '', + ]); + + return 1; + } + + if (! $this->commandExists('gh')) { + $this->output->writeln([ + '', + ' TIA publishing requires the gh CLI.', + ' Install: https://cli.github.com', + '', + ]); + + return 1; + } + + $this->output->writeln([ + '', + ' WARNING Publishing local baselines is discouraged.', + '', + ' Local runs can bake flaky results or dirty working-tree state into the', + ' baseline, which your team then replays. CI-published baselines are safer.', + ' See https://pestphp.com/docs/tia/ci for the recommended workflow.', + '', + ]); + + if (! $this->confirmPublish($repo)) { + $this->output->writeln(' TIA publish cancelled.'); + + return 0; + } + + $tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-publish-'.bin2hex(random_bytes(4)); + + if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) { + $this->output->writeln(' TIA failed to create temp dir for upload.'); + + return 1; + } + + $graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET; + + if (@file_put_contents($graphPath, $graphBytes) === false) { + $this->cleanup($tmpDir); + + return 1; + } + + $filesToUpload = [$graphPath]; + + $coverageBytes = $this->state->read(Tia::KEY_COVERAGE_CACHE); + + if ($coverageBytes !== null) { + $coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET; + + if (@file_put_contents($coveragePath, $coverageBytes) !== false) { + $filesToUpload[] = $coveragePath; + } + } + + $this->output->writeln(sprintf( + ' TIA publishing to %s (tag %s)…', + $repo, + self::RELEASE_TAG, + )); + + $exitCode = $this->ghReleaseUploadOrCreate($repo, $filesToUpload); + $this->cleanup($tmpDir); + + if ($exitCode !== 0) { + $this->output->writeln(' TIA gh release failed.'); + + return $exitCode; + } + + $this->output->writeln(sprintf( + ' TIA baseline published (%s).', + $this->formatSize(strlen($graphBytes) + ($coverageBytes === null ? 0 : strlen($coverageBytes))), + )); + + return 0; + } + + /** + * Uploads into the existing release if present, falls back to + * creating the release with the assets attached on first run. + * + * @param array $files + */ + private function ghReleaseUploadOrCreate(string $repo, array $files): int + { + $uploadArgs = ['gh', 'release', 'upload', self::RELEASE_TAG, ...$files, '-R', $repo, '--clobber']; + $upload = new Process($uploadArgs); + $upload->setTimeout(300.0); + $upload->run(function (string $_, string $buffer): void { + $this->output->write($buffer); + }); + + if ($upload->isSuccessful()) { + return 0; + } + + // Release likely doesn't exist yet — create it, attaching the files. + $createArgs = [ + 'gh', 'release', 'create', self::RELEASE_TAG, + ...$files, + '-R', $repo, + '--title', 'Pest TIA baseline', + '--notes', 'Machine-generated baseline for Pest TIA. Do not edit manually.', + ]; + $create = new Process($createArgs); + $create->setTimeout(300.0); + $create->run(function (string $_, string $buffer): void { + $this->output->write($buffer); + }); + + return $create->isSuccessful() ? 0 : 1; + } + + private function confirmPublish(string $repo): bool + { + if (! $this->isTerminal()) { + return false; + } + + $this->output->writeln(sprintf( + ' Publish to %s (tag %s)? [y/N]', + $repo, + self::RELEASE_TAG, + )); + + $handle = @fopen('php://stdin', 'r'); + + if ($handle === false) { + return false; + } + + $line = fgets($handle); + fclose($handle); + + if ($line === false) { + return false; + } + + // Unlike the fetch prompt, this one defaults to *No*. Empty input + // or anything other than an explicit "y"/"yes" cancels. + $line = strtolower(trim($line)); + + return $line === 'y' || $line === 'yes'; + } + + /** + * Parses `.git/config` for the `origin` remote and extracts + * `org/repo`. Supports the two URL flavours git emits out of the box. + * Non-GitHub remotes (GitLab, Bitbucket, self-hosted) → null, which + * silently opts the repo out of auto-sync. + */ + private function detectGitHubRepo(string $projectRoot): ?string + { + $gitConfig = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; + + if (! is_file($gitConfig)) { + return null; + } + + $content = @file_get_contents($gitConfig); + + if ($content === false) { + return null; + } + + // Find the `[remote "origin"]` section and the first `url` line + // inside it. Tolerates INI whitespace quirks (tabs, CRLF). + if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $content, $match) !== 1) { + return null; + } + + $url = $match[1]; + + // SSH: git@github.com:org/repo(.git) + if (preg_match('#^git@github\.com:([\w.-]+/[\w.-]+?)(?:\.git)?$#', $url, $m) === 1) { + return $m[1]; + } + + // HTTPS: https://github.com/org/repo(.git) (optional trailing slash) + if (preg_match('#^https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#', $url, $m) === 1) { + return $m[1]; + } + + return null; + } + + /** + * One-shot Y/n prompt. Defaults to Y. In non-interactive shells (CI, + * piped input) returns false so scripted runs never hang waiting for + * input. + */ + private function confirm(string $repo): bool + { + if (! $this->isTerminal()) { + return false; + } + + $this->output->writeln(''); + $this->output->writeln(sprintf( + ' TIA no local cache — fetch baseline from %s? [Y/n]', + $repo, + )); + + $handle = @fopen('php://stdin', 'r'); + + if ($handle === false) { + return false; + } + + $line = fgets($handle); + fclose($handle); + + if ($line === false) { + return false; + } + + $line = strtolower(trim($line)); + + return $line === '' || $line === 'y' || $line === 'yes'; + } + + /** + * Real-TTY check for STDIN. Symfony's `isInteractive()` defaults to true + * unless `--no-interaction` is explicitly passed, which would make + * scripted invocations (CI, pipes, subshells) hang at a prompt nobody + * sees. Combining both signals is the safe default. + */ + private function isTerminal(): bool + { + if (! $this->input->isInteractive()) { + return false; + } + + if (! defined('STDIN')) { + return false; + } + + if (function_exists('posix_isatty')) { + return @posix_isatty(STDIN) === true; + } + + if (function_exists('stream_isatty')) { + return @stream_isatty(STDIN) === true; + } + + return false; + } + + /** + * Tries `gh` first (handles private repos + rate limiting via the + * user's GitHub auth), falls through to public HTTPS. Returns the + * raw asset bytes, or null on any failure. + */ + private function download(string $repo, string $asset): ?string + { + $viaGh = $this->downloadViaGh($repo, $asset); + + if ($viaGh !== null) { + return $viaGh; + } + + return $this->downloadViaHttps($repo, $asset); + } + + private function downloadViaGh(string $repo, string $asset): ?string + { + if (! $this->commandExists('gh')) { + return null; + } + + $tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-'.bin2hex(random_bytes(4)); + + if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) { + return null; + } + + $process = new Process([ + 'gh', 'release', 'download', self::RELEASE_TAG, + '-R', $repo, + '-p', $asset, + '-D', $tmpDir, + '--clobber', + ]); + $process->setTimeout(120.0); + $process->run(); + + $payload = null; + + if ($process->isSuccessful()) { + $path = $tmpDir.DIRECTORY_SEPARATOR.$asset; + + if (is_file($path)) { + $content = @file_get_contents($path); + $payload = $content === false ? null : $content; + } + } + + $this->cleanup($tmpDir); + + return $payload; + } + + private function downloadViaHttps(string $repo, string $asset): ?string + { + $url = sprintf( + 'https://github.com/%s/releases/download/%s/%s', + $repo, + self::RELEASE_TAG, + $asset, + ); + + $ctx = stream_context_create([ + 'http' => [ + 'timeout' => 120, + 'follow_location' => 1, + 'ignore_errors' => false, + ], + ]); + + $content = @file_get_contents($url, false, $ctx); + + return $content === false ? null : $content; + } + + private function commandExists(string $cmd): bool + { + $probe = new Process(['command', '-v', $cmd]); + $probe->run(); + + if ($probe->isSuccessful()) { + return true; + } + + $which = new Process(['which', $cmd]); + $which->run(); + + return $which->isSuccessful(); + } + + private function cleanup(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + $entries = glob($dir.DIRECTORY_SEPARATOR.'*'); + + if ($entries !== false) { + foreach ($entries as $entry) { + if (is_file($entry)) { + @unlink($entry); + } + } + } + + @rmdir($dir); + } + + private function formatSize(int $bytes): string + { + if ($bytes >= 1024 * 1024) { + return sprintf('%.1f MB', $bytes / 1024 / 1024); + } + + if ($bytes >= 1024) { + return sprintf('%.1f KB', $bytes / 1024); + } + + return $bytes.' B'; + } +} From 1476b529a127c01808b6fbbd6b3c83ac9d13978c Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 20 Apr 2026 19:56:23 -0700 Subject: [PATCH 020/160] wip --- src/Plugins/Tia.php | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 643f0e8a0..1454b9143 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -134,17 +134,19 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private int $replayedCount = 0; /** - * Captured at replay setup so the end-of-run summary can report the - * scope of the changes that drove the run. + * Counter-part of `$replayedCount`: every time `getCachedResult()` + * decides the test must execute (affected, unknown, or no cached + * result), we bump this. Together the two counters let the summary + * show "affected + replayed" in units of test methods, not test + * files, matching the "Tests: N" total Pest prints above. */ - private int $changedFileCount = 0; + private int $executedCount = 0; /** - * Captured at replay setup — number of tests the graph flagged as - * affected (i.e. should re-execute). May overshoot the actually- - * executed count when the user narrows with a path filter. + * Captured at replay setup so the end-of-run summary can report the + * scope of the changes that drove the run. */ - private int $affectedTestCount = 0; + private int $changedFileCount = 0; /** * Holds the graph during replay so `beforeEach` can look up cached @@ -248,11 +250,15 @@ public function getCachedResult(string $filename, string $testId): ?TestStatus // Affected files must re-execute. if ($rel !== null && isset($this->affectedFiles[$rel])) { + $this->executedCount++; + return null; } // Unknown files (not in graph) must execute — they're new. if ($rel === null || ! $this->replayGraph->knowsTest($rel)) { + $this->executedCount++; + return null; } @@ -262,6 +268,11 @@ public function getCachedResult(string $filename, string $testId): ?TestStatus if ($result !== null) { $this->replayedCount++; + } else { + // Graph knows the test file but has no stored result for this + // specific test id (new test, or first time seeing this method). + // It must execute. + $this->executedCount++; } return $result; @@ -701,7 +712,6 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum $affected = $changed === [] ? [] : $graph->affected($changed); $this->changedFileCount = count($changed); - $this->affectedTestCount = count($affected); $affectedSet = array_fill_keys($affected, true); @@ -871,13 +881,14 @@ private function flushWorkerReplay(): void $results = $collector->all(); - if ($results === [] && $this->replayedCount === 0) { + if ($results === [] && $this->replayedCount === 0 && $this->executedCount === 0) { return; } $json = json_encode([ 'results' => $results, 'replayed' => $this->replayedCount, + 'executed' => $this->executedCount, ], JSON_UNESCAPED_SLASHES); if ($json === false) { @@ -923,6 +934,10 @@ private function mergeWorkerReplayPartials(): void $this->replayedCount += $decoded['replayed']; } + if (isset($decoded['executed']) && is_int($decoded['executed'])) { + $this->executedCount += $decoded['executed']; + } + if (isset($decoded['results']) && is_array($decoded['results'])) { $normalised = []; @@ -1023,10 +1038,14 @@ private function readPartial(string $key): ?array */ private function emitReplaySummary(): void { + // `$executedCount` and `$replayedCount` are maintained in lockstep + // by `getCachedResult()` — every test id that hits that method bumps + // exactly one of them. Summing the two gives the test-method total + // that lines up with Pest's "Tests: N" banner directly above. $this->output->writeln(sprintf( ' TIA %d changed file(s) → %d affected, %d replayed.', $this->changedFileCount, - $this->affectedTestCount, + $this->executedCount, $this->replayedCount, )); } From a5915b16ab2adf2cb69144b5b76500c12c3e77cd Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Mon, 20 Apr 2026 20:58:38 -0700 Subject: [PATCH 021/160] wip --- src/Concerns/Testable.php | 20 +++++++- src/Kernel.php | 14 +----- src/Plugins/Tia.php | 35 +++++++++++++- src/Plugins/Tia/Bootstrapper.php | 47 +++++++++++++++++++ src/Plugins/Tia/Graph.php | 30 ++++++++++-- src/Plugins/Tia/ResultCollector.php | 22 +++++++-- ...sureTiaAssertionsAreRecordedOnFinished.php | 6 +++ 7 files changed, 152 insertions(+), 22 deletions(-) create mode 100644 src/Plugins/Tia/Bootstrapper.php diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 69c48debc..8ffbeefc3 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -249,6 +249,17 @@ protected function setUp(...$arguments): void return; } + // Risky tests have no public PHPUnit hook to replay as-risky. + // Best available: short-circuit as a pass so the test doesn't + // misreport as a failure. Aggregate risky totals won't + // survive replay — accepted trade-off until PHPUnit grows a + // programmatic risky-marker API. + if ($cached->isRisky()) { + $this->__cachedPass = true; + + return; + } + // Non-success: throw the matching PHPUnit exception. Runner // catches it and marks the test with the correct status so // skips, failures, incompletes and todos appear in output @@ -371,7 +382,14 @@ protected function tearDown(...$arguments): void private function __runTest(Closure $closure, ...$args): mixed { if ($this->__cachedPass) { - $this->addToAssertionCount(1); + // Feed the exact assertion count captured during the recorded + // run so Pest's "Tests: N passed (M assertions)" banner stays + // accurate on replay instead of collapsing to 1-per-test. + /** @var Tia $tia */ + $tia = Container::getInstance()->get(Tia::class); + $assertions = $tia->getCachedAssertions($this::class.'::'.$this->name()); + + $this->addToAssertionCount($assertions > 0 ? $assertions : 1); return null; } diff --git a/src/Kernel.php b/src/Kernel.php index 965b1f6ba..aef9bda7d 100644 --- a/src/Kernel.php +++ b/src/Kernel.php @@ -13,7 +13,6 @@ 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; @@ -37,6 +36,7 @@ */ private const array BOOTSTRAPPERS = [ Bootstrappers\BootOverrides::class, + Plugins\Tia\Bootstrapper::class, Bootstrappers\BootSubscribers::class, Bootstrappers\BootFiles::class, Bootstrappers\BootView::class, @@ -65,17 +65,7 @@ public static function boot(TestSuite $testSuite, InputInterface $input, OutputI ->add(TestSuite::class, $testSuite) ->add(InputInterface::class, $input) ->add(OutputInterface::class, $output) - ->add(Container::class, $container) - ->add(Tia\Recorder::class, new Tia\Recorder) - ->add(Tia\CoverageCollector::class, new Tia\CoverageCollector) - ->add(Tia\WatchPatterns::class, new Tia\WatchPatterns) - ->add(Tia\ResultCollector::class, new Tia\ResultCollector) - ->add(Tia\Contracts\State::class, new Tia\FileState(__DIR__.DIRECTORY_SEPARATOR.'..'.DIRECTORY_SEPARATOR.'.temp')) - ->add(Tia\BaselineSync::class, new Tia\BaselineSync( - $container->get(Tia\Contracts\State::class), // @phpstan-ignore argument.type - $output, - $input, - )); + ->add(Container::class, $container); $kernel = new self( new Application, diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 1454b9143..73df0ab28 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -142,6 +142,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable */ private int $executedCount = 0; + /** + * Cached assertion count per test id for the current replay run. Keyed + * by `ClassName::methodName`; populated when `getCachedResult()` hits + * cache and drained by `Testable::__runTest()` on the short-circuit + * path so the emitted count matches the recorded run. + * + * @var array + */ + private array $cachedAssertionsByTestId = []; + /** * Captured at replay setup so the end-of-run summary can report the * scope of the changes that drove the run. @@ -268,6 +278,11 @@ public function getCachedResult(string $filename, string $testId): ?TestStatus if ($result !== null) { $this->replayedCount++; + // Cache the assertion count alongside the status so `Testable` + // can emit the exact `addToAssertionCount()` at replay time + // without hitting the graph twice per test. + $assertions = $this->replayGraph->getAssertions($this->branch, $testId); + $this->cachedAssertionsByTestId[$testId] = $assertions ?? 0; } else { // Graph knows the test file but has no stored result for this // specific test id (new test, or first time seeing this method). @@ -278,6 +293,17 @@ public function getCachedResult(string $filename, string $testId): ?TestStatus return $result; } + /** + * Exact assertion count captured for the given test during its last + * recorded run. Returns `0` if unknown (new test, or old graph entry + * pre-dating assertion-count tracking). `Testable::__runTest` reads + * this to feed `addToAssertionCount()` instead of defaulting to 1. + */ + public function getCachedAssertions(string $testId): int + { + return $this->cachedAssertionsByTestId[$testId] ?? 0; + } + /** * {@inheritDoc} */ @@ -1101,7 +1127,14 @@ private function snapshotTestResults(): void } foreach ($results as $testId => $result) { - $graph->setResult($this->branch, $testId, $result['status'], $result['message'], $result['time']); + $graph->setResult( + $this->branch, + $testId, + $result['status'], + $result['message'], + $result['time'], + $result['assertions'], + ); } $this->saveGraph($graph); diff --git a/src/Plugins/Tia/Bootstrapper.php b/src/Plugins/Tia/Bootstrapper.php new file mode 100644 index 000000000..6abf2ea5a --- /dev/null +++ b/src/Plugins/Tia/Bootstrapper.php @@ -0,0 +1,47 @@ +container->add(State::class, new FileState($this->tempDir())); + } + + /** + * Resolve Pest's `.temp/` directory relative to this file so TIA's + * caches share the same location as the rest of Pest's transient + * state (PHPUnit result cache, coverage PHP dumps, etc.). + */ + private function tempDir(): string + { + return __DIR__ + .DIRECTORY_SEPARATOR.'..' + .DIRECTORY_SEPARATOR.'..' + .DIRECTORY_SEPARATOR.'..' + .DIRECTORY_SEPARATOR.'.temp'; + } +} diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 5f4efc0a6..64356312d 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -60,7 +60,7 @@ final class Graph * @var array, - * results: array + * results: array * }> */ private array $baselines = []; @@ -257,14 +257,36 @@ public function setRecordedAtSha(string $branch, ?string $sha): void $this->baselines[$branch]['sha'] = $sha; } - public function setResult(string $branch, string $testId, int $status, string $message, float $time): void + public function setResult(string $branch, string $testId, int $status, string $message, float $time, int $assertions = 0): void { $this->ensureBaseline($branch); $this->baselines[$branch]['results'][$testId] = [ - 'status' => $status, 'message' => $message, 'time' => $time, + 'status' => $status, + 'message' => $message, + 'time' => $time, + 'assertions' => $assertions, ]; } + /** + * Returns the cached assertion count for a test, or `null` if unknown. + * Callers use this to feed `addToAssertionCount()` at replay time so + * the "Tests: N passed (M assertions)" banner matches the recorded run + * instead of defaulting to 1 assertion per test. + */ + public function getAssertions(string $branch, string $testId, string $fallbackBranch = 'main'): ?int + { + $baseline = $this->baselineFor($branch, $fallbackBranch); + + if (! isset($baseline['results'][$testId]['assertions'])) { + return null; + } + + $value = $baseline['results'][$testId]['assertions']; + + return is_int($value) ? $value : null; + } + public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus { $baseline = $this->baselineFor($branch, $fallbackBranch); @@ -310,7 +332,7 @@ public function lastRunTree(string $branch, string $fallbackBranch = 'main'): ar } /** - * @return array{sha: ?string, tree: array, results: array} + * @return array{sha: ?string, tree: array, results: array} */ private function baselineFor(string $branch, string $fallbackBranch): array { diff --git a/src/Plugins/Tia/ResultCollector.php b/src/Plugins/Tia/ResultCollector.php index ceabb0d45..434b4220b 100644 --- a/src/Plugins/Tia/ResultCollector.php +++ b/src/Plugins/Tia/ResultCollector.php @@ -118,6 +118,17 @@ public function reset(): void $this->startTime = null; } + /** + * Called by the Finished subscriber after a test's outcome + assertion + * events have all fired. Clears the "currently recording" pointer so + * the next test's events don't get mis-attributed. + */ + public function finishTest(): void + { + $this->currentTestId = null; + $this->startTime = null; + } + private function record(int $status, string $message): void { if ($this->currentTestId === null) { @@ -128,14 +139,17 @@ private function record(int $status, string $message): void ? round(microtime(true) - $this->startTime, 3) : 0.0; + // PHPUnit can fire more than one outcome event per test — the + // canonical case is a risky pass (`Passed` then `ConsideredRisky`). + // Last-wins semantics preserve the most specific status; the + // existing assertion count (if any) survives the overwrite. + $existing = $this->results[$this->currentTestId] ?? null; + $this->results[$this->currentTestId] = [ 'status' => $status, 'message' => $message, 'time' => $time, - 'assertions' => 0, + 'assertions' => $existing['assertions'] ?? 0, ]; - - $this->currentTestId = null; - $this->startTime = null; } } diff --git a/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php index 122f72130..a6f1e2a9d 100644 --- a/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php +++ b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php @@ -30,5 +30,11 @@ public function notify(Finished $event): void $event->numberOfAssertionsPerformed(), ); } + + // Close the "currently recording" window on Finished so the next + // test's events don't get mis-attributed. Keeping the pointer open + // through the outcome subscribers is what lets a late-firing + // `ConsideredRisky` overwrite an earlier `Passed`. + $this->collector->finishTest(); } } From 7e4280bf83d334514604a9f66098053f88974a3e Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Tue, 21 Apr 2026 07:13:08 -0700 Subject: [PATCH 022/160] chore: improves feedback --- composer.json | 2 +- src/Plugins/Tia.php | 47 ++++++++++++++++---------------- src/Plugins/Tia/BaselineSync.php | 39 -------------------------- 3 files changed, 24 insertions(+), 64 deletions(-) diff --git a/composer.json b/composer.json index 4b2c4e284..1f3eadf26 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ "require": { "php": "^8.3.0", "brianium/paratest": "^7.20.0", - "nunomaduro/collision": "^8.9.3", + "nunomaduro/collision": "^8.9.4", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", "pestphp/pest-plugin-arch": "^4.0.2", diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 73df0ab28..79f1f2003 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -4,6 +4,7 @@ namespace Pest\Plugins; +use NunoMaduro\Collision\Adapters\Phpunit\Printers\DefaultPrinter; use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\Terminable; @@ -152,12 +153,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable */ private array $cachedAssertionsByTestId = []; - /** - * Captured at replay setup so the end-of-run summary can report the - * scope of the changes that drove the run. - */ - private int $changedFileCount = 0; - /** * Holds the graph during replay so `beforeEach` can look up cached * results without re-loading from disk on every test. @@ -460,7 +455,6 @@ public function addOutput(int $exitCode): int if ($this->replayRan) { $this->bumpRecordedSha(); - $this->emitReplaySummary(); } if ((string) Parallel::getGlobal(self::RECORDING_GLOBAL) !== '1') { @@ -737,14 +731,14 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum $affected = $changed === [] ? [] : $graph->affected($changed); - $this->changedFileCount = count($changed); - $affectedSet = array_fill_keys($affected, true); $this->replayRan = true; $this->replayGraph = $graph; $this->affectedFiles = $affectedSet; + $this->registerRecap(); + if (! Parallel::isEnabled()) { return $arguments; } @@ -1057,23 +1051,28 @@ private function readPartial(string $key): ?array * git still reports them as modified. */ /** - * Prints the post-run TIA summary. Runs after the test report so the - * replayed count reflects what actually happened (cache hits counted - * inside `getCachedResult`) rather than a graph-level estimate that - * ignores any CLI path filter the user passed in. + * Hooks a recap callback into Collision's `DefaultPrinter` so TIA's + * counts ride along the "Tests: N passed (M assertions, ...)" line + * instead of printing on their own block. Collision joins each + * callback's return value with a gray `, ` separator, so we return + * a single fragment like `728 replayed via tia` (or nothing when + * there's no replay activity to report). */ - private function emitReplaySummary(): void + private function registerRecap(): void { - // `$executedCount` and `$replayedCount` are maintained in lockstep - // by `getCachedResult()` — every test id that hits that method bumps - // exactly one of them. Summing the two gives the test-method total - // that lines up with Pest's "Tests: N" banner directly above. - $this->output->writeln(sprintf( - ' TIA %d changed file(s) → %d affected, %d replayed.', - $this->changedFileCount, - $this->executedCount, - $this->replayedCount, - )); + DefaultPrinter::addRecap(function (): string { + $fragments = []; + + if ($this->executedCount > 0) { + $fragments[] = $this->executedCount.' affected'; + } + + if ($this->replayedCount > 0) { + $fragments[] = $this->replayedCount.' replayed'; + } + + return $fragments === [] ? '' : implode(', ', $fragments); + }); } private function bumpRecordedSha(): void diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 40039dd75..d765e6f85 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -69,10 +69,6 @@ public function fetchIfAvailable(string $projectRoot): bool return false; } - if (! $this->confirm($repo)) { - return false; - } - $this->output->writeln(sprintf( ' TIA fetching baseline from %s…', $repo, @@ -337,41 +333,6 @@ private function detectGitHubRepo(string $projectRoot): ?string return null; } - /** - * One-shot Y/n prompt. Defaults to Y. In non-interactive shells (CI, - * piped input) returns false so scripted runs never hang waiting for - * input. - */ - private function confirm(string $repo): bool - { - if (! $this->isTerminal()) { - return false; - } - - $this->output->writeln(''); - $this->output->writeln(sprintf( - ' TIA no local cache — fetch baseline from %s? [Y/n]', - $repo, - )); - - $handle = @fopen('php://stdin', 'r'); - - if ($handle === false) { - return false; - } - - $line = fgets($handle); - fclose($handle); - - if ($line === false) { - return false; - } - - $line = strtolower(trim($line)); - - return $line === '' || $line === 'y' || $line === 'yes'; - } - /** * Real-TTY check for STDIN. Symfony's `isInteractive()` defaults to true * unless `--no-interaction` is explicitly passed, which would make From 0d66dc43226c11d268e318b7c62e40470ccc1b6f Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Tue, 21 Apr 2026 07:26:19 -0700 Subject: [PATCH 023/160] chore: removes https --- src/Plugins/Tia/BaselineSync.php | 61 +++++++++++++------------------- 1 file changed, 24 insertions(+), 37 deletions(-) diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index d765e6f85..6d04dcade 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -165,6 +165,9 @@ public function publish(string $projectRoot): int ' baseline, which your team then replays. CI-published baselines are safer.', ' See https://pestphp.com/docs/tia/ci for the recommended workflow.', '', + ' Release is created as a draft to avoid firing `release:published`', + ' workflows. Consumers must authenticate `gh` — anonymous downloads are out.', + '', ]); if (! $this->confirmPublish($repo)) { @@ -228,6 +231,15 @@ public function publish(string $projectRoot): int * Uploads into the existing release if present, falls back to * creating the release with the assets attached on first run. * + * The release is always created as a **draft**. Drafts don't fire the + * `release:published` GitHub Actions event, which matters a lot for + * repos that tie deploys to release events (e.g. `laravel/cloud`'s + * `deploy.yml` fires `deploy-staging → deploy-production` on any + * published release). Authenticated collaborators can still + * `gh release download` draft assets. Anonymous HTTPS downloads of + * draft assets don't work — acceptable for a feature aimed at teams + * that authenticate `gh` anyway. + * * @param array $files */ private function ghReleaseUploadOrCreate(string $repo, array $files): int @@ -243,11 +255,13 @@ private function ghReleaseUploadOrCreate(string $repo, array $files): int return 0; } - // Release likely doesn't exist yet — create it, attaching the files. + // Release likely doesn't exist yet — create it as a draft so it + // doesn't trigger release-gated workflows, attaching the files. $createArgs = [ 'gh', 'release', 'create', self::RELEASE_TAG, ...$files, '-R', $repo, + '--draft', '--title', 'Pest TIA baseline', '--notes', 'Machine-generated baseline for Pest TIA. Do not edit manually.', ]; @@ -361,22 +375,17 @@ private function isTerminal(): bool } /** - * Tries `gh` first (handles private repos + rate limiting via the - * user's GitHub auth), falls through to public HTTPS. Returns the - * raw asset bytes, or null on any failure. + * Downloads a release asset via `gh release download`. Returns the raw + * asset bytes, or null if `gh` isn't installed, the user isn't + * authenticated, the release is missing, or I/O fails. + * + * We publish baselines as **draft** releases (see + * `ghReleaseUploadOrCreate`) so `release:published` doesn't trigger + * deploy-gated workflows. Draft assets aren't served via anonymous + * HTTPS, so `gh` + repo auth is the only viable transport — no + * plain-HTTPS fallback. */ private function download(string $repo, string $asset): ?string - { - $viaGh = $this->downloadViaGh($repo, $asset); - - if ($viaGh !== null) { - return $viaGh; - } - - return $this->downloadViaHttps($repo, $asset); - } - - private function downloadViaGh(string $repo, string $asset): ?string { if (! $this->commandExists('gh')) { return null; @@ -414,28 +423,6 @@ private function downloadViaGh(string $repo, string $asset): ?string return $payload; } - private function downloadViaHttps(string $repo, string $asset): ?string - { - $url = sprintf( - 'https://github.com/%s/releases/download/%s/%s', - $repo, - self::RELEASE_TAG, - $asset, - ); - - $ctx = stream_context_create([ - 'http' => [ - 'timeout' => 120, - 'follow_location' => 1, - 'ignore_errors' => false, - ], - ]); - - $content = @file_get_contents($url, false, $ctx); - - return $content === false ? null : $content; - } - private function commandExists(string $cmd): bool { $probe = new Process(['command', '-v', $cmd]); From ed399af43e78458076af1042310c66948c7fa3c5 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Tue, 21 Apr 2026 07:41:50 -0700 Subject: [PATCH 024/160] wip --- src/Plugins/Tia.php | 12 - src/Plugins/Tia/BaselineSync.php | 373 ++++++++----------------------- 2 files changed, 96 insertions(+), 289 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 79f1f2003..cd44a9d6c 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -74,8 +74,6 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private const string REBUILD_OPTION = '--tia-rebuild'; - private const string PUBLISH_OPTION = '--tia-publish'; - /** * State keys under which TIA persists its blobs. Kept here as constants * (rather than scattered strings) so the storage layout is visible in @@ -308,16 +306,6 @@ public function handleArguments(array $arguments): array $recordingGlobal = $isWorker && (string) Parallel::getGlobal(self::RECORDING_GLOBAL) === '1'; $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1'; - // `--tia-publish` is its own entry point: it neither records nor - // replays, it just uploads whatever baseline is already on disk - // and exits. Handled before the usual `--tia` gating so users can - // publish without also triggering a suite run. - if (! $isWorker && $this->hasArgument(self::PUBLISH_OPTION, $arguments)) { - $projectRoot = TestSuite::getInstance()->rootPath; - - exit($this->baselineSync->publish($projectRoot)); - } - $enabled = $this->hasArgument(self::OPTION, $arguments); $forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments); diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 6d04dcade..d245ddf1e 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -6,7 +6,6 @@ use Pest\Plugins\Tia; use Pest\Plugins\Tia\Contracts\State; -use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Process\Process; @@ -15,15 +14,21 @@ * contributors and fresh CI workspaces start in replay mode instead of * paying the ~30s record cost. * - * The baseline lives as a GitHub Release with a fixed tag containing two - * assets — the graph JSON and the coverage cache. The repo is inferred - * from `.git/config`'s `origin` remote, so no per-project configuration - * is required. Non-GitHub remotes silently opt out. + * Storage: **workflow artifacts**, not releases. A dedicated CI workflow + * (conventionally `.github/workflows/tia-baseline.yml`) runs the full + * suite under `--tia` and uploads the resulting `tia.json` + + * `tia-coverage.bin` as a named artifact (`pest-tia-baseline`). On dev + * machines, this class finds the latest successful run of that workflow + * and downloads the artifact via `gh`. * - * Fetching is attempted in order: - * 1. `gh release download` — uses the user's existing GitHub auth, - * works for private repos. - * 2. Plain HTTPS — public-repo fallback when `gh` isn't installed. + * Why artifacts, not releases: + * - No tag is created → no `push` event cascade into CI workflows. + * - No release event → no deploy workflows tied to `release:published`. + * - Retention is run-scoped and tunable (1-90 days) instead of clobbering + * a single floating tag. + * - Publishing is strictly CI-only: artifacts can't be produced from a + * developer's laptop. This enforces the "CI is the authoritative + * publisher" policy that local-publish paths would otherwise erode. * * Fingerprint validation happens back in `Tia::handleParent` after the * blobs are written: a mismatched environment (different PHP version, @@ -35,14 +40,20 @@ final class BaselineSync { /** - * Conventional tag the CI recipe publishes under. Not configurable for - * MVP — if teams outgrow the convention, a `PEST_TIA_BASELINE_TAG` env - * var is the likely escape hatch. + * Conventional workflow filename teams publish from. Not configurable + * for MVP — teams that outgrow the default can set + * `PEST_TIA_BASELINE_WORKFLOW` later. */ - private const string RELEASE_TAG = 'pest-tia-baseline'; + private const string WORKFLOW_FILE = 'tia-baseline.yml'; /** - * Asset filenames within the release — mirror the state keys so the + * Artifact name the workflow uploads under. The artifact is a zip + * containing `tia.json` (always) + `tia-coverage.bin` (optional). + */ + private const string ARTIFACT_NAME = 'pest-tia-baseline'; + + /** + * Asset filenames inside the artifact — mirror the state keys so the * CI publisher and the sync consumer stay in lock-step. */ private const string GRAPH_ASSET = Tia::KEY_GRAPH; @@ -52,14 +63,13 @@ final class BaselineSync public function __construct( private readonly State $state, private readonly OutputInterface $output, - private readonly InputInterface $input, ) {} /** - * Attempts the full detect → prompt → download flow. Returns true when - * the graph blob was pulled and written to state. Coverage is best- - * effort: its absence doesn't fail the sync, since plain `--tia` (no - * `--coverage`) works fine without it. + * Detects the repo, fetches the latest baseline artifact, writes its + * contents into the TIA state store. Returns true when the graph blob + * landed; coverage is best-effort since plain `--tia` (no `--coverage`) + * never reads it. */ public function fetchIfAvailable(string $projectRoot): bool { @@ -74,9 +84,9 @@ public function fetchIfAvailable(string $projectRoot): bool $repo, )); - $graphJson = $this->download($repo, self::GRAPH_ASSET); + $payload = $this->download($repo); - if ($graphJson === null) { + if ($payload === null) { $this->output->writeln( ' TIA no baseline published yet — recording locally.', ); @@ -84,228 +94,22 @@ public function fetchIfAvailable(string $projectRoot): bool return false; } - if (! $this->state->write(Tia::KEY_GRAPH, $graphJson)) { + if (! $this->state->write(Tia::KEY_GRAPH, $payload['graph'])) { return false; } - // Coverage cache is optional. The baseline is useful even without - // it (plain `--tia` never needs it) so don't fail the whole sync - // just because this asset is missing or slow. - $coverageBin = $this->download($repo, self::COVERAGE_ASSET); - - if ($coverageBin !== null) { - $this->state->write(Tia::KEY_COVERAGE_CACHE, $coverageBin); + if ($payload['coverage'] !== null) { + $this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']); } $this->output->writeln(sprintf( ' TIA baseline ready (%s).', - $this->formatSize(strlen($graphJson) + strlen($coverageBin ?? '')), + $this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')), )); return true; } - /** - * Publishes the *local* baseline to GitHub Releases under the - * conventional tag, creating the release on first run or uploading - * into the existing one otherwise. - * - * Uploading from a developer workstation is intentionally discouraged - * — CI is the authoritative publisher because its environment is - * reproducible, its working tree is clean, and its result cache - * isn't contaminated by local flakiness. The prompt here defaults to - * *No* to keep this an explicit, opt-in action. - * - * Returns a CLI-style exit code so the caller can `exit()` on it. - */ - public function publish(string $projectRoot): int - { - $graphBytes = $this->state->read(Tia::KEY_GRAPH); - - if ($graphBytes === null) { - $this->output->writeln([ - '', - ' TIA no local baseline to publish.', - ' Run ./vendor/bin/pest --tia first to record one, then retry.', - '', - ]); - - return 1; - } - - $repo = $this->detectGitHubRepo($projectRoot); - - if ($repo === null) { - $this->output->writeln([ - '', - ' TIA cannot infer a GitHub repo from .git/config.', - ' Publishing is supported only for GitHub-hosted projects.', - '', - ]); - - return 1; - } - - if (! $this->commandExists('gh')) { - $this->output->writeln([ - '', - ' TIA publishing requires the gh CLI.', - ' Install: https://cli.github.com', - '', - ]); - - return 1; - } - - $this->output->writeln([ - '', - ' WARNING Publishing local baselines is discouraged.', - '', - ' Local runs can bake flaky results or dirty working-tree state into the', - ' baseline, which your team then replays. CI-published baselines are safer.', - ' See https://pestphp.com/docs/tia/ci for the recommended workflow.', - '', - ' Release is created as a draft to avoid firing `release:published`', - ' workflows. Consumers must authenticate `gh` — anonymous downloads are out.', - '', - ]); - - if (! $this->confirmPublish($repo)) { - $this->output->writeln(' TIA publish cancelled.'); - - return 0; - } - - $tmpDir = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-publish-'.bin2hex(random_bytes(4)); - - if (! @mkdir($tmpDir, 0755, true) && ! is_dir($tmpDir)) { - $this->output->writeln(' TIA failed to create temp dir for upload.'); - - return 1; - } - - $graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET; - - if (@file_put_contents($graphPath, $graphBytes) === false) { - $this->cleanup($tmpDir); - - return 1; - } - - $filesToUpload = [$graphPath]; - - $coverageBytes = $this->state->read(Tia::KEY_COVERAGE_CACHE); - - if ($coverageBytes !== null) { - $coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET; - - if (@file_put_contents($coveragePath, $coverageBytes) !== false) { - $filesToUpload[] = $coveragePath; - } - } - - $this->output->writeln(sprintf( - ' TIA publishing to %s (tag %s)…', - $repo, - self::RELEASE_TAG, - )); - - $exitCode = $this->ghReleaseUploadOrCreate($repo, $filesToUpload); - $this->cleanup($tmpDir); - - if ($exitCode !== 0) { - $this->output->writeln(' TIA gh release failed.'); - - return $exitCode; - } - - $this->output->writeln(sprintf( - ' TIA baseline published (%s).', - $this->formatSize(strlen($graphBytes) + ($coverageBytes === null ? 0 : strlen($coverageBytes))), - )); - - return 0; - } - - /** - * Uploads into the existing release if present, falls back to - * creating the release with the assets attached on first run. - * - * The release is always created as a **draft**. Drafts don't fire the - * `release:published` GitHub Actions event, which matters a lot for - * repos that tie deploys to release events (e.g. `laravel/cloud`'s - * `deploy.yml` fires `deploy-staging → deploy-production` on any - * published release). Authenticated collaborators can still - * `gh release download` draft assets. Anonymous HTTPS downloads of - * draft assets don't work — acceptable for a feature aimed at teams - * that authenticate `gh` anyway. - * - * @param array $files - */ - private function ghReleaseUploadOrCreate(string $repo, array $files): int - { - $uploadArgs = ['gh', 'release', 'upload', self::RELEASE_TAG, ...$files, '-R', $repo, '--clobber']; - $upload = new Process($uploadArgs); - $upload->setTimeout(300.0); - $upload->run(function (string $_, string $buffer): void { - $this->output->write($buffer); - }); - - if ($upload->isSuccessful()) { - return 0; - } - - // Release likely doesn't exist yet — create it as a draft so it - // doesn't trigger release-gated workflows, attaching the files. - $createArgs = [ - 'gh', 'release', 'create', self::RELEASE_TAG, - ...$files, - '-R', $repo, - '--draft', - '--title', 'Pest TIA baseline', - '--notes', 'Machine-generated baseline for Pest TIA. Do not edit manually.', - ]; - $create = new Process($createArgs); - $create->setTimeout(300.0); - $create->run(function (string $_, string $buffer): void { - $this->output->write($buffer); - }); - - return $create->isSuccessful() ? 0 : 1; - } - - private function confirmPublish(string $repo): bool - { - if (! $this->isTerminal()) { - return false; - } - - $this->output->writeln(sprintf( - ' Publish to %s (tag %s)? [y/N]', - $repo, - self::RELEASE_TAG, - )); - - $handle = @fopen('php://stdin', 'r'); - - if ($handle === false) { - return false; - } - - $line = fgets($handle); - fclose($handle); - - if ($line === false) { - return false; - } - - // Unlike the fetch prompt, this one defaults to *No*. Empty input - // or anything other than an explicit "y"/"yes" cancels. - $line = strtolower(trim($line)); - - return $line === 'y' || $line === 'yes'; - } - /** * Parses `.git/config` for the `origin` remote and extracts * `org/repo`. Supports the two URL flavours git emits out of the box. @@ -348,46 +152,23 @@ private function detectGitHubRepo(string $projectRoot): ?string } /** - * Real-TTY check for STDIN. Symfony's `isInteractive()` defaults to true - * unless `--no-interaction` is explicitly passed, which would make - * scripted invocations (CI, pipes, subshells) hang at a prompt nobody - * sees. Combining both signals is the safe default. + * Two-step fetch: find the latest successful run of the baseline + * workflow, then download the named artifact from it. Returns + * `['graph' => bytes, 'coverage' => bytes|null]` on success, or null + * if `gh` is unavailable, the workflow hasn't run yet, the artifact + * is missing, or any shell step fails. + * + * @return array{graph: string, coverage: ?string}|null */ - private function isTerminal(): bool + private function download(string $repo): ?array { - if (! $this->input->isInteractive()) { - return false; - } - - if (! defined('STDIN')) { - return false; - } - - if (function_exists('posix_isatty')) { - return @posix_isatty(STDIN) === true; + if (! $this->commandExists('gh')) { + return null; } - if (function_exists('stream_isatty')) { - return @stream_isatty(STDIN) === true; - } + $runId = $this->latestSuccessfulRunId($repo); - return false; - } - - /** - * Downloads a release asset via `gh release download`. Returns the raw - * asset bytes, or null if `gh` isn't installed, the user isn't - * authenticated, the release is missing, or I/O fails. - * - * We publish baselines as **draft** releases (see - * `ghReleaseUploadOrCreate`) so `release:published` doesn't trigger - * deploy-gated workflows. Draft assets aren't served via anonymous - * HTTPS, so `gh` + repo auth is the only viable transport — no - * plain-HTTPS fallback. - */ - private function download(string $repo, string $asset): ?string - { - if (! $this->commandExists('gh')) { + if ($runId === null) { return null; } @@ -398,29 +179,67 @@ private function download(string $repo, string $asset): ?string } $process = new Process([ - 'gh', 'release', 'download', self::RELEASE_TAG, + 'gh', 'run', 'download', $runId, '-R', $repo, - '-p', $asset, + '-n', self::ARTIFACT_NAME, '-D', $tmpDir, - '--clobber', ]); $process->setTimeout(120.0); $process->run(); - $payload = null; + if (! $process->isSuccessful()) { + $this->cleanup($tmpDir); + + return null; + } - if ($process->isSuccessful()) { - $path = $tmpDir.DIRECTORY_SEPARATOR.$asset; + $graphPath = $tmpDir.DIRECTORY_SEPARATOR.self::GRAPH_ASSET; + $coveragePath = $tmpDir.DIRECTORY_SEPARATOR.self::COVERAGE_ASSET; - if (is_file($path)) { - $content = @file_get_contents($path); - $payload = $content === false ? null : $content; - } + $graph = is_file($graphPath) ? @file_get_contents($graphPath) : false; + + if ($graph === false) { + $this->cleanup($tmpDir); + + return null; } + $coverage = is_file($coveragePath) ? @file_get_contents($coveragePath) : false; + $this->cleanup($tmpDir); - return $payload; + return [ + 'graph' => $graph, + 'coverage' => $coverage === false ? null : $coverage, + ]; + } + + /** + * Queries GitHub for the most recent successful run of the baseline + * workflow. `--jq '.[0].databaseId // empty'` coerces "no runs found" + * into an empty string, which we map to null. + */ + private function latestSuccessfulRunId(string $repo): ?string + { + $process = new Process([ + 'gh', 'run', 'list', + '-R', $repo, + '--workflow', self::WORKFLOW_FILE, + '--status', 'success', + '--limit', '1', + '--json', 'databaseId', + '--jq', '.[0].databaseId // empty', + ]); + $process->setTimeout(30.0); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + $runId = trim($process->getOutput()); + + return $runId === '' ? null : $runId; } private function commandExists(string $cmd): bool From 2941f9821fa48ea64b5a67c01b935fd7fc2dd43f Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Tue, 21 Apr 2026 08:15:24 -0700 Subject: [PATCH 025/160] wip --- src/Plugins/Tia.php | 22 +++++---- src/Plugins/Tia/BaselineSync.php | 53 +++++++++++++++++++--- src/Plugins/Tia/Bootstrapper.php | 11 +++-- src/Plugins/Tia/Contracts/State.php | 2 +- src/Plugins/Tia/Fingerprint.php | 30 +++++++++++- src/Plugins/Tia/WatchDefaults/Laravel.php | 4 ++ src/Plugins/Tia/WatchDefaults/Livewire.php | 4 ++ src/Plugins/Tia/WatchDefaults/Php.php | 7 ++- src/Plugins/Tia/WatchDefaults/Symfony.php | 4 ++ 9 files changed, 114 insertions(+), 23 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index cd44a9d6c..4dc439e09 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -30,7 +30,7 @@ * ----- * - **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 `.temp/tia.json`. + * `test → [source_file, …]` edges land in `.temp/tia/graph.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 +53,7 @@ * - **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 `.temp/tia-worker-.json`. + * into `.temp/tia/worker-edges-.json`. * - **Worker, replay**: nothing to do; args already narrowed. * * Guardrails @@ -77,29 +77,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable /** * State keys under which TIA persists its blobs. Kept here as constants * (rather than scattered strings) so the storage layout is visible in - * one place, and so `CoverageMerger` can reference the same keys. + * one place, and so `CoverageMerger` can reference the same keys. All + * files live under `.temp/tia/` — the `tia-` filename prefix is gone + * because the directory already namespaces them. */ - public const string KEY_GRAPH = 'tia.json'; + public const string KEY_GRAPH = 'graph.json'; - public const string KEY_AFFECTED = 'tia-affected.json'; + public const string KEY_AFFECTED = 'affected.json'; - private const string KEY_WORKER_EDGES_PREFIX = 'tia-worker-edges-'; + private const string KEY_WORKER_EDGES_PREFIX = 'worker-edges-'; - private const string KEY_WORKER_RESULTS_PREFIX = 'tia-worker-results-'; + private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-'; /** * Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage` * run. Stored as bytes so the backend stays JSON/file-agnostic — the * merger un/serialises rather than `require`-ing a PHP file. */ - public const string KEY_COVERAGE_CACHE = 'tia-coverage.bin'; + public const string KEY_COVERAGE_CACHE = 'coverage.bin'; /** * Marker key dropped by `Tia` to tell `Support\Coverage` to apply the * merge. Absent on plain `--coverage` runs so non-TIA usage keeps its * current (narrow) behaviour. */ - public const string KEY_COVERAGE_MARKER = 'tia-coverage.marker'; + public const string KEY_COVERAGE_MARKER = 'coverage.marker'; /** * Global flag toggled by the parent process so workers know to record. @@ -108,7 +110,7 @@ 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 `.temp/tia-affected.json`. + * Workers read the affected set from `.temp/tia/affected.json`. */ private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index d245ddf1e..f81f36573 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -16,8 +16,9 @@ * * Storage: **workflow artifacts**, not releases. A dedicated CI workflow * (conventionally `.github/workflows/tia-baseline.yml`) runs the full - * suite under `--tia` and uploads the resulting `tia.json` + - * `tia-coverage.bin` as a named artifact (`pest-tia-baseline`). On dev + * suite under `--tia` and uploads the `.temp/tia/` directory as a named + * artifact (`pest-tia-baseline`) containing `graph.json` + + * `coverage.bin`. On dev * machines, this class finds the latest successful run of that workflow * and downloads the artifact via `gh`. * @@ -48,7 +49,7 @@ final class BaselineSync /** * Artifact name the workflow uploads under. The artifact is a zip - * containing `tia.json` (always) + `tia-coverage.bin` (optional). + * containing `graph.json` (always) + `coverage.bin` (optional). */ private const string ARTIFACT_NAME = 'pest-tia-baseline'; @@ -87,9 +88,7 @@ public function fetchIfAvailable(string $projectRoot): bool $payload = $this->download($repo); if ($payload === null) { - $this->output->writeln( - ' TIA no baseline published yet — recording locally.', - ); + $this->emitPublishInstructions($repo); return false; } @@ -110,6 +109,48 @@ public function fetchIfAvailable(string $projectRoot): bool return true; } + /** + * Prints actionable instructions for publishing a first baseline when + * the consumer-side fetch finds nothing. Without this, the "no + * baseline yet" state is a dead-end for users — they see the message + * and have to guess what to do next. + */ + private function emitPublishInstructions(string $repo): void + { + $this->output->writeln([ + ' TIA no baseline published yet — recording locally.', + '', + ' To share the baseline with your team, add this workflow to the repo:', + '', + ' .github/workflows/tia-baseline.yml', + '', + ' name: TIA Baseline', + ' on:', + ' push: { branches: [main] }', + ' schedule: [{ cron: \'0 3 * * *\' }]', + ' workflow_dispatch:', + ' jobs:', + ' baseline:', + ' runs-on: ubuntu-latest', + ' steps:', + ' - uses: actions/checkout@v4', + ' with: { fetch-depth: 0 }', + ' - uses: shivammathur/setup-php@v2', + ' with: { php-version: \'8.4\', coverage: xdebug }', + ' - run: composer install --no-interaction --prefer-dist', + ' - run: ./vendor/bin/pest --parallel --tia --coverage', + ' - uses: actions/upload-artifact@v4', + ' with:', + ' name: pest-tia-baseline', + ' path: vendor/pestphp/pest/.temp/tia/', + ' retention-days: 30', + '', + sprintf(' Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo), + ' Details: https://pestphp.com/docs/tia/ci', + '', + ]); + } + /** * Parses `.git/config` for the `origin` remote and extracts * `org/repo`. Supports the two URL flavours git emits out of the box. diff --git a/src/Plugins/Tia/Bootstrapper.php b/src/Plugins/Tia/Bootstrapper.php index 6abf2ea5a..5585e945a 100644 --- a/src/Plugins/Tia/Bootstrapper.php +++ b/src/Plugins/Tia/Bootstrapper.php @@ -32,9 +32,11 @@ public function boot(): void } /** - * Resolve Pest's `.temp/` directory relative to this file so TIA's - * caches share the same location as the rest of Pest's transient - * state (PHPUnit result cache, coverage PHP dumps, etc.). + * TIA's own subdirectory under Pest's `.temp/`. Keeping every TIA blob + * in a single folder (`.temp/tia/`) avoids the `tia-`-prefix salad + * alongside PHPUnit's unrelated files (coverage.php, test-results, + * code-coverage/) and makes the CI artifact-upload path a single + * directory instead of a list of individual files. */ private function tempDir(): string { @@ -42,6 +44,7 @@ private function tempDir(): string .DIRECTORY_SEPARATOR.'..' .DIRECTORY_SEPARATOR.'..' .DIRECTORY_SEPARATOR.'..' - .DIRECTORY_SEPARATOR.'.temp'; + .DIRECTORY_SEPARATOR.'.temp' + .DIRECTORY_SEPARATOR.'tia'; } } diff --git a/src/Plugins/Tia/Contracts/State.php b/src/Plugins/Tia/Contracts/State.php index b109e3ec2..d440c4e17 100644 --- a/src/Plugins/Tia/Contracts/State.php +++ b/src/Plugins/Tia/Contracts/State.php @@ -39,7 +39,7 @@ public function exists(string $key): bool; /** * Returns every key whose name starts with `$prefix`. Used to collect - * paratest worker partials (`tia-worker-.json`, etc.) without + * paratest worker partials (`worker-edges-.json`, etc.) without * exposing backend-specific glob semantics. * * @return list diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index a978f8d81..a4a2de343 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -16,7 +16,7 @@ { // Bump this whenever the set of inputs or the hash algorithm changes, so // older graphs are invalidated automatically. - private const int SCHEMA_VERSION = 2; + private const int SCHEMA_VERSION = 3; /** * @return array @@ -26,6 +26,13 @@ public static function compute(string $projectRoot): array return [ 'schema' => self::SCHEMA_VERSION, 'php' => PHP_VERSION, + // Loaded extensions + their versions. Guards against the + // "recorded without pcov/xdebug → subsequent run has the + // driver but graph has no edges" trap where the fingerprint + // matches but the graph is effectively empty. Sorted so two + // processes with the same extensions in different load order + // still produce the same hash. + 'extensions' => self::extensionsFingerprint(), 'pest' => self::readPestVersion($projectRoot), 'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'), 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), @@ -41,6 +48,27 @@ public static function compute(string $projectRoot): array ]; } + /** + * Deterministic hash of the PHP extension set: `ext-name@version` pairs + * sorted alphabetically and joined. Captures both presence (pcov + * disappeared? graph must rebuild) and version changes (xdebug minor + * bump with coverage-mode semantics). + */ + private static function extensionsFingerprint(): string + { + $extensions = get_loaded_extensions(); + sort($extensions); + + $parts = []; + + foreach ($extensions as $name) { + $version = phpversion($name); + $parts[] = $name.'@'.($version === false ? '?' : $version); + } + + return hash('xxh128', implode("\n", $parts)); + } + /** * @param array $a * @param array $b diff --git a/src/Plugins/Tia/WatchDefaults/Laravel.php b/src/Plugins/Tia/WatchDefaults/Laravel.php index 3810c0cfe..b2e8445e2 100644 --- a/src/Plugins/Tia/WatchDefaults/Laravel.php +++ b/src/Plugins/Tia/WatchDefaults/Laravel.php @@ -60,6 +60,10 @@ public function defaults(string $projectRoot, string $testPath): array // Blade templates — compiled to cache, source file not executed. 'resources/views/**/*.blade.php' => [$featurePath], + // Email templates are nested under views/email or views/emails + // by convention and power mailable tests that render markup. + 'resources/views/email/**/*.blade.php' => [$featurePath], + 'resources/views/emails/**/*.blade.php' => [$featurePath], // Translations — JSON translations read via file_get_contents, // PHP translations loaded via include (but during boot). diff --git a/src/Plugins/Tia/WatchDefaults/Livewire.php b/src/Plugins/Tia/WatchDefaults/Livewire.php index 3a37c487b..80f3fb1fb 100644 --- a/src/Plugins/Tia/WatchDefaults/Livewire.php +++ b/src/Plugins/Tia/WatchDefaults/Livewire.php @@ -29,6 +29,10 @@ public function defaults(string $projectRoot, string $testPath): array // Livewire views live alongside Blade views or in a dedicated dir. 'resources/views/livewire/**/*.blade.php' => [$testPath], 'resources/views/components/**/*.blade.php' => [$testPath], + // Volt's second default mount — single-file components used as + // full-page routes. Missing this means editing a Volt page + // doesn't re-run its tests. + 'resources/views/pages/**/*.blade.php' => [$testPath], // Livewire JS interop / Alpine plugins. 'resources/js/**/*.js' => [$testPath], diff --git a/src/Plugins/Tia/WatchDefaults/Php.php b/src/Plugins/Tia/WatchDefaults/Php.php index 389966cc6..bc6f0dc27 100644 --- a/src/Plugins/Tia/WatchDefaults/Php.php +++ b/src/Plugins/Tia/WatchDefaults/Php.php @@ -25,9 +25,14 @@ public function defaults(string $projectRoot, string $testPath): array return [ // Environment files — can change DB drivers, feature flags, - // queue connections, etc. Not PHP, not fingerprinted. + // queue connections, etc. Not PHP, not fingerprinted. Covers + // the local-override variants (`.env.local`, `.env.testing.local`) + // that both Laravel and Symfony recommend for machine-specific + // config. '.env' => [$testPath], '.env.testing' => [$testPath], + '.env.local' => [$testPath], + '.env.*.local' => [$testPath], // Docker / CI — can affect integration test infrastructure. 'docker-compose.yml' => [$testPath], diff --git a/src/Plugins/Tia/WatchDefaults/Symfony.php b/src/Plugins/Tia/WatchDefaults/Symfony.php index a3d4b0b3e..ee8683d7b 100644 --- a/src/Plugins/Tia/WatchDefaults/Symfony.php +++ b/src/Plugins/Tia/WatchDefaults/Symfony.php @@ -46,7 +46,11 @@ public function defaults(string $projectRoot, string $testPath): array 'src/Kernel.php' => [$testPath], // Migrations — run during setUp (before coverage window). + // DoctrineMigrationsBundle's default is `migrations/` at the + // project root; many Symfony projects relocate to + // `src/Migrations/` — both covered. 'migrations/**/*.php' => [$testPath], + 'src/Migrations/**/*.php' => [$testPath], // Twig templates — compiled, source not PHP-executed. 'templates/**/*.html.twig' => [$testPath], From f6609f4039d47ee15b818241b21206364c1658a8 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Tue, 21 Apr 2026 08:36:41 -0700 Subject: [PATCH 026/160] wip --- src/Plugins/Tia/BaselineSync.php | 141 +++++++++++++++++++++++++------ 1 file changed, 116 insertions(+), 25 deletions(-) diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index f81f36573..9fe5b97ef 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -4,6 +4,7 @@ namespace Pest\Plugins\Tia; +use Composer\InstalledVersions; use Pest\Plugins\Tia; use Pest\Plugins\Tia\Contracts\State; use Symfony\Component\Console\Output\OutputInterface; @@ -111,44 +112,134 @@ public function fetchIfAvailable(string $projectRoot): bool /** * Prints actionable instructions for publishing a first baseline when - * the consumer-side fetch finds nothing. Without this, the "no - * baseline yet" state is a dead-end for users — they see the message - * and have to guess what to do next. + * the consumer-side fetch finds nothing. + * + * Behaviour splits on environment: + * - **CI:** a single line. The current run is almost certainly *the* + * publisher (it's what this workflow does by definition), so + * printing the whole recipe again is redundant and noisy. + * - **Local:** the full recipe, adapted to Laravel's pre-test steps + * (`.env.example` copy + `artisan key:generate`) when the framework + * is present. Generic PHP projects get a slimmer skeleton. */ private function emitPublishInstructions(string $repo): void { - $this->output->writeln([ + if ($this->isCi()) { + $this->output->writeln( + ' TIA no baseline yet — this run will produce one.', + ); + + return; + } + + $yaml = $this->isLaravel() + ? $this->laravelWorkflowYaml() + : $this->genericWorkflowYaml(); + + $preamble = [ ' TIA no baseline published yet — recording locally.', '', ' To share the baseline with your team, add this workflow to the repo:', '', ' .github/workflows/tia-baseline.yml', '', - ' name: TIA Baseline', - ' on:', - ' push: { branches: [main] }', - ' schedule: [{ cron: \'0 3 * * *\' }]', - ' workflow_dispatch:', - ' jobs:', - ' baseline:', - ' runs-on: ubuntu-latest', - ' steps:', - ' - uses: actions/checkout@v4', - ' with: { fetch-depth: 0 }', - ' - uses: shivammathur/setup-php@v2', - ' with: { php-version: \'8.4\', coverage: xdebug }', - ' - run: composer install --no-interaction --prefer-dist', - ' - run: ./vendor/bin/pest --parallel --tia --coverage', - ' - uses: actions/upload-artifact@v4', - ' with:', - ' name: pest-tia-baseline', - ' path: vendor/pestphp/pest/.temp/tia/', - ' retention-days: 30', + ]; + + $indentedYaml = array_map( + static fn (string $line): string => ' '.$line, + explode("\n", $yaml), + ); + + $trailer = [ '', sprintf(' Commit, push, then run once: gh workflow run tia-baseline.yml -R %s', $repo), ' Details: https://pestphp.com/docs/tia/ci', '', - ]); + ]; + + $this->output->writeln([...$preamble, ...$indentedYaml, ...$trailer]); + } + + /** + * True when running inside a CI provider. Conservative list — only the + * three providers Pest formally supports / sees in the wild. `CI=true` + * alone is ambiguous (users set it locally too) so we require a + * provider-specific flag. + */ + private function isCi(): bool + { + return getenv('GITHUB_ACTIONS') === 'true' + || getenv('GITLAB_CI') === 'true' + || getenv('CIRCLECI') === 'true'; + } + + private function isLaravel(): bool + { + return class_exists(InstalledVersions::class) + && InstalledVersions::isInstalled('laravel/framework'); + } + + /** + * Laravel projects need a populated `.env` and a generated `APP_KEY` + * before the first boot, otherwise `Illuminate\Encryption\MissingAppKeyException` + * fires during `setUp`. Include the standard pre-test dance plus the + * extension set typical Laravel apps rely on. + */ + private function laravelWorkflowYaml(): string + { + return <<<'YAML' +name: TIA Baseline +on: + push: { branches: [main] } + schedule: [{ cron: '0 3 * * *' }] + workflow_dispatch: +jobs: + baseline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + coverage: xdebug + extensions: json, dom, curl, libxml, mbstring, zip, pdo, pdo_sqlite, sqlite3, bcmath, intl + - run: cp .env.example .env + - run: composer install --no-interaction --prefer-dist + - run: php artisan key:generate + - run: ./vendor/bin/pest --parallel --tia --coverage + - uses: actions/upload-artifact@v4 + with: + name: pest-tia-baseline + path: vendor/pestphp/pest/.temp/tia/ + retention-days: 30 +YAML; + } + + private function genericWorkflowYaml(): string + { + return <<<'YAML' +name: TIA Baseline +on: + push: { branches: [main] } + schedule: [{ cron: '0 3 * * *' }] + workflow_dispatch: +jobs: + baseline: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - uses: shivammathur/setup-php@v2 + with: { php-version: '8.4', coverage: xdebug } + - run: composer install --no-interaction --prefer-dist + - run: ./vendor/bin/pest --parallel --tia --coverage + - uses: actions/upload-artifact@v4 + with: + name: pest-tia-baseline + path: vendor/pestphp/pest/.temp/tia/ + retention-days: 30 +YAML; } /** From 51fc3807895855e0383e1aa022f9203da3b2d43b Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Tue, 21 Apr 2026 09:40:01 -0700 Subject: [PATCH 027/160] wip --- src/Plugins/Tia.php | 84 +++++++++++--- src/Plugins/Tia/Fingerprint.php | 192 ++++++++++++++++++++++++-------- src/Plugins/Tia/Graph.php | 18 ++- 3 files changed, 233 insertions(+), 61 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 4dc439e09..87eb8691a 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -503,6 +503,22 @@ public function addOutput(int $exitCode): int $finalised[$testFile] = array_keys($sourceSet); } + // Empty-edges guard: if every worker returned no edges it almost + // always means the coverage driver wasn't loaded in the workers + // (common footgun with custom PHP ini scan dirs, Herd profiles, + // stripped CI runners). Writing the empty graph would silently + // seed a broken baseline; fail loud instead. + if ($finalised === []) { + $this->output->writeln([ + '', + ' ERROR TIA recorded zero edges — coverage driver likely missing.', + ' Install / enable pcov or xdebug (mode: coverage) in the worker PHP and retry.', + '', + ]); + + return $exitCode; + } + $graph->replaceEdges($finalised); $graph->pruneMissingTests(); @@ -527,6 +543,56 @@ public function addOutput(int $exitCode): int return $exitCode; } + /** + * Compares a loaded graph's fingerprint to the current one and decides + * how much of the graph is still usable. + * + * - **Structural drift** (composer.lock, Pest.php, factory codegen, + * schema bump): edges themselves are potentially wrong → discard + * the whole graph + coverage cache and return null. Caller falls + * through to record mode. + * - **Environmental drift** (PHP minor, extension set, Pest version): + * edges describe the code correctly; only the cached per-test + * results were captured against a different runtime and might not + * reproduce. Drop `baselines[branch].results` + coverage cache, + * bump the fingerprint to the current env, persist. Caller uses + * the graph for edges; results refill naturally during this run's + * replay (every test misses cache, runs normally, seeds results). + * - **Match**: return the graph untouched. + * + * @param array{structural: array, environmental: array} $current + */ + private function reconcileFingerprint(Graph $graph, array $current): ?Graph + { + $stored = $graph->fingerprint(); + + if (! Fingerprint::structuralMatches($stored, $current)) { + $this->output->writeln( + ' TIA graph structure outdated — rebuilding.', + ); + $this->state->delete(self::KEY_GRAPH); + $this->state->delete(self::KEY_COVERAGE_CACHE); + + return null; + } + + $drift = Fingerprint::environmentalDrift($stored, $current); + + if ($drift !== []) { + $this->output->writeln(sprintf( + ' TIA env differs from baseline (%s) — results dropped, edges reused.', + implode(', ', $drift), + )); + + $graph->clearResults($this->branch); + $graph->setFingerprint($current); + $this->saveGraph($graph); + $this->state->delete(self::KEY_COVERAGE_CACHE); + } + + return $graph; + } + /** * @param array $arguments * @return array @@ -547,11 +613,8 @@ private function handleParent(array $arguments, string $projectRoot, bool $force $graph = $forceRebuild ? null : $this->loadGraph($projectRoot); - if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) { - $this->output->writeln( - ' TIA environment fingerprint changed — graph will be rebuilt.', - ); - $graph = null; + if ($graph instanceof Graph) { + $graph = $this->reconcileFingerprint($graph, $fingerprint); } if ($graph instanceof Graph) { @@ -571,18 +634,13 @@ private function handleParent(array $arguments, string $projectRoot, bool $force // No local graph and not being forced to rebuild from scratch: try // to pull a team-shared baseline so fresh checkouts (new devs, CI // containers) don't pay the full record cost. If the pull succeeds - // the graph is re-read and re-validated against the local env. + // the graph is re-read and reconciled against the local env. if ($graph === null && ! $forceRebuild) { if ($this->baselineSync->fetchIfAvailable($projectRoot)) { $graph = $this->loadGraph($projectRoot); - if ($graph instanceof Graph && ! Fingerprint::matches($graph->fingerprint(), $fingerprint)) { - $this->output->writeln( - ' TIA pulled baseline fingerprint mismatch — discarding.', - ); - $this->state->delete(self::KEY_GRAPH); - $this->state->delete(self::KEY_COVERAGE_CACHE); - $graph = null; + if ($graph instanceof Graph) { + $graph = $this->reconcileFingerprint($graph, $fingerprint); } } } diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index a4a2de343..0cc743a2d 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -5,80 +5,161 @@ namespace Pest\Plugins\Tia; /** - * Captures environmental inputs that, when changed, make the TIA graph stale. + * Captures environmental inputs that, when changed, may make the TIA graph + * or its recorded results stale. The fingerprint is split into two buckets: * - * Any drift in PHP version, Composer lock, or Pest/PHPUnit config can change - * what a test actually exercises, so the graph must be rebuilt in those cases. + * - **structural** — describes what the graph's *edges* were recorded + * against. If any of these drift (`composer.lock`, `tests/Pest.php`, + * Pest's factory codegen, etc.) the edges themselves are potentially + * wrong and the graph must rebuild from scratch. + * - **environmental** — describes the *runtime* the results were captured + * on (PHP minor, extension set, Pest version). Drift here means the + * edges are still trustworthy, but the cached per-test results (pass/ + * fail/time) may not reproduce on this machine. Tia's handler drops the + * branch's results + coverage cache and re-runs to freshen them, rather + * than re-recording from scratch. + * + * Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and + * rebuilt on first load; the schema bump in the structural bucket takes + * care of that automatically. * * @internal */ final readonly class Fingerprint { - // Bump this whenever the set of inputs or the hash algorithm changes, so - // older graphs are invalidated automatically. - private const int SCHEMA_VERSION = 3; + // Bump this whenever the set of inputs or the hash algorithm changes, + // so older graphs are invalidated automatically. + private const int SCHEMA_VERSION = 4; /** - * @return array + * @return array{ + * structural: array, + * environmental: array, + * } */ public static function compute(string $projectRoot): array { return [ - 'schema' => self::SCHEMA_VERSION, - 'php' => PHP_VERSION, - // Loaded extensions + their versions. Guards against the - // "recorded without pcov/xdebug → subsequent run has the - // driver but graph has no edges" trap where the fingerprint - // matches but the graph is effectively empty. Sorted so two - // processes with the same extensions in different load order - // still produce the same hash. - 'extensions' => self::extensionsFingerprint(), - 'pest' => self::readPestVersion($projectRoot), - 'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'), - 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), - 'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), - 'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'), - // Pest's generated classes bake the code-generation logic in — if - // TestCaseFactory changes (new attribute, different method - // signature, etc.) every previously-recorded edge is stale. - // Hashing the factory sources makes path-repo / dev-main installs - // automatically rebuild their graphs when Pest itself is edited. - 'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'), - 'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'), + 'structural' => [ + 'schema' => self::SCHEMA_VERSION, + 'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'), + 'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'), + 'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'), + 'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'), + // Pest's generated classes bake the code-generation logic + // in — if TestCaseFactory changes (new attribute, different + // method signature, etc.) every previously-recorded edge is + // stale. Hashing the factory sources makes path-repo / + // dev-main installs automatically rebuild their graphs when + // Pest itself is edited. + 'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'), + 'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'), + ], + 'environmental' => [ + // PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch + // almost never matches a dev's Herd/Homebrew install, and + // the patch rarely changes anything test-visible. + 'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION, + 'extensions' => self::extensionsFingerprint(), + 'pest' => self::readPestVersion($projectRoot), + ], ]; } /** - * Deterministic hash of the PHP extension set: `ext-name@version` pairs - * sorted alphabetically and joined. Captures both presence (pcov - * disappeared? graph must rebuild) and version changes (xdebug minor - * bump with coverage-mode semantics). + * True when the structural buckets match. Drift here means the edges + * are potentially wrong; caller should discard the graph and rebuild. + * + * @param array $a + * @param array $b */ - private static function extensionsFingerprint(): string + public static function structuralMatches(array $a, array $b): bool { - $extensions = get_loaded_extensions(); - sort($extensions); + $aStructural = self::structuralOnly($a); + $bStructural = self::structuralOnly($b); - $parts = []; + ksort($aStructural); + ksort($bStructural); - foreach ($extensions as $name) { - $version = phpversion($name); - $parts[] = $name.'@'.($version === false ? '?' : $version); + return $aStructural === $bStructural; + } + + /** + * Returns a list of field names that drifted between the stored and + * current environmental fingerprints. Empty list = no drift. Caller + * uses this to print a human-readable warning and to decide whether + * per-test results should be dropped (any drift → yes). + * + * @param array $stored + * @param array $current + * @return list + */ + public static function environmentalDrift(array $stored, array $current): array + { + $a = self::environmentalOnly($stored); + $b = self::environmentalOnly($current); + + $drifts = []; + + foreach ($a as $key => $value) { + if (($b[$key] ?? null) !== $value) { + $drifts[] = $key; + } } - return hash('xxh128', implode("\n", $parts)); + foreach ($b as $key => $value) { + if (! array_key_exists($key, $a) && $value !== null) { + $drifts[] = $key; + } + } + + return array_values(array_unique($drifts)); } /** - * @param array $a - * @param array $b + * @param array $fingerprint + * @return array */ - public static function matches(array $a, array $b): bool + private static function structuralOnly(array $fingerprint): array { - ksort($a); - ksort($b); + return self::bucket($fingerprint, 'structural'); + } - return $a === $b; + /** + * @param array $fingerprint + * @return array + */ + private static function environmentalOnly(array $fingerprint): array + { + return self::bucket($fingerprint, 'environmental'); + } + + /** + * Returns `$fingerprint[$key]` as an `array` if it exists + * and is an array, otherwise empty. Legacy flat-shape fingerprints + * (schema ≤ 3) return empty here, which makes `structuralMatches` fail + * and the caller rebuild — the clean migration path. + * + * @param array $fingerprint + * @return array + */ + private static function bucket(array $fingerprint, string $key): array + { + $raw = $fingerprint[$key] ?? null; + + if (! is_array($raw)) { + return []; + } + + $normalised = []; + + foreach ($raw as $k => $v) { + if (is_string($k)) { + $normalised[$k] = $v; + } + } + + return $normalised; } private static function hashIfExists(string $path): ?string @@ -92,6 +173,25 @@ private static function hashIfExists(string $path): ?string return $hash === false ? null : $hash; } + /** + * Deterministic hash of the PHP extension set: `ext-name@version` pairs + * sorted alphabetically and joined. + */ + private static function extensionsFingerprint(): string + { + $extensions = get_loaded_extensions(); + sort($extensions); + + $parts = []; + + foreach ($extensions as $name) { + $version = phpversion($name); + $parts[] = $name.'@'.($version === false ? '?' : $version); + } + + return hash('xxh128', implode("\n", $parts)); + } + private static function readPestVersion(string $projectRoot): string { $installed = $projectRoot.'/vendor/composer/installed.json'; diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 64356312d..d4ee674b6 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -223,7 +223,7 @@ public function allTestFiles(): array } /** - * @param array $fingerprint + * @param array $fingerprint */ public function setFingerprint(array $fingerprint): void { @@ -231,7 +231,7 @@ public function setFingerprint(array $fingerprint): void } /** - * @return array + * @return array */ public function fingerprint(): array { @@ -323,6 +323,20 @@ public function setLastRunTree(string $branch, array $tree): void $this->baselines[$branch]['tree'] = $tree; } + /** + * Wipes cached per-test results for the given branch. Edges and tree + * snapshot stay intact — the graph still describes the code correctly, + * only the "what happened last time" data is reset. Used on + * environmental fingerprint drift: the edges were recorded elsewhere + * (e.g. CI) so they're still valid, but the results aren't trustworthy + * on this machine until the tests re-run here. + */ + public function clearResults(string $branch): void + { + $this->ensureBaseline($branch); + $this->baselines[$branch]['results'] = []; + } + /** * @return array */ From e24882c48644fbafb6c0de6402ab5fd2336a98ea Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Tue, 21 Apr 2026 09:41:19 -0700 Subject: [PATCH 028/160] wip --- src/Concerns/Testable.php | 2 +- src/Plugins/Tia.php | 56 +++++++++---------- src/Plugins/Tia/BaselineSync.php | 6 +- src/Plugins/Tia/ChangedFiles.php | 4 +- src/Plugins/Tia/CoverageMerger.php | 15 ++--- src/Plugins/Tia/FileState.php | 4 +- ...sureTiaAssertionsAreRecordedOnFinished.php | 4 +- .../EnsureTiaResultIsRecordedOnErrored.php | 4 +- .../EnsureTiaResultIsRecordedOnFailed.php | 4 +- .../EnsureTiaResultIsRecordedOnIncomplete.php | 4 +- .../EnsureTiaResultIsRecordedOnPassed.php | 4 +- .../EnsureTiaResultIsRecordedOnRisky.php | 4 +- .../EnsureTiaResultIsRecordedOnSkipped.php | 4 +- .../EnsureTiaResultsAreCollected.php | 4 +- src/Support/Coverage.php | 3 +- 15 files changed, 61 insertions(+), 61 deletions(-) diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 8ffbeefc3..6d3047657 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -9,8 +9,8 @@ use Pest\Panic; use Pest\Plugins\Tia; use Pest\Preset; -use Pest\Support\Container; use Pest\Support\ChainableClosure; +use Pest\Support\Container; use Pest\Support\ExceptionTrace; use Pest\Support\Reflection; use Pest\Support\Shell; diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 87eb8691a..e66ee2394 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -8,7 +8,6 @@ use Pest\Contracts\Plugins\AddsOutput; use Pest\Contracts\Plugins\HandlesArguments; use Pest\Contracts\Plugins\Terminable; -use PHPUnit\Framework\TestStatus\TestStatus; use Pest\Plugins\Tia\BaselineSync; use Pest\Plugins\Tia\ChangedFiles; use Pest\Plugins\Tia\Contracts\State; @@ -20,6 +19,7 @@ use Pest\Plugins\Tia\WatchPatterns; use Pest\Support\Container; use Pest\TestSuite; +use PHPUnit\Framework\TestStatus\TestStatus; use Symfony\Component\Console\Output\OutputInterface; use Throwable; @@ -173,12 +173,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable */ private array $affectedFiles = []; - private static function workerEdgesKey(string $token): string + private function workerEdgesKey(string $token): string { return self::KEY_WORKER_EDGES_PREFIX.$token.'.json'; } - private static function workerResultsKey(string $token): string + private function workerResultsKey(string $token): string { return self::KEY_WORKER_RESULTS_PREFIX.$token.'.json'; } @@ -242,7 +242,7 @@ private function saveGraph(Graph $graph): bool */ public function getCachedResult(string $filename, string $testId): ?TestStatus { - if ($this->replayGraph === null) { + if (! $this->replayGraph instanceof Graph) { return null; } @@ -271,7 +271,7 @@ public function getCachedResult(string $filename, string $testId): ?TestStatus // branch (falls back to main if branch is fresh). $result = $this->replayGraph->getResult($this->branch, $testId); - if ($result !== null) { + if ($result instanceof TestStatus) { $this->replayedCount++; // Cache the assertion count alongside the status so `Testable` // can emit the exact `addToAssertionCount()` at replay time @@ -351,7 +351,7 @@ public function terminate(): void // the graph lands with results on first write — otherwise the next // run would load a graph with edges but empty results, miss the // cache for every test, and look pointlessly slow. - if (Parallel::isWorker() && ($this->replayGraph !== null || $this->recordingActive)) { + if (Parallel::isWorker() && ($this->replayGraph instanceof Graph || $this->recordingActive)) { $this->flushWorkerReplay(); } @@ -376,7 +376,7 @@ public function terminate(): void } if (Parallel::isWorker()) { - $this->flushWorkerPartial($projectRoot, $perTest); + $this->flushWorkerPartial($perTest); $recorder->reset(); $this->coverageCollector->reset(); @@ -635,13 +635,10 @@ private function handleParent(array $arguments, string $projectRoot, bool $force // to pull a team-shared baseline so fresh checkouts (new devs, CI // containers) don't pay the full record cost. If the pull succeeds // the graph is re-read and reconciled against the local env. - if ($graph === null && ! $forceRebuild) { - if ($this->baselineSync->fetchIfAvailable($projectRoot)) { - $graph = $this->loadGraph($projectRoot); - - if ($graph instanceof Graph) { - $graph = $this->reconcileFingerprint($graph, $fingerprint); - } + if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot)) { + $graph = $this->loadGraph($projectRoot); + if ($graph instanceof Graph) { + $graph = $this->reconcileFingerprint($graph, $fingerprint); } } @@ -657,14 +654,14 @@ private function handleParent(array $arguments, string $projectRoot, bool $force // report collapses to near-zero coverage. Fall back to recording // (full suite) to seed the cache for next time. if ($this->piggybackCoverage && ! $this->state->exists(self::KEY_COVERAGE_CACHE)) { - return $this->enterRecordMode($projectRoot, $arguments); + return $this->enterRecordMode($arguments); } if ($graph instanceof Graph) { return $this->enterReplayMode($graph, $projectRoot, $arguments); } - return $this->enterRecordMode($projectRoot, $arguments); + return $this->enterRecordMode($arguments); } /** @@ -792,7 +789,7 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum } // Parallel: persist affected set so workers can install the filter. - if (! $this->persistAffectedSet($projectRoot, $affected)) { + if (! $this->persistAffectedSet($affected)) { $this->output->writeln( ' TIA failed to persist affected set — running full suite.', ); @@ -802,7 +799,7 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum // Clear stale partials from a previous interrupted run so the merge // pass doesn't pick up results from an unrelated invocation. - $this->purgeWorkerPartials($projectRoot); + $this->purgeWorkerPartials(); Parallel::setGlobal(self::REPLAYING_GLOBAL, '1'); @@ -812,7 +809,7 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum /** * @param array $affected Project-relative paths. */ - private function persistAffectedSet(string $projectRoot, array $affected): bool + private function persistAffectedSet(array $affected): bool { $json = json_encode(array_values($affected), JSON_UNESCAPED_SLASHES); @@ -827,7 +824,7 @@ private function persistAffectedSet(string $projectRoot, array $affected): bool * @param array $arguments * @return array */ - private function enterRecordMode(string $projectRoot, array $arguments): array + private function enterRecordMode(array $arguments): array { $recorder = $this->recorder; @@ -852,7 +849,7 @@ private function enterRecordMode(string $projectRoot, array $arguments): array // recording. We only advertise the intent through a global. // Clean up any stale partial files from a previous interrupted // run so the merge step doesn't confuse itself. - $this->purgeWorkerPartials($projectRoot); + $this->purgeWorkerPartials(); Parallel::setGlobal(self::RECORDING_GLOBAL, '1'); @@ -907,7 +904,7 @@ private function emitCoverageDriverMissing(): void /** * @param array> $perTest */ - private function flushWorkerPartial(string $projectRoot, array $perTest): void + private function flushWorkerPartial(array $perTest): void { $json = json_encode($perTest, JSON_UNESCAPED_SLASHES); @@ -915,7 +912,7 @@ private function flushWorkerPartial(string $projectRoot, array $perTest): void return; } - $this->state->write(self::workerEdgesKey($this->workerToken()), $json); + $this->state->write($this->workerEdgesKey($this->workerToken()), $json); } /** @@ -926,12 +923,11 @@ private function collectWorkerEdgesPartials(): array return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX); } - private function purgeWorkerPartials(string $projectRoot): void + private function purgeWorkerPartials(): void { foreach ($this->collectWorkerEdgesPartials() as $key) { $this->state->delete($key); } - foreach ($this->collectWorkerReplayPartials() as $key) { $this->state->delete($key); } @@ -963,7 +959,7 @@ private function flushWorkerReplay(): void return; } - $this->state->write(self::workerResultsKey($this->workerToken()), $json); + $this->state->write($this->workerResultsKey($this->workerToken()), $json); } /** @@ -1011,10 +1007,12 @@ private function mergeWorkerReplayPartials(): void /** @var mixed $result */ foreach ($decoded['results'] as $testId => $result) { - if (! is_string($testId) || ! is_array($result)) { + if (! is_string($testId)) { + continue; + } + if (! is_array($result)) { continue; } - $normalised[$testId] = [ 'status' => is_int($result['status'] ?? null) ? $result['status'] : 0, 'message' => is_string($result['message'] ?? null) ? $result['message'] : '', @@ -1119,7 +1117,7 @@ private function registerRecap(): void $fragments[] = $this->replayedCount.' replayed'; } - return $fragments === [] ? '' : implode(', ', $fragments); + return implode(', ', $fragments); }); } diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 9fe5b97ef..97466f857 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -39,7 +39,7 @@ * * @internal */ -final class BaselineSync +final readonly class BaselineSync { /** * Conventional workflow filename teams publish from. Not configurable @@ -63,8 +63,8 @@ final class BaselineSync private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE; public function __construct( - private readonly State $state, - private readonly OutputInterface $output, + private State $state, + private OutputInterface $output, ) {} /** diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index e24fda26a..171b007a6 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -38,7 +38,7 @@ public function __construct(private string $projectRoot) {} * that git still reports as modified but whose content is bit-identical * to the previous TIA invocation. * - * @param array $files project-relative paths. + * @param array $files project-relative paths. * @param array $lastRunTree path → content hash from last run. * @return array */ @@ -101,7 +101,7 @@ public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): a * detect which files are actually different. * * @param array $files - * @return array path → xxh128 content hash + * @return array path → xxh128 content hash */ public function snapshotTree(array $files): array { diff --git a/src/Plugins/Tia/CoverageMerger.php b/src/Plugins/Tia/CoverageMerger.php index fb4820239..0f9845716 100644 --- a/src/Plugins/Tia/CoverageMerger.php +++ b/src/Plugins/Tia/CoverageMerger.php @@ -46,7 +46,7 @@ public static function applyIfMarked(string $reportPath): void { $state = self::state(); - if ($state === null || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) { + if (! $state instanceof State || ! $state->exists(Tia::KEY_COVERAGE_MARKER)) { return; } @@ -60,7 +60,7 @@ public static function applyIfMarked(string $reportPath): void // verbatim (as serialised bytes) for next time. $current = self::requireCoverage($reportPath); - if ($current !== null) { + if ($current instanceof CodeCoverage) { $state->write(Tia::KEY_COVERAGE_CACHE, serialize($current)); } @@ -70,7 +70,7 @@ public static function applyIfMarked(string $reportPath): void $cached = self::unserializeCoverage($cachedBytes); $current = self::requireCoverage($reportPath); - if ($cached === null || $current === null) { + if (! $cached instanceof CodeCoverage || ! $current instanceof CodeCoverage) { return; } @@ -84,7 +84,7 @@ public static function applyIfMarked(string $reportPath): void // can `require` it, and to the state cache for the next run. @file_put_contents( $reportPath, - "write(Tia::KEY_COVERAGE_CACHE, $serialised); } @@ -108,10 +108,12 @@ private static function stripCurrentTestsFromCached(CodeCoverage $cached, CodeCo foreach ($lineCoverage as $file => $lines) { foreach ($lines as $line => $ids) { - if ($ids === null || $ids === []) { + if ($ids === null) { + continue; + } + if ($ids === []) { continue; } - $filtered = array_values(array_diff($ids, $currentIds)); if ($filtered !== $ids) { @@ -175,7 +177,6 @@ private static function requireCoverage(string $reportPath): ?CodeCoverage private static function unserializeCoverage(string $bytes): ?CodeCoverage { try { - /** @var mixed $value */ $value = @unserialize($bytes); } catch (Throwable) { return null; diff --git a/src/Plugins/Tia/FileState.php b/src/Plugins/Tia/FileState.php index 33060d8e8..d19384073 100644 --- a/src/Plugins/Tia/FileState.php +++ b/src/Plugins/Tia/FileState.php @@ -17,14 +17,14 @@ * * @internal */ -final class FileState implements State +final readonly class FileState implements State { /** * Configured root. May not exist on disk yet; resolved + created on * the first write. Keeping the raw string lets the instance be built * before Pest's temp dir has been materialised. */ - private readonly string $rootDir; + private string $rootDir; public function __construct(string $rootDir) { diff --git a/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php index a6f1e2a9d..9764919be 100644 --- a/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php +++ b/src/Subscribers/EnsureTiaAssertionsAreRecordedOnFinished.php @@ -16,9 +16,9 @@ * * @internal */ -final class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber +final readonly class EnsureTiaAssertionsAreRecordedOnFinished implements FinishedSubscriber { - public function __construct(private readonly ResultCollector $collector) {} + public function __construct(private ResultCollector $collector) {} public function notify(Finished $event): void { diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php b/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php index c6cd4a270..ecdf3833a 100644 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnErrored.php @@ -11,9 +11,9 @@ /** * @internal */ -final class EnsureTiaResultIsRecordedOnErrored implements ErroredSubscriber +final readonly class EnsureTiaResultIsRecordedOnErrored implements ErroredSubscriber { - public function __construct(private readonly ResultCollector $collector) {} + public function __construct(private ResultCollector $collector) {} public function notify(Errored $event): void { diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php index c46cf8a0b..29940e0c5 100644 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnFailed.php @@ -11,9 +11,9 @@ /** * @internal */ -final class EnsureTiaResultIsRecordedOnFailed implements FailedSubscriber +final readonly class EnsureTiaResultIsRecordedOnFailed implements FailedSubscriber { - public function __construct(private readonly ResultCollector $collector) {} + public function __construct(private ResultCollector $collector) {} public function notify(Failed $event): void { diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php b/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php index fe91ecbb8..330525e89 100644 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnIncomplete.php @@ -11,9 +11,9 @@ /** * @internal */ -final class EnsureTiaResultIsRecordedOnIncomplete implements MarkedIncompleteSubscriber +final readonly class EnsureTiaResultIsRecordedOnIncomplete implements MarkedIncompleteSubscriber { - public function __construct(private readonly ResultCollector $collector) {} + public function __construct(private ResultCollector $collector) {} public function notify(MarkedIncomplete $event): void { diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php b/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php index 739b213ad..09ebcc217 100644 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnPassed.php @@ -11,9 +11,9 @@ /** * @internal */ -final class EnsureTiaResultIsRecordedOnPassed implements PassedSubscriber +final readonly class EnsureTiaResultIsRecordedOnPassed implements PassedSubscriber { - public function __construct(private readonly ResultCollector $collector) {} + public function __construct(private ResultCollector $collector) {} public function notify(Passed $event): void { diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php b/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php index 8554816e0..fe65f6ebf 100644 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnRisky.php @@ -11,9 +11,9 @@ /** * @internal */ -final class EnsureTiaResultIsRecordedOnRisky implements ConsideredRiskySubscriber +final readonly class EnsureTiaResultIsRecordedOnRisky implements ConsideredRiskySubscriber { - public function __construct(private readonly ResultCollector $collector) {} + public function __construct(private ResultCollector $collector) {} public function notify(ConsideredRisky $event): void { diff --git a/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php b/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php index 1b94cf663..58de98add 100644 --- a/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php +++ b/src/Subscribers/EnsureTiaResultIsRecordedOnSkipped.php @@ -11,9 +11,9 @@ /** * @internal */ -final class EnsureTiaResultIsRecordedOnSkipped implements SkippedSubscriber +final readonly class EnsureTiaResultIsRecordedOnSkipped implements SkippedSubscriber { - public function __construct(private readonly ResultCollector $collector) {} + public function __construct(private ResultCollector $collector) {} public function notify(Skipped $event): void { diff --git a/src/Subscribers/EnsureTiaResultsAreCollected.php b/src/Subscribers/EnsureTiaResultsAreCollected.php index aec17fea8..7d4328659 100644 --- a/src/Subscribers/EnsureTiaResultsAreCollected.php +++ b/src/Subscribers/EnsureTiaResultsAreCollected.php @@ -20,9 +20,9 @@ * * @internal */ -final class EnsureTiaResultsAreCollected implements PreparedSubscriber +final readonly class EnsureTiaResultsAreCollected implements PreparedSubscriber { - public function __construct(private readonly ResultCollector $collector) {} + public function __construct(private ResultCollector $collector) {} public function notify(Prepared $event): void { diff --git a/src/Support/Coverage.php b/src/Support/Coverage.php index 6845ad4eb..62d24212a 100644 --- a/src/Support/Coverage.php +++ b/src/Support/Coverage.php @@ -5,6 +5,7 @@ namespace Pest\Support; use Pest\Exceptions\ShouldNotHappen; +use Pest\Plugins\Tia\CoverageMerger; use SebastianBergmann\CodeCoverage\CodeCoverage; use SebastianBergmann\CodeCoverage\Node\Directory; use SebastianBergmann\CodeCoverage\Node\File; @@ -92,7 +93,7 @@ public static function report(OutputInterface $output, bool $compact = false, bo // tests. Merge their fresh coverage slice into the cached full-run // snapshot (stored by the previous `--tia --coverage` pass) so the // report reflects the entire suite, not just what re-ran. - \Pest\Plugins\Tia\CoverageMerger::applyIfMarked($reportPath); + CoverageMerger::applyIfMarked($reportPath); /** @var CodeCoverage $codeCoverage */ $codeCoverage = require $reportPath; From 856a3700329d3095a1cbc81b16920587ddaa50a6 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Tue, 21 Apr 2026 09:44:26 -0700 Subject: [PATCH 029/160] style --- src/Plugins/Tia/Graph.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index d4ee674b6..a8bc912f3 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -282,9 +282,7 @@ public function getAssertions(string $branch, string $testId, string $fallbackBr return null; } - $value = $baseline['results'][$testId]['assertions']; - - return is_int($value) ? $value : null; + return $baseline['results'][$testId]['assertions']; } public function getResult(string $branch, string $testId, string $fallbackBranch = 'main'): ?TestStatus From c6a42a2b289ac0ca1879212a9601b52bfa3a059a Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Wed, 22 Apr 2026 08:07:52 -0700 Subject: [PATCH 030/160] wip --- .github/workflows/tests.yml | 18 + bin/pest | 9 + composer.json | 5 +- src/Plugins/Tia.php | 63 ++- src/Plugins/Tia/BaselineSync.php | 79 +++- src/Support/XdebugGuard.php | 179 +++++++ tests-tia/AffectedSetTest.php | 63 +++ tests-tia/FingerprintDriftTest.php | 52 +++ .../Fixtures/sample-project/composer.json | 27 ++ tests-tia/Fixtures/sample-project/phpunit.xml | 22 + .../Fixtures/sample-project/src/Greeter.php | 13 + .../Fixtures/sample-project/src/Math.php | 13 + .../sample-project/tests/GreeterTest.php | 9 + .../sample-project/tests/MathTest.php | 13 + .../Fixtures/sample-project/tests/Pest.php | 7 + tests-tia/RebuildTest.php | 28 ++ tests-tia/RecordReplayTest.php | 42 ++ tests-tia/SourceRevertTest.php | 46 ++ tests-tia/StatusReplayTest.php | 53 +++ tests-tia/Support/Sandbox.php | 440 ++++++++++++++++++ tests-tia/bootstrap.php | 65 +++ tests-tia/phpunit.xml | 17 + 22 files changed, 1259 insertions(+), 4 deletions(-) create mode 100644 src/Support/XdebugGuard.php create mode 100644 tests-tia/AffectedSetTest.php create mode 100644 tests-tia/FingerprintDriftTest.php create mode 100644 tests-tia/Fixtures/sample-project/composer.json create mode 100644 tests-tia/Fixtures/sample-project/phpunit.xml create mode 100644 tests-tia/Fixtures/sample-project/src/Greeter.php create mode 100644 tests-tia/Fixtures/sample-project/src/Math.php create mode 100644 tests-tia/Fixtures/sample-project/tests/GreeterTest.php create mode 100644 tests-tia/Fixtures/sample-project/tests/MathTest.php create mode 100644 tests-tia/Fixtures/sample-project/tests/Pest.php create mode 100644 tests-tia/RebuildTest.php create mode 100644 tests-tia/RecordReplayTest.php create mode 100644 tests-tia/SourceRevertTest.php create mode 100644 tests-tia/StatusReplayTest.php create mode 100644 tests-tia/Support/Sandbox.php create mode 100644 tests-tia/bootstrap.php create mode 100644 tests-tia/phpunit.xml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index ce3b3349b..fa653501c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -76,3 +76,21 @@ jobs: - name: Integration Tests run: composer test:integration + + # tests-tia records coverage inside its sandbox, which requires + # pcov (or xdebug) in the process PHP. The main setup-php step is + # `coverage: none` for speed — re-enable pcov here just for the + # TIA step. Cheap: pcov startup is near-zero. + - name: Enable pcov for TIA + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: pcov + extensions: sockets + + - name: TIA End-to-End Tests + # Black-box tests drive Pest `--tia` against a throw-away sandbox. + # First scenario takes ~60s (composer-installs the host Pest into a + # cached template); subsequent clones are cheap. + run: composer test:tia diff --git a/bin/pest b/bin/pest index 8cd277887..10a65dd00 100755 --- a/bin/pest +++ b/bin/pest @@ -142,6 +142,15 @@ use Symfony\Component\Console\Output\ConsoleOutput; // Get $rootPath based on $autoloadPath $rootPath = dirname($autoloadPath, 2); + + // Re-execs PHP without Xdebug on TIA replay runs so repeat `--tia` + // invocations aren't slowed by a coverage driver they don't use. Plain + // `pest` runs are left alone — users may rely on Xdebug for IDE + // breakpoints, step-through debugging, or custom tooling. See + // XdebugGuard for the full decision (coverage / tia-rebuild / Xdebug + // mode gates). + \Pest\Support\XdebugGuard::maybeDrop($rootPath); + $input = new ArgvInput; $testSuite = TestSuite::getInstance( diff --git a/composer.json b/composer.json index 1f3eadf26..c9dc5eb36 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "require": { "php": "^8.3.0", "brianium/paratest": "^7.20.0", + "composer/xdebug-handler": "^3.0.5", "nunomaduro/collision": "^8.9.4", "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^4.0.0", @@ -92,6 +93,7 @@ "test:inline": "php bin/pest --configuration=phpunit.inline.xml", "test:parallel": "php bin/pest --exclude-group=integration --parallel --processes=3", "test:integration": "php bin/pest --group=integration -v", + "test:tia": "php bin/pest --configuration=tests-tia/phpunit.xml", "update:snapshots": "REBUILD_SNAPSHOTS=true php bin/pest --update-snapshots", "test": [ "@test:lint", @@ -99,7 +101,8 @@ "@test:type:coverage", "@test:unit", "@test:parallel", - "@test:integration" + "@test:integration", + "@test:tia" ] }, "extra": { diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index e66ee2394..9a57f5abc 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -74,6 +74,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private const string REBUILD_OPTION = '--tia-rebuild'; + /** + * Bypasses `BaselineSync`'s post-failure cooldown. After a failed + * baseline fetch, subsequent `--tia` runs skip the fetch for 24h; this + * flag forces an immediate retry (e.g. right after publishing a + * baseline from CI for the first time). + */ + private const string REFETCH_OPTION = '--tia-refetch'; + /** * State keys under which TIA persists its blobs. Kept here as constants * (rather than scattered strings) so the storage layout is visible in @@ -103,6 +111,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable */ public const string KEY_COVERAGE_MARKER = 'coverage.marker'; + /** + * Cooldown marker keyed by `BaselineSync` after a failed fetch. Holds + * `{"until": }` — subsequent runs within the window skip the + * fetch attempt (and its `gh run list` network hop) until the + * cooldown expires or the user passes `--tia-refetch`. + */ + public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json'; + /** * Global flag toggled by the parent process so workers know to record. */ @@ -199,6 +215,12 @@ private function workerResultsKey(string $token): string */ private bool $recordingActive = false; + /** + * True when `--tia-refetch` is in the current argv — `BaselineSync` + * uses it to bypass the post-failure fetch cooldown. + */ + private bool $forceRefetch = false; + public function __construct( private readonly OutputInterface $output, private readonly Recorder $recorder, @@ -310,13 +332,15 @@ public function handleArguments(array $arguments): array $enabled = $this->hasArgument(self::OPTION, $arguments); $forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments); + $this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments); - if (! $enabled && ! $forceRebuild && ! $recordingGlobal && ! $replayingGlobal) { + if (! $enabled && ! $forceRebuild && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) { return $arguments; } $arguments = $this->popArgument(self::OPTION, $arguments); $arguments = $this->popArgument(self::REBUILD_OPTION, $arguments); + $arguments = $this->popArgument(self::REFETCH_OPTION, $arguments); // When `--coverage` is active, piggyback on PHPUnit's CodeCoverage // instead of starting our own PCOV / Xdebug session. Running two @@ -401,6 +425,16 @@ public function terminate(): void $graph->replaceEdges($perTest); $graph->pruneMissingTests(); + // Fold in the results collected during this same record run. The + // `AddsOutput` pass that runs `snapshotTestResults` fires *before* + // `terminate()` in the shutdown chain, so by the time the graph + // lands on disk, the snapshot pass has already returned empty. + // Writing results here means a first `--tia` invocation produces + // a graph with edges *and* results — the immediate next run hits + // cache for every unchanged test rather than needing a "warm-up" + // pass. + $this->seedResultsInto($graph); + if (! $this->saveGraph($graph)) { $this->output->writeln(' TIA failed to write graph.'); $recorder->reset(); @@ -635,7 +669,7 @@ private function handleParent(array $arguments, string $projectRoot, bool $force // to pull a team-shared baseline so fresh checkouts (new devs, CI // containers) don't pay the full record cost. If the pull succeeds // the graph is re-read and reconciled against the local env. - if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot)) { + if (! $graph instanceof Graph && ! $forceRebuild && $this->baselineSync->fetchIfAvailable($projectRoot, $this->forceRefetch)) { $graph = $this->loadGraph($projectRoot); if ($graph instanceof Graph) { $graph = $this->reconcileFingerprint($graph, $fingerprint); @@ -1147,6 +1181,31 @@ private function bumpRecordedSha(): void $this->saveGraph($graph); } + /** + * In-memory equivalent of `snapshotTestResults()` — transfers the + * collected results straight into the given graph instance without a + * load/save round-trip. Used on the record path where the graph + * hasn't hit disk yet and a separate `loadGraph()` would find nothing. + */ + private function seedResultsInto(Graph $graph): void + { + /** @var ResultCollector $collector */ + $collector = Container::getInstance()->get(ResultCollector::class); + + foreach ($collector->all() as $testId => $result) { + $graph->setResult( + $this->branch, + $testId, + $result['status'], + $result['message'], + $result['time'], + $result['assertions'], + ); + } + + $collector->reset(); + } + /** * Merges per-test status + message from the `ResultCollector` into the * TIA graph. Runs after every `--tia` invocation so the graph always has diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 97466f857..399bb72ee 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -62,6 +62,15 @@ private const string COVERAGE_ASSET = Tia::KEY_COVERAGE_CACHE; + /** + * Cooldown (in seconds) applied after a failed baseline fetch. + * Rationale: when the remote workflow hasn't published yet, every + * `pest --tia` invocation would otherwise re-hit `gh run list` and + * re-print the publish instructions — noisy + slow. Back off for a + * day, let the user override with `--tia-refetch`. + */ + private const int FETCH_COOLDOWN_SECONDS = 86400; + public function __construct( private State $state, private OutputInterface $output, @@ -72,8 +81,12 @@ public function __construct( * contents into the TIA state store. Returns true when the graph blob * landed; coverage is best-effort since plain `--tia` (no `--coverage`) * never reads it. + * + * `$force = true` (driven by `--tia-refetch`) ignores the post-failure + * cooldown so the user can retry on demand without waiting out the + * 24h window. */ - public function fetchIfAvailable(string $projectRoot): bool + public function fetchIfAvailable(string $projectRoot, bool $force = false): bool { $repo = $this->detectGitHubRepo($projectRoot); @@ -81,6 +94,16 @@ public function fetchIfAvailable(string $projectRoot): bool return false; } + if (! $force && ($remaining = $this->cooldownRemaining()) !== null) { + $this->output->writeln(sprintf( + ' TIA last fetch found no baseline — next auto-retry in %s. ' + .'Override with --tia-refetch.', + $this->formatDuration($remaining), + )); + + return false; + } + $this->output->writeln(sprintf( ' TIA fetching baseline from %s…', $repo, @@ -89,6 +112,7 @@ public function fetchIfAvailable(string $projectRoot): bool $payload = $this->download($repo); if ($payload === null) { + $this->startCooldown(); $this->emitPublishInstructions($repo); return false; @@ -102,6 +126,11 @@ public function fetchIfAvailable(string $projectRoot): bool $this->state->write(Tia::KEY_COVERAGE_CACHE, $payload['coverage']); } + // Successful fetch wipes any stale cooldown so the next failure + // (say, weeks later) starts a fresh 24h timer rather than inheriting + // one from the deep past. + $this->clearCooldown(); + $this->output->writeln(sprintf( ' TIA baseline ready (%s).', $this->formatSize(strlen($payload['graph']) + strlen($payload['coverage'] ?? '')), @@ -110,6 +139,54 @@ public function fetchIfAvailable(string $projectRoot): bool return true; } + /** + * Seconds left on the cooldown, or `null` when the cooldown is cleared + * / expired / unreadable. + */ + private function cooldownRemaining(): ?int + { + $raw = $this->state->read(Tia::KEY_FETCH_COOLDOWN); + + if ($raw === null) { + return null; + } + + $decoded = json_decode($raw, true); + + if (! is_array($decoded) || ! isset($decoded['until']) || ! is_int($decoded['until'])) { + return null; + } + + $remaining = $decoded['until'] - time(); + + return $remaining > 0 ? $remaining : null; + } + + private function startCooldown(): void + { + $this->state->write(Tia::KEY_FETCH_COOLDOWN, (string) json_encode([ + 'until' => time() + self::FETCH_COOLDOWN_SECONDS, + ])); + } + + private function clearCooldown(): void + { + $this->state->delete(Tia::KEY_FETCH_COOLDOWN); + } + + private function formatDuration(int $seconds): string + { + if ($seconds >= 3600) { + return (int) round($seconds / 3600).'h'; + } + + if ($seconds >= 60) { + return (int) round($seconds / 60).'m'; + } + + return $seconds.'s'; + } + /** * Prints actionable instructions for publishing a first baseline when * the consumer-side fetch finds nothing. diff --git a/src/Support/XdebugGuard.php b/src/Support/XdebugGuard.php new file mode 100644 index 000000000..539a90ea0 --- /dev/null +++ b/src/Support/XdebugGuard.php @@ -0,0 +1,179 @@ +check(); + } + + /** + * True when Xdebug 3+ is running in coverage-only mode (or empty). False + * for older Xdebug without `xdebug_info` — be conservative and leave it + * loaded; we can't prove the mode is safe to drop. + */ + private static function xdebugIsCoverageOnly(): bool + { + if (! function_exists('xdebug_info')) { + return false; + } + + $modes = @xdebug_info('mode'); + + if (! is_array($modes)) { + return false; + } + + $modes = array_values(array_filter($modes, is_string(...))); + + if ($modes === []) { + return true; + } + + return $modes === ['coverage']; + } + + /** + * Encodes the argv-based rules: `--tia` must be present, no coverage + * flag, no forced rebuild, and TIA must be about to replay rather than + * record. Plain `pest` (and anything else without `--tia`) keeps Xdebug + * loaded so non-TIA users aren't surprised by behaviour changes. + * + * @param array $argv + */ + private static function runLooksDroppable(array $argv, string $projectRoot): bool + { + $hasTia = false; + + foreach ($argv as $value) { + if (! is_string($value)) { + continue; + } + + if ($value === '--coverage' + || str_starts_with($value, '--coverage=') + || str_starts_with($value, '--coverage-')) { + return false; + } + + if ($value === '--tia-rebuild') { + return false; + } + + if ($value === '--tia') { + $hasTia = true; + } + } + + if (! $hasTia) { + return false; + } + + return self::tiaWillReplay($projectRoot); + } + + /** + * True when a valid TIA graph already lives on disk AND its structural + * fingerprint matches the current environment. Any other outcome + * (missing graph, unreadable JSON, structural drift) means TIA will + * record and the driver must stay loaded. + */ + private static function tiaWillReplay(string $projectRoot): bool + { + $path = self::graphPath(); + + if (! is_file($path)) { + return false; + } + + $json = @file_get_contents($path); + + if ($json === false) { + return false; + } + + $graph = Graph::decode($json, $projectRoot); + + if (! $graph instanceof Graph) { + return false; + } + + return Fingerprint::structuralMatches( + $graph->fingerprint(), + Fingerprint::compute($projectRoot), + ); + } + + /** + * On-disk location of the TIA graph — mirrors `Bootstrapper::tempDir()` + * so both writer and reader stay in sync without a runtime container + * lookup (the container isn't booted yet at this point). + */ + private static function graphPath(): string + { + return dirname(__DIR__, 2) + .DIRECTORY_SEPARATOR.'.temp' + .DIRECTORY_SEPARATOR.'tia' + .DIRECTORY_SEPARATOR.Tia::KEY_GRAPH; + } +} diff --git a/tests-tia/AffectedSetTest.php b/tests-tia/AffectedSetTest.php new file mode 100644 index 000000000..288b22277 --- /dev/null +++ b/tests-tia/AffectedSetTest.php @@ -0,0 +1,63 @@ +pest(['--tia']); + + $sandbox->write('src/Math.php', <<<'PHP' +pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toMatch('/2 affected,\s*1 replayed/'); + }); +}); + +test('adding a new test file runs the new test + replays the rest', function () { + tiaScenario(function (Sandbox $sandbox) { + $sandbox->pest(['--tia']); + + $sandbox->write('tests/ExtraTest.php', <<<'PHP' +toBeTrue(); +}); +PHP); + + $process = $sandbox->pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toMatch('/1 affected,\s*3 replayed/'); + }); +}); diff --git a/tests-tia/FingerprintDriftTest.php b/tests-tia/FingerprintDriftTest.php new file mode 100644 index 000000000..b7c5fccb0 --- /dev/null +++ b/tests-tia/FingerprintDriftTest.php @@ -0,0 +1,52 @@ +pest(['--tia']); + + $graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json'; + $graph = json_decode((string) file_get_contents($graphPath), true); + $graph['fingerprint']['structural']['composer_lock'] = str_repeat('0', 32); + file_put_contents($graphPath, json_encode($graph)); + + $process = $sandbox->pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toContain('graph structure outdated'); + }); +}); + +test('environmental drift keeps edges, drops results', function () { + tiaScenario(function (Sandbox $sandbox) { + $sandbox->pest(['--tia']); + + $graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json'; + $graph = json_decode((string) file_get_contents($graphPath), true); + + $edgeCountBefore = count($graph['edges']); + + $graph['fingerprint']['environmental']['php_minor'] = '7.4'; + $graph['fingerprint']['environmental']['extensions'] = str_repeat('0', 32); + file_put_contents($graphPath, json_encode($graph)); + + $process = $sandbox->pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toContain('env differs from baseline'); + expect(tiaOutput($process))->toContain('results dropped, edges reused'); + + $graphAfter = $sandbox->graph(); + expect(count($graphAfter['edges']))->toBe($edgeCountBefore); + expect($graphAfter['fingerprint']['environmental']['php_minor']) + ->not()->toBe('7.4'); + }); +}); diff --git a/tests-tia/Fixtures/sample-project/composer.json b/tests-tia/Fixtures/sample-project/composer.json new file mode 100644 index 000000000..a72a1f25d --- /dev/null +++ b/tests-tia/Fixtures/sample-project/composer.json @@ -0,0 +1,27 @@ +{ + "name": "pest/tia-sample-project", + "type": "project", + "description": "Throw-away fixture used by tests-tia to exercise TIA end-to-end.", + "require": { + "php": "^8.3" + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Tests\\": "tests/" + } + }, + "config": { + "sort-packages": true, + "allow-plugins": { + "pestphp/pest-plugin": true, + "php-http/discovery": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/tests-tia/Fixtures/sample-project/phpunit.xml b/tests-tia/Fixtures/sample-project/phpunit.xml new file mode 100644 index 000000000..0c3e59357 --- /dev/null +++ b/tests-tia/Fixtures/sample-project/phpunit.xml @@ -0,0 +1,22 @@ + + + + + tests + + + + + src + + + diff --git a/tests-tia/Fixtures/sample-project/src/Greeter.php b/tests-tia/Fixtures/sample-project/src/Greeter.php new file mode 100644 index 000000000..e952204ea --- /dev/null +++ b/tests-tia/Fixtures/sample-project/src/Greeter.php @@ -0,0 +1,13 @@ +toBe('Hello, Nuno!'); +}); diff --git a/tests-tia/Fixtures/sample-project/tests/MathTest.php b/tests-tia/Fixtures/sample-project/tests/MathTest.php new file mode 100644 index 000000000..609d97ea6 --- /dev/null +++ b/tests-tia/Fixtures/sample-project/tests/MathTest.php @@ -0,0 +1,13 @@ +toBe(5); +}); + +test('math add negative', function () { + expect(Math::add(-1, 1))->toBe(0); +}); diff --git a/tests-tia/Fixtures/sample-project/tests/Pest.php b/tests-tia/Fixtures/sample-project/tests/Pest.php new file mode 100644 index 000000000..f987b0709 --- /dev/null +++ b/tests-tia/Fixtures/sample-project/tests/Pest.php @@ -0,0 +1,7 @@ +pest(['--tia']); + expect($sandbox->hasGraph())->toBeTrue(); + + $graphBefore = $sandbox->graph(); + + $process = $sandbox->pest(['--tia', '--tia-rebuild']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toContain('recording dependency graph'); + + $graphAfter = $sandbox->graph(); + expect(array_keys($graphAfter['edges'])) + ->toEqualCanonicalizing(array_keys($graphBefore['edges'])); + }); +}); diff --git a/tests-tia/RecordReplayTest.php b/tests-tia/RecordReplayTest.php new file mode 100644 index 000000000..b4498e5a5 --- /dev/null +++ b/tests-tia/RecordReplayTest.php @@ -0,0 +1,42 @@ +pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + expect(tiaOutput($process))->toContain('recording dependency graph'); + expect($sandbox->hasGraph())->toBeTrue(); + + $graph = $sandbox->graph(); + expect($graph)->toHaveKey('edges'); + expect(array_keys($graph['edges']))->toContain('tests/MathTest.php'); + expect(array_keys($graph['edges']))->toContain('tests/GreeterTest.php'); + }); +}); + +test('warm run replays every test', function () { + tiaScenario(function (Sandbox $sandbox) { + // Cold pass: records edges AND snapshots results (series mode + // runs `snapshotTestResults` in the same `addOutput` pass). + $sandbox->pest(['--tia']); + + $process = $sandbox->pest(['--tia']); + + expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); + // Zero changes → only the `replayed` fragment appears in the + // recap; the `affected` fragment is omitted when count is 0. + expect(tiaOutput($process))->toMatch('/3 replayed/'); + expect(tiaOutput($process))->not()->toMatch('/\d+ affected/'); + }); +}); diff --git a/tests-tia/SourceRevertTest.php b/tests-tia/SourceRevertTest.php new file mode 100644 index 000000000..c32fb50cb --- /dev/null +++ b/tests-tia/SourceRevertTest.php @@ -0,0 +1,46 @@ +pest(['--tia']); + + $original = (string) file_get_contents($sandbox->path().'/src/Math.php'); + + $sandbox->write('src/Math.php', <<<'PHP' +pest(['--tia']); + expect($broken->isSuccessful())->toBeFalse(); + + $sandbox->write('src/Math.php', $original); + + $recovered = $sandbox->pest(['--tia']); + + expect($recovered->isSuccessful())->toBeTrue(tiaOutput($recovered)); + expect(tiaOutput($recovered))->toMatch('/2 affected,\s*1 replayed/'); + }); +}); diff --git a/tests-tia/StatusReplayTest.php b/tests-tia/StatusReplayTest.php new file mode 100644 index 000000000..d1eb4f0f6 --- /dev/null +++ b/tests-tia/StatusReplayTest.php @@ -0,0 +1,53 @@ +pest(['--tia']); + + $process = $sandbox->pest(['--tia']); + $output = tiaOutput($process); + + // MathTest has 2 assertions, GreeterTest has 1 → 3 total. + // The "Tests: … (N assertions, … replayed)" banner should show 3. + expect($output)->toMatch('/\(3 assertions/'); + }); +}); + +test('breaking a test replays as a failure on the next run', function () { + tiaScenario(function (Sandbox $sandbox) { + // Prime. + $sandbox->pest(['--tia']); + + // Break the test. Its test file's edge map still points at + // `src/Math.php`; editing the test file counts as a change + // and the test re-executes. + $sandbox->write('tests/MathTest.php', <<<'PHP' +toBe(999); // wrong +}); + +test('math add negative', function () { + expect(Math::add(-1, 1))->toBe(0); +}); +PHP); + + $process = $sandbox->pest(['--tia']); + + expect($process->isSuccessful())->toBeFalse(); + expect(tiaOutput($process))->toContain('math add'); + }); +}); diff --git a/tests-tia/Support/Sandbox.php b/tests-tia/Support/Sandbox.php new file mode 100644 index 000000000..4c3361a21 --- /dev/null +++ b/tests-tia/Support/Sandbox.php @@ -0,0 +1,440 @@ +path; + } + + public function write(string $relative, string $content): void + { + $absolute = $this->path.DIRECTORY_SEPARATOR.$relative; + $dir = dirname($absolute); + + if (! is_dir($dir) && ! @mkdir($dir, 0755, true) && ! is_dir($dir)) { + throw new RuntimeException("Cannot create {$dir}"); + } + + if (@file_put_contents($absolute, $content) === false) { + throw new RuntimeException("Cannot write {$absolute}"); + } + } + + public function delete(string $relative): void + { + $absolute = $this->path.DIRECTORY_SEPARATOR.$relative; + + if (is_file($absolute)) { + @unlink($absolute); + } + } + + /** + * @param array $flags + */ + public function pest(array $flags = []): Process + { + // Invoke Pest's bin script through PHP directly rather than the + // `vendor/bin/pest` symlink — `rcopy()` loses the `+x` bit when + // cloning the template. Going through `php` bypasses the exec + // check. Use `PHP_BINARY` (not a bare `php`) so the sandbox + // executes under the same interpreter that launched the outer + // test suite — otherwise macOS multi-version setups (Herd, brew, + // asdf, …) fall back to the first `php` on `$PATH`, which often + // lacks the coverage driver TIA's record mode needs. + $process = new Process( + [PHP_BINARY, 'vendor/pestphp/pest/bin/pest', ...$flags], + $this->path, + [ + // Strip any CI signal so TIA doesn't suppress instructions. + 'GITHUB_ACTIONS' => '', + 'GITLAB_CI' => '', + 'CIRCLECI' => '', + ], + ); + $process->setTimeout(120.0); + $process->run(); + + return $process; + } + + /** + * @return array|null + */ + public function graph(): ?array + { + $path = $this->path.'/vendor/pestphp/pest/.temp/tia/graph.json'; + + if (! is_file($path)) { + return null; + } + + $raw = @file_get_contents($path); + + if ($raw === false) { + return null; + } + + $decoded = json_decode($raw, true); + + return is_array($decoded) ? $decoded : null; + } + + public function hasGraph(): bool + { + return $this->graph() !== null; + } + + /** + * @param array $args + */ + public function git(array $args): Process + { + $process = new Process(['git', ...$args], $this->path); + $process->setTimeout(30.0); + $process->run(); + + return $process; + } + + public function destroy(): void + { + if (getenv('PEST_TIA_KEEP') === '1') { + fwrite(STDERR, "[PEST_TIA_KEEP] sandbox: {$this->path}\n"); + + return; + } + + if (is_dir($this->path)) { + self::rrmdir($this->path); + } + } + + /** + * Lazily provisions a once-per-process template with composer already + * installed against the host Pest source. Every sandbox clone copies + * from here, avoiding a ~30s composer install per test. + */ + private static function ensureTemplate(): string + { + if (self::$templatePath !== null && is_dir(self::$templatePath.'/vendor')) { + return self::$templatePath; + } + + // Cache key includes a fingerprint of the host Pest source tree — + // when we edit Pest internals, the key changes, old templates + // become orphaned, the new template rebuilds. Without this, a + // stale template with yesterday's Pest code silently masks today's + // code under test. + $template = sys_get_temp_dir() + .DIRECTORY_SEPARATOR + .'pest-tia-template-' + .self::hostFingerprint(); + + // Serialise template creation across parallel paratest workers. + // Without the lock, three workers hitting `ensureTemplate()` + // simultaneously each see "no vendor yet → rebuild", stomp on + // each other's composer install, and produce half-written + // fixtures. `flock` on a sibling lockfile keeps it to one + // builder; the others block, then observe the finished + // template and skip straight to the fast path. + $lockPath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-template.lock'; + $lock = fopen($lockPath, 'c'); + + if ($lock === false) { + throw new RuntimeException('Cannot open template lock at '.$lockPath); + } + + flock($lock, LOCK_EX); + + try { + // Re-check after acquiring the lock — another worker may have + // just finished the build while we were waiting. + if (is_dir($template.'/vendor')) { + self::$templatePath = $template; + + return $template; + } + + // Garbage-collect every older template keyed by a different + // fingerprint so /tmp doesn't accumulate a 200 MB graveyard + // over a month of edits. + foreach (glob(sys_get_temp_dir().DIRECTORY_SEPARATOR.'pest-tia-template-*') ?: [] as $orphan) { + if ($orphan !== $template) { + self::rrmdir($orphan); + } + } + + if (is_dir($template)) { + self::rrmdir($template); + } + + $fixture = __DIR__.'/../Fixtures/sample-project'; + + if (! is_dir($fixture)) { + throw new RuntimeException('Missing fixture at '.$fixture); + } + + if (! @mkdir($template, 0755, true) && ! is_dir($template)) { + throw new RuntimeException('Cannot create template at '.$template); + } + + self::rcopy($fixture, $template); + self::wireHostPest($template); + self::composerInstall($template); + + self::$templatePath = $template; + + return $template; + } finally { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + private static function wireHostPest(string $path): void + { + $hostRoot = realpath(__DIR__.'/../..'); + + if ($hostRoot === false) { + throw new RuntimeException('Cannot resolve host Pest root'); + } + + $composerJson = $path.'/composer.json'; + $decoded = json_decode((string) file_get_contents($composerJson), true); + + $decoded['repositories'] = [ + ['type' => 'path', 'url' => $hostRoot, 'options' => ['symlink' => false]], + ]; + $decoded['require']['pestphp/pest'] = '*@dev'; + + file_put_contents( + $composerJson, + json_encode($decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)."\n", + ); + } + + private static function composerInstall(string $path): void + { + // Invoke composer via the *same* PHP binary that's running this + // process. On macOS multi-version setups (Herd, brew, asdf, etc.) + // the `composer` shebang often points at the system PHP, which + // may not match the version the test suite booted with — leading + // to "your PHP version does not satisfy the requirement" errors + // even when the interpreter in use would satisfy it. Going + // through `PHP_BINARY` + the located composer binary/phar + // sidesteps that entirely. + $composer = self::locateComposer(); + $args = $composer === null + ? ['composer', 'install'] + : [PHP_BINARY, $composer, 'install']; + + $process = new Process( + [...$args, '--no-interaction', '--prefer-dist', '--no-progress', '--quiet'], + $path, + ); + $process->setTimeout(600.0); + $process->run(); + + if (! $process->isSuccessful()) { + throw new RuntimeException( + "composer install failed in template:\n".$process->getOutput().$process->getErrorOutput(), + ); + } + } + + /** + * Resolves the composer binary to a real path PHP can execute. Returns + * `null` when composer isn't findable, in which case the caller falls + * back to invoking plain `composer` via `$PATH` (and hopes for the + * best — usually fine on CI Linux runners). + */ + private static function locateComposer(): ?string + { + $probe = new Process(['command', '-v', 'composer']); + $probe->run(); + + $path = trim($probe->getOutput()); + + if ($path === '' || ! is_file($path)) { + return null; + } + + // `composer` may be a shell-script wrapper (Herd does this) — + // resolve the actual phar it invokes. Heuristic: parse out the + // last `.phar` argument from the wrapper, fall back to the file + // itself if no wrapper is detected. + $content = @file_get_contents($path); + + if ($content !== false && preg_match('/\S+\.phar/', $content, $m) === 1) { + $phar = $m[0]; + + if (is_file($phar)) { + return $phar; + } + } + + return $path; + } + + private static function bootstrapGit(string $path): void + { + // Each clone needs its own repo — TIA's SHA / branch / diff logic + // all rely on `.git/`. The template has no git dir so clones start + // from a clean slate. + $run = function (array $args) use ($path): void { + $process = new Process(['git', ...$args], $path); + $process->setTimeout(30.0); + $process->run(); + + if (! $process->isSuccessful()) { + throw new RuntimeException('git '.implode(' ', $args).' failed: '.$process->getErrorOutput()); + } + }; + + // `.git` may have been cloned from the template if we ever add one + // there — nuke it just in case so every sandbox starts fresh. + if (is_dir($path.'/.git')) { + self::rrmdir($path.'/.git'); + } + + // Keep `vendor/` and composer lock out of the sandbox's git repo + // entirely. With ~thousands of files `git add .` takes tens of + // seconds; TIA also ignores vendor paths via `shouldIgnore()` so + // tracking them buys nothing except slowness. + file_put_contents($path.'/.gitignore', "vendor/\ncomposer.lock\n"); + + $run(['init', '-q', '-b', 'main']); + $run(['config', 'user.email', 'sandbox@pest.test']); + $run(['config', 'user.name', 'Pest Sandbox']); + $run(['config', 'commit.gpgsign', 'false']); + $run(['add', '.']); + $run(['commit', '-q', '-m', 'initial']); + } + + /** + * Short hash derived from the host Pest source that the template is + * built against. Hashing the newest mtime across `src/`, `overrides/`, + * and `composer.json` is cheap (one stat each) and catches every edit + * that could alter TIA behaviour. + */ + private static function hostFingerprint(): string + { + $hostRoot = realpath(__DIR__.'/../..'); + + if ($hostRoot === false) { + return 'unknown'; + } + + $newest = 0; + + foreach ([$hostRoot.'/src', $hostRoot.'/overrides'] as $dir) { + if (! is_dir($dir)) { + continue; + } + + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iter as $file) { + if ($file->isFile()) { + $newest = max($newest, $file->getMTime()); + } + } + } + + if (is_file($hostRoot.'/composer.json')) { + $newest = max($newest, (int) filemtime($hostRoot.'/composer.json')); + } + + return substr(sha1($hostRoot.'|'.PHP_VERSION.'|'.$newest), 0, 12); + } + + private static function rcopy(string $src, string $dest): void + { + if (! is_dir($dest) && ! @mkdir($dest, 0755, true) && ! is_dir($dest)) { + throw new RuntimeException("Cannot create {$dest}"); + } + + $iter = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($src, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::SELF_FIRST, + ); + + foreach ($iter as $item) { + $target = $dest.DIRECTORY_SEPARATOR.$iter->getSubPathname(); + + if ($item->isDir()) { + @mkdir($target, 0755, true); + } else { + copy($item->getPathname(), $target); + } + } + } + + private static function rrmdir(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + // `rm -rf` shells out but handles symlinks, read-only files, and + // the composer-vendor quirks (lock files, .bin symlinks) that + // PHP's own recursive delete stumbles on. Non-fatal on failure. + $process = new Process(['rm', '-rf', $dir]); + $process->setTimeout(60.0); + $process->run(); + } +} diff --git a/tests-tia/bootstrap.php b/tests-tia/bootstrap.php new file mode 100644 index 000000000..65f4ee115 --- /dev/null +++ b/tests-tia/bootstrap.php @@ -0,0 +1,65 @@ +destroy(); + } +} + +/** + * Strip ANSI escapes so assertions are terminal-agnostic. + */ +function tiaOutput(Process $process): string +{ + $output = $process->getOutput().$process->getErrorOutput(); + + return preg_replace('/\e\[[0-9;]*m/', '', $output) ?? $output; +} diff --git a/tests-tia/phpunit.xml b/tests-tia/phpunit.xml new file mode 100644 index 000000000..99c042e84 --- /dev/null +++ b/tests-tia/phpunit.xml @@ -0,0 +1,17 @@ + + + + + . + Fixtures + Support + + + From 68527c996fdbbfb5f7ca9ff66a49ee897af1e45e Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Wed, 22 Apr 2026 08:25:38 -0700 Subject: [PATCH 031/160] wip --- src/Plugins/Tia/Fingerprint.php | 71 ++++++++++++++++++++++++++++++--- 1 file changed, 65 insertions(+), 6 deletions(-) diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 0cc743a2d..076273a32 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -60,7 +60,7 @@ public static function compute(string $projectRoot): array // almost never matches a dev's Herd/Homebrew install, and // the patch rarely changes anything test-visible. 'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION, - 'extensions' => self::extensionsFingerprint(), + 'extensions' => self::extensionsFingerprint($projectRoot), 'pest' => self::readPestVersion($projectRoot), ], ]; @@ -174,24 +174,83 @@ private static function hashIfExists(string $path): ?string } /** - * Deterministic hash of the PHP extension set: `ext-name@version` pairs - * sorted alphabetically and joined. + * Deterministic hash of the extensions the project actually depends on — + * the `ext-*` entries in composer.json's `require` / `require-dev`. An + * incidental extension loaded on the developer's machine (or on CI) but + * not declared as a dependency can't affect correctness of the test + * suite, so we ignore it here to keep the drift signal quiet. + * + * Declared extensions that aren't currently loaded record as `missing`, + * which is itself a drift signal worth surfacing. */ - private static function extensionsFingerprint(): string + private static function extensionsFingerprint(string $projectRoot): string { - $extensions = get_loaded_extensions(); + $extensions = self::declaredExtensions($projectRoot); + + if ($extensions === []) { + return hash('xxh128', ''); + } + sort($extensions); $parts = []; foreach ($extensions as $name) { $version = phpversion($name); - $parts[] = $name.'@'.($version === false ? '?' : $version); + $parts[] = $name.'@'.($version === false ? 'missing' : $version); } return hash('xxh128', implode("\n", $parts)); } + /** + * Extension names (without the `ext-` prefix) that appear as keys under + * `require` or `require-dev` in the project's composer.json. Returns + * an empty list when composer.json is missing / unreadable / malformed, + * so the environmental fingerprint stays stable in those cases rather + * than flapping. + * + * @return list + */ + private static function declaredExtensions(string $projectRoot): array + { + $path = $projectRoot.'/composer.json'; + + if (! is_file($path)) { + return []; + } + + $raw = @file_get_contents($path); + + if ($raw === false) { + return []; + } + + $data = json_decode($raw, true); + + if (! is_array($data)) { + return []; + } + + $extensions = []; + + foreach (['require', 'require-dev'] as $section) { + $packages = $data[$section] ?? null; + + if (! is_array($packages)) { + continue; + } + + foreach (array_keys($packages) as $package) { + if (is_string($package) && str_starts_with($package, 'ext-')) { + $extensions[] = substr($package, 4); + } + } + } + + return array_values(array_unique($extensions)); + } + private static function readPestVersion(string $projectRoot): string { $installed = $projectRoot.'/vendor/composer/installed.json'; From 660b57b3654f11e0f2d2c381804aae252b5c8736 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Wed, 22 Apr 2026 08:42:32 -0700 Subject: [PATCH 032/160] wip --- src/Plugins/Tia.php | 8 +- src/Plugins/Tia/BaselineSync.php | 16 ++- src/Plugins/Tia/Bootstrapper.php | 21 ++-- src/Plugins/Tia/Storage.php | 170 +++++++++++++++++++++++++++++ src/Support/XdebugGuard.php | 17 ++- tests-tia/FingerprintDriftTest.php | 4 +- tests-tia/Support/Sandbox.php | 9 +- 7 files changed, 215 insertions(+), 30 deletions(-) create mode 100644 src/Plugins/Tia/Storage.php diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 9a57f5abc..df3093b5f 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -30,7 +30,7 @@ * ----- * - **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 `.temp/tia/graph.json`. + * `test → [source_file, …]` edges land in `.pest/tia/graph.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 +53,7 @@ * - **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 `.temp/tia/worker-edges-.json`. + * into `.pest/tia/worker-edges-.json`. * - **Worker, replay**: nothing to do; args already narrowed. * * Guardrails @@ -86,7 +86,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable * State keys under which TIA persists its blobs. Kept here as constants * (rather than scattered strings) so the storage layout is visible in * one place, and so `CoverageMerger` can reference the same keys. All - * files live under `.temp/tia/` — the `tia-` filename prefix is gone + * files live under `.pest/tia/` — the `tia-` filename prefix is gone * because the directory already namespaces them. */ public const string KEY_GRAPH = 'graph.json'; @@ -126,7 +126,7 @@ 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 `.temp/tia/affected.json`. + * Workers read the affected set from `.pest/tia/affected.json`. */ private const string REPLAYING_GLOBAL = 'TIA_REPLAYING'; diff --git a/src/Plugins/Tia/BaselineSync.php b/src/Plugins/Tia/BaselineSync.php index 399bb72ee..0c653fc03 100644 --- a/src/Plugins/Tia/BaselineSync.php +++ b/src/Plugins/Tia/BaselineSync.php @@ -17,7 +17,7 @@ * * Storage: **workflow artifacts**, not releases. A dedicated CI workflow * (conventionally `.github/workflows/tia-baseline.yml`) runs the full - * suite under `--tia` and uploads the `.temp/tia/` directory as a named + * suite under `--tia` and uploads the `.pest/tia/` directory as a named * artifact (`pest-tia-baseline`) containing `graph.json` + * `coverage.bin`. On dev * machines, this class finds the latest successful run of that workflow @@ -285,10 +285,15 @@ private function laravelWorkflowYaml(): string - run: composer install --no-interaction --prefer-dist - run: php artisan key:generate - run: ./vendor/bin/pest --parallel --tia --coverage + - name: Stage baseline for upload + shell: bash + run: | + mkdir -p .pest-tia-baseline + cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/ - uses: actions/upload-artifact@v4 with: name: pest-tia-baseline - path: vendor/pestphp/pest/.temp/tia/ + path: .pest-tia-baseline/ retention-days: 30 YAML; } @@ -311,10 +316,15 @@ private function genericWorkflowYaml(): string with: { php-version: '8.4', coverage: xdebug } - run: composer install --no-interaction --prefer-dist - run: ./vendor/bin/pest --parallel --tia --coverage + - name: Stage baseline for upload + shell: bash + run: | + mkdir -p .pest-tia-baseline + cp -R "$HOME/.pest/tia"/*/. .pest-tia-baseline/ - uses: actions/upload-artifact@v4 with: name: pest-tia-baseline - path: vendor/pestphp/pest/.temp/tia/ + path: .pest-tia-baseline/ retention-days: 30 YAML; } diff --git a/src/Plugins/Tia/Bootstrapper.php b/src/Plugins/Tia/Bootstrapper.php index 5585e945a..78c1b9611 100644 --- a/src/Plugins/Tia/Bootstrapper.php +++ b/src/Plugins/Tia/Bootstrapper.php @@ -7,6 +7,7 @@ use Pest\Contracts\Bootstrapper as BootstrapperContract; use Pest\Plugins\Tia\Contracts\State; use Pest\Support\Container; +use Pest\TestSuite; /** * Plugin-level container registrations for TIA. Runs as part of Kernel's @@ -32,19 +33,17 @@ public function boot(): void } /** - * TIA's own subdirectory under Pest's `.temp/`. Keeping every TIA blob - * in a single folder (`.temp/tia/`) avoids the `tia-`-prefix salad - * alongside PHPUnit's unrelated files (coverage.php, test-results, - * code-coverage/) and makes the CI artifact-upload path a single - * directory instead of a list of individual files. + * TIA's per-project state directory. Default layout is + * `~/.pest/tia//` so the graph survives `composer + * install`, stays out of the project tree, and is naturally shared + * across worktrees of the same repo. See {@see Storage} for the key + * derivation and the home-dir-missing fallback. */ private function tempDir(): string { - return __DIR__ - .DIRECTORY_SEPARATOR.'..' - .DIRECTORY_SEPARATOR.'..' - .DIRECTORY_SEPARATOR.'..' - .DIRECTORY_SEPARATOR.'.temp' - .DIRECTORY_SEPARATOR.'tia'; + $testSuite = $this->container->get(TestSuite::class); + assert($testSuite instanceof TestSuite); + + return Storage::tempDir($testSuite->rootPath); } } diff --git a/src/Plugins/Tia/Storage.php b/src/Plugins/Tia/Storage.php new file mode 100644 index 000000000..d26e6fa4a --- /dev/null +++ b/src/Plugins/Tia/Storage.php @@ -0,0 +1,170 @@ +/`. Keeping state in the + * user's home directory (rather than under `vendor/pestphp/pest/`) means: + * + * - `composer install` / path-repo reinstalls don't wipe the graph. + * - The state lives outside the project tree, so there is nothing for + * users to gitignore or accidentally commit. + * - Multiple worktrees of the same repo share one cache naturally. + * + * The project key is derived from the git origin URL when available — a + * CI workflow running on `github.com/org/repo` and a developer's clone + * of the same remote both compute the *same* key, which is what lets the + * CI-uploaded baseline line up with the dev-side reader. When the project + * is not in git, the key falls back to a hash of the absolute path so + * unrelated projects on the same machine stay isolated. + * + * When no home directory is resolvable (`HOME` / `USERPROFILE` both + * unset — the tests-tia sandboxes strip these deliberately, and some + * locked-down CI environments do the same), state falls back to + * `/.pest/tia/`. That path is project-local but still + * survives composer installs, so the degradation is graceful. + * + * @internal + */ +final class Storage +{ + /** + * Directory where TIA's State blobs live for `$projectRoot`. + */ + public static function tempDir(string $projectRoot): string + { + $home = self::homeDir(); + + if ($home === null) { + return $projectRoot + .DIRECTORY_SEPARATOR.'.pest' + .DIRECTORY_SEPARATOR.'tia'; + } + + return $home + .DIRECTORY_SEPARATOR.'.pest' + .DIRECTORY_SEPARATOR.'tia' + .DIRECTORY_SEPARATOR.self::projectKey($projectRoot); + } + + /** + * OS-neutral home directory — `HOME` on Unix, `USERPROFILE` on + * Windows. Returns null if neither resolves to an existing + * directory, in which case callers fall back to project-local state. + */ + private static function homeDir(): ?string + { + foreach (['HOME', 'USERPROFILE'] as $key) { + $value = getenv($key); + + if (is_string($value) && $value !== '' && is_dir($value)) { + return rtrim($value, '/\\'); + } + } + + return null; + } + + /** + * Folder name for `$projectRoot` under `~/.pest/tia/`. + * + * Strategy — each step rules out a class of collision: + * + * 1. If the project has a git origin URL, use a **normalised** form + * (`host/org/repo`, lowercased, no `.git` suffix) as the input. + * `git@github.com:foo/bar.git`, `ssh://git@github.com/foo/bar` + * and `https://github.com/foo/bar` all collapse to + * `github.com/foo/bar` — three developers cloning the same repo + * by different transports share one cache, which is what we want. + * 2. Otherwise, use the canonicalised absolute path (`realpath`). + * Two unrelated `app/` checkouts under different parent folders + * have different realpaths → different hashes → isolated. + * 3. Hash the chosen input with sha256 and keep the first 16 hex + * chars — 64 bits of entropy makes accidental collision + * astronomically unlikely even across thousands of projects. + * 4. Prefix with a slug of the project basename so `ls ~/.pest/tia/` + * is readable; the slug is cosmetic only, all isolation comes + * from the hash. + * + * Result: `myapp-a1b2c3d4e5f67890`. + */ + private static function projectKey(string $projectRoot): string + { + $origin = self::originIdentity($projectRoot); + + $realpath = @realpath($projectRoot); + $input = $origin ?? ($realpath === false ? $projectRoot : $realpath); + + $hash = substr(hash('sha256', $input), 0, 16); + $slug = self::slug(basename($projectRoot)); + + return $slug === '' ? $hash : $slug.'-'.$hash; + } + + /** + * Canonical git origin identity for `$projectRoot`, or null when + * no origin URL can be parsed. The returned form is + * `host/org/repo` (lowercased, `.git` stripped) so SSH / HTTPS / git + * protocol clones of the same remote produce the same value. + */ + private static function originIdentity(string $projectRoot): ?string + { + $url = self::rawOriginUrl($projectRoot); + + if ($url === null) { + return null; + } + + // git@host:org/repo(.git) + if (preg_match('#^[\w.-]+@([\w.-]+):([\w./-]+?)(?:\.git)?/?$#', $url, $m) === 1) { + return strtolower($m[1].'/'.$m[2]); + } + + // scheme://[user@]host[:port]/org/repo(.git) — https, ssh, git, file + if (preg_match('#^[a-z]+://(?:[^@/]+@)?([^/:]+)(?::\d+)?/([\w./-]+?)(?:\.git)?/?$#i', $url, $m) === 1) { + return strtolower($m[1].'/'.$m[2]); + } + + // Unrecognised form — hash the raw URL so different inputs still + // diverge, but lowercased so the only variance is intentional. + return strtolower($url); + } + + private static function rawOriginUrl(string $projectRoot): ?string + { + $config = $projectRoot.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'config'; + + if (! is_file($config)) { + return null; + } + + $raw = @file_get_contents($config); + + if ($raw === false) { + return null; + } + + if (preg_match('/\[remote "origin"\][^\[]*?url\s*=\s*(\S+)/s', $raw, $match) === 1) { + return trim($match[1]); + } + + return null; + } + + /** + * Filesystem-safe kebab of `$name`. Cosmetic only — used as a + * human-readable prefix on the hash so `~/.pest/tia/` lists + * recognisable folders. + */ + private static function slug(string $name): string + { + $slug = strtolower($name); + $slug = preg_replace('/[^a-z0-9]+/', '-', $slug) ?? ''; + + return trim($slug, '-'); + } +} diff --git a/src/Support/XdebugGuard.php b/src/Support/XdebugGuard.php index 539a90ea0..58cf628a6 100644 --- a/src/Support/XdebugGuard.php +++ b/src/Support/XdebugGuard.php @@ -8,6 +8,7 @@ use Pest\Plugins\Tia; use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Graph; +use Pest\Plugins\Tia\Storage; /** * Re-execs the PHP process without Xdebug on TIA replay runs, matching the @@ -140,7 +141,7 @@ private static function runLooksDroppable(array $argv, string $projectRoot): boo */ private static function tiaWillReplay(string $projectRoot): bool { - $path = self::graphPath(); + $path = self::graphPath($projectRoot); if (! is_file($path)) { return false; @@ -165,15 +166,13 @@ private static function tiaWillReplay(string $projectRoot): bool } /** - * On-disk location of the TIA graph — mirrors `Bootstrapper::tempDir()` - * so both writer and reader stay in sync without a runtime container - * lookup (the container isn't booted yet at this point). + * On-disk location of the TIA graph — delegates to {@see Storage} so + * the writer (TIA's bootstrapper) and this reader stay in sync + * without a runtime container lookup (the container isn't booted yet + * at this point). */ - private static function graphPath(): string + private static function graphPath(string $projectRoot): string { - return dirname(__DIR__, 2) - .DIRECTORY_SEPARATOR.'.temp' - .DIRECTORY_SEPARATOR.'tia' - .DIRECTORY_SEPARATOR.Tia::KEY_GRAPH; + return Storage::tempDir($projectRoot).DIRECTORY_SEPARATOR.Tia::KEY_GRAPH; } } diff --git a/tests-tia/FingerprintDriftTest.php b/tests-tia/FingerprintDriftTest.php index b7c5fccb0..5ac2df51d 100644 --- a/tests-tia/FingerprintDriftTest.php +++ b/tests-tia/FingerprintDriftTest.php @@ -13,7 +13,7 @@ tiaScenario(function (Sandbox $sandbox) { $sandbox->pest(['--tia']); - $graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json'; + $graphPath = $sandbox->path().'/.pest/tia/graph.json'; $graph = json_decode((string) file_get_contents($graphPath), true); $graph['fingerprint']['structural']['composer_lock'] = str_repeat('0', 32); file_put_contents($graphPath, json_encode($graph)); @@ -29,7 +29,7 @@ tiaScenario(function (Sandbox $sandbox) { $sandbox->pest(['--tia']); - $graphPath = $sandbox->path().'/vendor/pestphp/pest/.temp/tia/graph.json'; + $graphPath = $sandbox->path().'/.pest/tia/graph.json'; $graph = json_decode((string) file_get_contents($graphPath), true); $edgeCountBefore = count($graph['edges']); diff --git a/tests-tia/Support/Sandbox.php b/tests-tia/Support/Sandbox.php index 4c3361a21..33843f985 100644 --- a/tests-tia/Support/Sandbox.php +++ b/tests-tia/Support/Sandbox.php @@ -101,6 +101,13 @@ public function pest(array $flags = []): Process 'GITHUB_ACTIONS' => '', 'GITLAB_CI' => '', 'CIRCLECI' => '', + // Force TIA's Storage to fall back to the sandbox-local + // `.pest/tia/` layout. Without this, every sandbox run + // would dump state into the developer's real home dir + // (`~/.pest/tia/`), polluting it and making tests + // non-hermetic. + 'HOME' => '', + 'USERPROFILE' => '', ], ); $process->setTimeout(120.0); @@ -114,7 +121,7 @@ public function pest(array $flags = []): Process */ public function graph(): ?array { - $path = $this->path.'/vendor/pestphp/pest/.temp/tia/graph.json'; + $path = $this->path.'/.pest/tia/graph.json'; if (! is_file($path)) { return null; From d9c18f9c02183f3e2da83e98a663115378f64096 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Wed, 22 Apr 2026 09:03:10 -0700 Subject: [PATCH 033/160] wip --- src/Concerns/Testable.php | 70 ++++++++++++++++++++----------------- src/Plugins/Tia.php | 33 ++++++++++++++--- src/Support/XdebugGuard.php | 4 +-- tests-tia/RebuildTest.php | 6 ++-- 4 files changed, 72 insertions(+), 41 deletions(-) diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 6d3047657..1e35cc2a3 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -238,6 +238,44 @@ protected function setUp(...$arguments): void $this->__cachedPass = false; + $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); + + $description = $method->description; + if ($this->dataName()) { + $description = str_contains((string) $description, ':dataset') + ? str_replace(':dataset', str_replace('dataset ', '', $this->dataName()), (string) $description) + : $description.' with '.$this->dataName(); + } + + $description = htmlspecialchars(html_entity_decode((string) $description), ENT_NOQUOTES); + + if ($method->repetitions > 1) { + $matches = []; + preg_match('/\((.*?)\)/', $description, $matches); + + if (count($matches) > 1) { + if (str_contains($description, 'with '.$matches[0].' /')) { + $description = str_replace('with '.$matches[0].' /', '', $description); + } else { + $description = str_replace('with '.$matches[0], '', $description); + } + } + + $description .= ' @ repetition '.($matches[1].' of '.$method->repetitions); + } + + $this->__description = self::$__latestDescription = $description; + self::$__latestAssignees = $method->assignees; + self::$__latestNotes = $method->notes; + self::$__latestIssues = $method->issues; + self::$__latestPrs = $method->prs; + + // TIA replay short-circuit. Runs AFTER dataset/description/ + // assignee metadata is populated so output and filtering still + // see the correct test name + tags on a cache hit, but BEFORE + // `parent::setUp()` and `beforeEach` so we skip the user's + // fixture setup (which is the whole point of replay — avoid + // paying for work whose outcome we already know). /** @var Tia $tia */ $tia = Container::getInstance()->get(Tia::class); $cached = $tia->getCachedResult(self::$__filename, $this::class.'::'.$this->name()); @@ -275,38 +313,6 @@ protected function setUp(...$arguments): void throw new AssertionFailedError($cached->message() ?: 'Cached failure'); } - $method = TestSuite::getInstance()->tests->get(self::$__filename)->getMethod($this->name()); - - $description = $method->description; - if ($this->dataName()) { - $description = str_contains((string) $description, ':dataset') - ? str_replace(':dataset', str_replace('dataset ', '', $this->dataName()), (string) $description) - : $description.' with '.$this->dataName(); - } - - $description = htmlspecialchars(html_entity_decode((string) $description), ENT_NOQUOTES); - - if ($method->repetitions > 1) { - $matches = []; - preg_match('/\((.*?)\)/', $description, $matches); - - if (count($matches) > 1) { - if (str_contains($description, 'with '.$matches[0].' /')) { - $description = str_replace('with '.$matches[0].' /', '', $description); - } else { - $description = str_replace('with '.$matches[0], '', $description); - } - } - - $description .= ' @ repetition '.($matches[1].' of '.$method->repetitions); - } - - $this->__description = self::$__latestDescription = $description; - self::$__latestAssignees = $method->assignees; - self::$__latestNotes = $method->notes; - self::$__latestIssues = $method->issues; - self::$__latestPrs = $method->prs; - parent::setUp(); $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index df3093b5f..467a7116c 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -72,7 +72,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private const string OPTION = '--tia'; - private const string REBUILD_OPTION = '--tia-rebuild'; + /** + * Discards any existing graph and re-records from scratch. Meant to + * be combined with `--tia`; the flag is shared with the rest of Pest + * (no `tia-` prefix) so a single `--tia --fresh` reads naturally as + * "TIA, fresh start". + */ + private const string FRESH_OPTION = '--fresh'; /** * Bypasses `BaselineSync`'s post-failure cooldown. After a failed @@ -331,15 +337,22 @@ public function handleArguments(array $arguments): array $replayingGlobal = $isWorker && (string) Parallel::getGlobal(self::REPLAYING_GLOBAL) === '1'; $enabled = $this->hasArgument(self::OPTION, $arguments); - $forceRebuild = $this->hasArgument(self::REBUILD_OPTION, $arguments); + $freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments); $this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments); - if (! $enabled && ! $forceRebuild && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) { + // `--fresh` only takes effect alongside `--tia` (or from a + // worker that's already in TIA mode). Without `--tia`, Pest + // users could be passing `--fresh` to an unrelated plugin — + // silently ignore it here and let whatever else consumes it + // handle it. The flag isn't popped in that branch. + $forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal); + + if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) { return $arguments; } $arguments = $this->popArgument(self::OPTION, $arguments); - $arguments = $this->popArgument(self::REBUILD_OPTION, $arguments); + $arguments = $this->popArgument(self::FRESH_OPTION, $arguments); $arguments = $this->popArgument(self::REFETCH_OPTION, $arguments); // When `--coverage` is active, piggyback on PHPUnit's CodeCoverage @@ -1141,6 +1154,18 @@ private function readPartial(string $key): ?array private function registerRecap(): void { DefaultPrinter::addRecap(function (): string { + // Parallel mode: worker replays live in other processes and + // flushed their counters to disk on terminate. Collision's + // `writeRecap` fires inside `ExecutionFinished`, which is + // strictly before `addOutput` — so we must merge right here + // or the fragment below would read 0 and the suffix would + // silently disappear on `--tia --parallel`. The merge is + // idempotent: partial keys are deleted on read, so the + // later `addOutput` call becomes a no-op. + if (Parallel::isEnabled() && ! Parallel::isWorker()) { + $this->mergeWorkerReplayPartials(); + } + $fragments = []; if ($this->executedCount > 0) { diff --git a/src/Support/XdebugGuard.php b/src/Support/XdebugGuard.php index 58cf628a6..de1c15daf 100644 --- a/src/Support/XdebugGuard.php +++ b/src/Support/XdebugGuard.php @@ -21,7 +21,7 @@ * * The guard engages only when ALL of these hold: * 1. `--tia` is present in argv. - * 2. No `--tia-rebuild` flag (forced record always drives the coverage + * 2. No `--fresh` flag (forced record always drives the coverage * driver; dropping Xdebug would break the recording). * 3. No `--coverage*` flag (coverage runs need the driver regardless). * 4. A valid graph already exists on disk AND its structural fingerprint @@ -117,7 +117,7 @@ private static function runLooksDroppable(array $argv, string $projectRoot): boo return false; } - if ($value === '--tia-rebuild') { + if ($value === '--fresh') { return false; } diff --git a/tests-tia/RebuildTest.php b/tests-tia/RebuildTest.php index c9d4ecc89..17d7f7099 100644 --- a/tests-tia/RebuildTest.php +++ b/tests-tia/RebuildTest.php @@ -5,18 +5,18 @@ use Pest\TestsTia\Support\Sandbox; /* - * `--tia-rebuild` short-circuits whatever graph is on disk and records + * `--tia --fresh` short-circuits whatever graph is on disk and records * from scratch. Used when the user knows the cache is wrong. */ -test('--tia-rebuild forces record mode even with a valid graph', function () { +test('--tia --fresh forces record mode even with a valid graph', function () { tiaScenario(function (Sandbox $sandbox) { $sandbox->pest(['--tia']); expect($sandbox->hasGraph())->toBeTrue(); $graphBefore = $sandbox->graph(); - $process = $sandbox->pest(['--tia', '--tia-rebuild']); + $process = $sandbox->pest(['--tia', '--fresh']); expect($process->isSuccessful())->toBeTrue(tiaOutput($process)); expect(tiaOutput($process))->toContain('recording dependency graph'); From e876dba8bad1a2af9b96027a24ca713ccbde6ad1 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 23 Apr 2026 09:29:56 -0700 Subject: [PATCH 034/160] wip --- src/Concerns/Testable.php | 2 +- src/Plugins/Tia/ChangedFiles.php | 84 +++++++++++++++++++++- src/Plugins/Tia/ContentHash.php | 118 +++++++++++++++++++++++++++++++ src/Plugins/Tia/Fingerprint.php | 7 +- 4 files changed, 206 insertions(+), 5 deletions(-) create mode 100644 src/Plugins/Tia/ContentHash.php diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 1e35cc2a3..3f8e2dc97 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -395,7 +395,7 @@ private function __runTest(Closure $closure, ...$args): mixed $tia = Container::getInstance()->get(Tia::class); $assertions = $tia->getCachedAssertions($this::class.'::'.$this->name()); - $this->addToAssertionCount($assertions > 0 ? $assertions : 1); + $this->addToAssertionCount($assertions); return null; } diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 171b007a6..2b511a390 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -85,7 +85,7 @@ public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): a continue; } - $hash = @hash_file('xxh128', $absolute); + $hash = ContentHash::of($absolute); if ($hash === false || $hash !== $snapshot) { $remaining[] = $file; @@ -119,7 +119,7 @@ public function snapshotTree(array $files): array continue; } - $hash = @hash_file('xxh128', $absolute); + $hash = ContentHash::of($absolute); if ($hash !== false) { $out[$file] = $hash; @@ -167,7 +167,85 @@ public function since(?string $sha): ?array $unique[$file] = true; } - return array_keys($unique); + $candidates = array_keys($unique); + + // Behavioural de-noising: for every file git calls "changed", hash + // the current content and the content at `$sha` through + // `ContentHash::of()`. A change that only touched comments / + // whitespace / blade `{{-- --}}` blocks produces the same hash on + // both sides and gets dropped before it can invalidate any test. + // Without this, a single-comment edit on a migration re-runs the + // entire DB-touching suite. + if ($sha !== null && $sha !== '') { + return $this->filterBehaviourallyUnchanged($candidates, $sha); + } + + return $candidates; + } + + /** + * @param array $files + * @return array + */ + private function filterBehaviourallyUnchanged(array $files, string $sha): array + { + $remaining = []; + + foreach ($files as $file) { + $absolute = $this->projectRoot.DIRECTORY_SEPARATOR.$file; + + if (! is_file($absolute)) { + // Deleted on disk — a genuine change, keep it. + $remaining[] = $file; + + continue; + } + + $currentHash = ContentHash::of($absolute); + + if ($currentHash === false) { + $remaining[] = $file; + + continue; + } + + $baselineContent = $this->contentAtSha($sha, $file); + + if ($baselineContent === null) { + // Couldn't read the baseline (new file, binary, `git show` + // failed). Err on the side of re-running. + $remaining[] = $file; + + continue; + } + + $baselineHash = ContentHash::ofContent($file, $baselineContent); + + if ($currentHash !== $baselineHash) { + $remaining[] = $file; + } + } + + return $remaining; + } + + /** + * Reads `$path` at `$sha` via `git show`. Returns null when the file + * didn't exist at that SHA, when git errors, or when the content + * isn't valid UTF-8-safe bytes (rare — binary files that happen to + * be tracked). + */ + private function contentAtSha(string $sha, string $path): ?string + { + $process = new Process(['git', 'show', $sha.':'.$path], $this->projectRoot); + $process->setTimeout(5.0); + $process->run(); + + if (! $process->isSuccessful()) { + return null; + } + + return $process->getOutput(); } private function shouldIgnore(string $path): bool diff --git a/src/Plugins/Tia/ContentHash.php b/src/Plugins/Tia/ContentHash.php new file mode 100644 index 000000000..3c56b6cda --- /dev/null +++ b/src/Plugins/Tia/ContentHash.php @@ -0,0 +1,118 @@ +:`) and + * want to avoid a disk round-trip. + */ + public static function ofContent(string $path, string $raw): string + { + $lower = strtolower($path); + + if (str_ends_with($lower, '.blade.php')) { + return self::hashBladeContent($raw); + } + + if (str_ends_with($lower, '.php')) { + return self::hashPhpContent($raw); + } + + return hash('xxh128', $raw); + } + + /** + * Tokenise the content and hash the concatenated values of every + * token except whitespace / comment / docblock. `token_get_all()` + * is built-in, fast, and enough to collapse any formatting-only + * edit. If tokenisation fails (rare syntax error), fall back to + * the raw hash so the caller still gets a deterministic signal. + */ + private static function hashPhpContent(string $raw): string + { + $tokens = @token_get_all($raw); + + if ($tokens === []) { + return hash('xxh128', $raw); + } + + $normalised = ''; + + foreach ($tokens as $token) { + if (is_array($token)) { + if ($token[0] === T_WHITESPACE) { + continue; + } + if ($token[0] === T_COMMENT) { + continue; + } + if ($token[0] === T_DOC_COMMENT) { + continue; + } + $normalised .= $token[1]; + } else { + $normalised .= $token; + } + } + + return hash('xxh128', $normalised); + } + + /** + * Blade templates aren't PHP syntactically, so `token_get_all()` + * doesn't help. Strip `{{-- … --}}` comments (the only Blade-native + * comment form) and collapse whitespace runs. Output differences + * that would survive the Blade compiler (markup reordering, new + * directives, changed interpolation) still flip the hash; pure + * reformatting does not. + */ + private static function hashBladeContent(string $raw): string + { + $stripped = preg_replace('/\{\{--.*?--\}\}/s', '', $raw) ?? $raw; + $stripped = preg_replace('/\s+/', ' ', $stripped) ?? $stripped; + + return hash('xxh128', trim($stripped)); + } +} diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index 076273a32..fd6dbec26 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -29,7 +29,12 @@ { // Bump this whenever the set of inputs or the hash algorithm changes, // so older graphs are invalidated automatically. - private const int SCHEMA_VERSION = 4; + // + // v5: ChangedFiles now hashes via `ContentHash` (normalises PHP + // tokens + Blade whitespace/comments) instead of raw bytes. + // Old graphs' run-tree hashes are incompatible and must be + // rebuilt. + private const int SCHEMA_VERSION = 5; /** * @return array{ From c1feefbb9e1da7a5f1836df0fd87f88a1bfd0cf3 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 23 Apr 2026 09:44:12 -0700 Subject: [PATCH 035/160] wip --- src/Concerns/Testable.php | 12 +++++ src/Plugins/Tia/BladeEdges.php | 92 ++++++++++++++++++++++++++++++++++ src/Plugins/Tia/Graph.php | 18 ++++++- src/Plugins/Tia/Recorder.php | 26 ++++++++++ 4 files changed, 146 insertions(+), 2 deletions(-) create mode 100644 src/Plugins/Tia/BladeEdges.php diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 3f8e2dc97..dcc111b21 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -8,6 +8,8 @@ use Pest\Exceptions\DatasetArgumentsMismatch; use Pest\Panic; use Pest\Plugins\Tia; +use Pest\Plugins\Tia\BladeEdges; +use Pest\Plugins\Tia\Recorder; use Pest\Preset; use Pest\Support\ChainableClosure; use Pest\Support\Container; @@ -315,6 +317,16 @@ protected function setUp(...$arguments): void parent::setUp(); + // TIA blade-edge recording (Laravel-only). Runs right after + // `parent::setUp()` so the Laravel app exists and the View + // facade is bound; idempotent against the current app instance + // so the 774-test suite doesn't stack 774 composers when Laravel + // keeps the same app across tests. + $recorder = Container::getInstance()->get(Recorder::class); + if ($recorder instanceof Recorder) { + BladeEdges::arm($recorder); + } + $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; if ($this->__beforeEach instanceof Closure) { diff --git a/src/Plugins/Tia/BladeEdges.php b/src/Plugins/Tia/BladeEdges.php new file mode 100644 index 000000000..8a7465b50 --- /dev/null +++ b/src/Plugins/Tia/BladeEdges.php @@ -0,0 +1,92 @@ +.php`, not the `.blade.php` + * source. Without a dedicated hook TIA has no edges for blade files, + * so it leans on the Laravel WatchDefault's broad "any .blade.php + * change → every feature test" fallback. Safe but noisy — editing a + * single partial re-runs the whole suite. + * + * With this armed at record time, each test's edge set grows to + * include the precise `.blade.php` files it rendered (directly or + * through `@include`, layouts, components, Livewire, Inertia root + * views — anything that goes through Laravel's view factory fires + * `View::composer('*')`). Replay then invalidates exactly the tests + * that rendered the changed template. + * + * Implementation note: everything Laravel-touching goes through + * string class names, `class_exists`, and `method_exists` so Pest + * core doesn't pull `illuminate/container` into its `require`. + * + * @internal + */ +final class BladeEdges +{ + private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container'; + + /** + * App-scoped marker that makes `arm()` idempotent. Tests call it + * from every `setUp()`, and Laravel reuses the same app instance + * across tests in most configurations — without this guard we'd + * stack one composer per test and replay every one of them on + * every view render. + */ + private const string MARKER = 'pest.tia.blade-edges-armed'; + + public static function arm(Recorder $recorder): void + { + if (! $recorder->isActive()) { + return; + } + + $containerClass = self::CONTAINER_CLASS; + + if (! class_exists($containerClass)) { + return; + } + + /** @var object $app */ + $app = $containerClass::getInstance(); + + if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) { + return; + } + + if ($app->bound(self::MARKER)) { + return; + } + + if (! $app->bound('view')) { + return; + } + + $app->instance(self::MARKER, true); + + $factory = $app->make('view'); + + if (! is_object($factory) || ! method_exists($factory, 'composer')) { + return; + } + + $factory->composer('*', static function (object $view) use ($recorder): void { + if (! method_exists($view, 'getPath')) { + return; + } + + /** @var mixed $path */ + $path = $view->getPath(); + + if (is_string($path) && $path !== '') { + $recorder->linkSource($path); + } + }); + } +} diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index a8bc912f3..887b45823 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -153,11 +153,25 @@ public function affected(array $changedFiles): array } } - // 2. Watch-pattern lookup (non-PHP assets → test directories). + // 2. Watch-pattern lookup — fallback for files we don't have + // precise edges for. When a file is already in `$fileIds` step + // 1 resolved it surgically; broadcasting it again through the + // watch pattern would re-add every test the pattern maps to, + // defeating the point of recording the edge in the first place. + // Blade templates captured via Laravel's view composer are the + // motivating case — we want their specific tests, not every + // feature test. + $unknownToGraph = []; + foreach ($normalised as $rel) { + if (! isset($this->fileIds[$rel])) { + $unknownToGraph[] = $rel; + } + } + /** @var WatchPatterns $watchPatterns */ $watchPatterns = Container::getInstance()->get(WatchPatterns::class); - $dirs = $watchPatterns->matchedDirectories($this->projectRoot, $normalised); + $dirs = $watchPatterns->matchedDirectories($this->projectRoot, $unknownToGraph); $allTestFiles = array_keys($this->edges); foreach ($watchPatterns->testsUnderDirectories($dirs, $allTestFiles) as $testFile) { diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index 635b9088c..6f87f6829 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -144,6 +144,32 @@ public function endTest(): void $this->currentTestFile = null; } + /** + * Records an extra source-file dependency for the currently-running + * test. Used by collaborators that capture edges the coverage driver + * cannot see — Blade templates rendered through Laravel's view + * factory are the motivating case (their `.blade.php` source never + * executes directly; a cached compiled PHP file does). No-op when + * the recorder is inactive or no test is in flight, so callers can + * fire it unconditionally from app-level hooks. + */ + public function linkSource(string $sourceFile): void + { + if (! $this->active) { + return; + } + + if ($this->currentTestFile === null) { + return; + } + + if ($sourceFile === '') { + return; + } + + $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; + } + /** * @return array> absolute test file → list of absolute source files. */ From 470a5833d462c25bb1d53ddc207461bf419e8bb0 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 23 Apr 2026 10:30:44 -0700 Subject: [PATCH 036/160] wip --- src/Concerns/Testable.php | 11 +- src/Plugins/Tia.php | 73 ++++++++--- src/Plugins/Tia/Fingerprint.php | 8 +- src/Plugins/Tia/Graph.php | 196 +++++++++++++++++++++++++++-- src/Plugins/Tia/Recorder.php | 52 ++++++++ src/Plugins/Tia/TableExtractor.php | 154 +++++++++++++++++++++++ src/Plugins/Tia/TableTracker.php | 123 ++++++++++++++++++ 7 files changed, 589 insertions(+), 28 deletions(-) create mode 100644 src/Plugins/Tia/TableExtractor.php create mode 100644 src/Plugins/Tia/TableTracker.php diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index dcc111b21..64df36580 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -10,6 +10,7 @@ use Pest\Plugins\Tia; use Pest\Plugins\Tia\BladeEdges; use Pest\Plugins\Tia\Recorder; +use Pest\Plugins\Tia\TableTracker; use Pest\Preset; use Pest\Support\ChainableClosure; use Pest\Support\Container; @@ -317,14 +318,16 @@ protected function setUp(...$arguments): void parent::setUp(); - // TIA blade-edge recording (Laravel-only). Runs right after - // `parent::setUp()` so the Laravel app exists and the View - // facade is bound; idempotent against the current app instance - // so the 774-test suite doesn't stack 774 composers when Laravel + // TIA blade-edge + table-edge recording (Laravel-only). Runs + // right after `parent::setUp()` so the Laravel app exists and + // the View / DB facades are bound; each arm call is + // idempotent against the current app instance so the 774-test + // suite doesn't stack 774 composers / listeners when Laravel // keeps the same app across tests. $recorder = Container::getInstance()->get(Recorder::class); if ($recorder instanceof Recorder) { BladeEdges::arm($recorder); + TableTracker::arm($recorder); } $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 467a7116c..9a7af9318 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -412,8 +412,10 @@ public function terminate(): void return; } + $perTestTables = $recorder->perTestTables(); + if (Parallel::isWorker()) { - $this->flushWorkerPartial($perTest); + $this->flushWorkerPartial($perTest, $perTestTables); $recorder->reset(); $this->coverageCollector->reset(); @@ -436,6 +438,7 @@ public function terminate(): void $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), ); $graph->replaceEdges($perTest); + $graph->replaceTestTables($perTestTables); $graph->pruneMissingTests(); // Fold in the results collected during this same record run. The @@ -522,7 +525,8 @@ public function addOutput(int $exitCode): int $changedFiles->snapshotTree($changedFiles->since($currentSha) ?? []), ); - $merged = []; + $mergedFiles = []; + $mergedTables = []; foreach ($partialKeys as $key) { $data = $this->readPartial($key); @@ -531,13 +535,23 @@ public function addOutput(int $exitCode): int continue; } - foreach ($data as $testFile => $sources) { - if (! isset($merged[$testFile])) { - $merged[$testFile] = []; + foreach ($data['files'] as $testFile => $sources) { + if (! isset($mergedFiles[$testFile])) { + $mergedFiles[$testFile] = []; } foreach ($sources as $source) { - $merged[$testFile][$source] = true; + $mergedFiles[$testFile][$source] = true; + } + } + + foreach ($data['tables'] as $testFile => $tables) { + if (! isset($mergedTables[$testFile])) { + $mergedTables[$testFile] = []; + } + + foreach ($tables as $table) { + $mergedTables[$testFile][$table] = true; } } @@ -546,10 +560,16 @@ public function addOutput(int $exitCode): int $finalised = []; - foreach ($merged as $testFile => $sourceSet) { + foreach ($mergedFiles as $testFile => $sourceSet) { $finalised[$testFile] = array_keys($sourceSet); } + $finalisedTables = []; + + foreach ($mergedTables as $testFile => $tableSet) { + $finalisedTables[$testFile] = array_keys($tableSet); + } + // Empty-edges guard: if every worker returned no edges it almost // always means the coverage driver wasn't loaded in the workers // (common footgun with custom PHP ini scan dirs, Herd profiles, @@ -567,6 +587,7 @@ public function addOutput(int $exitCode): int } $graph->replaceEdges($finalised); + $graph->replaceTestTables($finalisedTables); $graph->pruneMissingTests(); if (! $this->saveGraph($graph)) { @@ -949,11 +970,15 @@ private function emitCoverageDriverMissing(): void } /** - * @param array> $perTest + * @param array> $perTestFiles + * @param array> $perTestTables */ - private function flushWorkerPartial(array $perTest): void + private function flushWorkerPartial(array $perTestFiles, array $perTestTables): void { - $json = json_encode($perTest, JSON_UNESCAPED_SLASHES); + $json = json_encode([ + 'files' => $perTestFiles, + 'tables' => $perTestTables, + ], JSON_UNESCAPED_SLASHES); if ($json === false) { return; @@ -1090,7 +1115,7 @@ private function workerToken(): string } /** - * @return array>|null + * @return array{files: array>, tables: array>}|null */ private function readPartial(string $key): ?array { @@ -1106,20 +1131,36 @@ private function readPartial(string $key): ?array return null; } + $filesSource = is_array($data['files'] ?? null) ? $data['files'] : []; + $tablesSource = is_array($data['tables'] ?? null) ? $data['tables'] : []; + + return [ + 'files' => $this->cleanPartialSection($filesSource), + 'tables' => $this->cleanPartialSection($tablesSource), + ]; + } + + /** + * @param array $section + * @return array> + */ + private function cleanPartialSection(array $section): array + { $out = []; - foreach ($data as $test => $sources) { + foreach ($section as $test => $items) { if (! is_string($test)) { continue; } - if (! is_array($sources)) { + if (! is_array($items)) { continue; } + $clean = []; - foreach ($sources as $source) { - if (is_string($source)) { - $clean[] = $source; + foreach ($items as $item) { + if (is_string($item)) { + $clean[] = $item; } } diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index fd6dbec26..a570b26a7 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -34,7 +34,13 @@ // tokens + Blade whitespace/comments) instead of raw bytes. // Old graphs' run-tree hashes are incompatible and must be // rebuilt. - private const int SCHEMA_VERSION = 5; + // v6: Graph gained per-test table edges (`$testTables`) powering + // surgical migration invalidation. Worker partial shape + // changed to `{files, tables}`. Old graphs have no table + // coverage, which would leave every DB test invalidated by + // any migration change — force a rebuild so the new edges + // are populated. + private const int SCHEMA_VERSION = 6; /** * @return array{ diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 887b45823..350be7c52 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -40,6 +40,24 @@ final class Graph */ private array $edges = []; + /** + * Table edges: test file (relative) → list of lowercase SQL table + * names the test queried during record. Populated from the + * Recorder's `perTestTables()` snapshot; consumed at replay time + * to do surgical invalidation when a migration changes — the + * test only re-runs if its set intersects the tables the changed + * migration touches. Empty for tests that never hit the DB, which + * is exactly why those tests stay unaffected by migration edits. + * + * Unlike `$edges`, we store names rather than ids: the table + * universe is small (hundreds at most on a giant app), storing + * strings keeps the on-disk graph diff-readable, and the lookup + * cost is negligible compared to the per-file ids used above. + * + * @var array> + */ + private array $testTables = []; + /** * Environment fingerprint captured at record time. * @@ -126,11 +144,68 @@ public function affected(array $changedFiles): array } } - // 1. Coverage-edge lookup (PHP → PHP). + $affectedSet = []; + + // Migration changes don't flow through the coverage-edge path — + // `RefreshDatabase` in every test's `setUp()` means every test + // has an edge to every migration, so step 1 would re-run the + // whole DB-touching suite on any migration edit. Route them + // separately: static-parse the migration source, union the + // referenced tables, and match tests whose recorded query + // footprint intersects that set. Missed files (rare: migrations + // with pure raw SQL or dynamic names) fall back to the watch + // pattern below. + $migrationPaths = []; + $nonMigrationPaths = []; + + foreach ($normalised as $rel) { + if ($this->isMigrationPath($rel)) { + $migrationPaths[] = $rel; + } else { + $nonMigrationPaths[] = $rel; + } + } + + $changedTables = []; + $unparseableMigrations = []; + + foreach ($migrationPaths as $rel) { + $tables = $this->tablesForMigration($rel); + + if ($tables === []) { + $unparseableMigrations[] = $rel; + + continue; + } + + foreach ($tables as $table) { + $changedTables[$table] = true; + } + } + + if ($changedTables !== []) { + foreach ($this->testTables as $testFile => $tables) { + if (isset($affectedSet[$testFile])) { + continue; + } + + foreach ($tables as $table) { + if (isset($changedTables[$table])) { + $affectedSet[$testFile] = true; + + break; + } + } + } + } + + // 1. Coverage-edge lookup (PHP → PHP). Migrations are already + // handled above; skipping them here prevents their always-on + // coverage edges from invalidating the whole DB suite. $changedIds = []; $unknownSourceDirs = []; - foreach ($normalised as $rel) { + foreach ($nonMigrationPaths as $rel) { if (isset($this->fileIds[$rel])) { $changedIds[$this->fileIds[$rel]] = true; } elseif (str_ends_with($rel, '.php') && ! str_starts_with($rel, 'tests/')) { @@ -141,9 +216,11 @@ public function affected(array $changedFiles): array } } - $affectedSet = []; - foreach ($this->edges as $testFile => $ids) { + if (isset($affectedSet[$testFile])) { + continue; + } + foreach ($ids as $id) { if (isset($changedIds[$id])) { $affectedSet[$testFile] = true; @@ -160,9 +237,12 @@ public function affected(array $changedFiles): array // defeating the point of recording the edge in the first place. // Blade templates captured via Laravel's view composer are the // motivating case — we want their specific tests, not every - // feature test. - $unknownToGraph = []; - foreach ($normalised as $rel) { + // feature test. Migrations whose static parse yielded nothing + // (exotic syntax, raw SQL) are funneled back in here too so + // broad invalidation still kicks in for edge cases we can't + // parse. + $unknownToGraph = $unparseableMigrations; + foreach ($nonMigrationPaths as $rel) { if (! isset($this->fileIds[$rel])) { $unknownToGraph[] = $rel; } @@ -406,6 +486,79 @@ public function replaceEdges(array $testToFiles): void } } + /** + * Replaces table edges for the given test files. Table names are + * lowercased + deduplicated; the input comes straight from the + * Recorder's `perTestTables()` snapshot. Tests absent from the + * input keep their existing table set (same partial-update policy + * as `replaceEdges`). + * + * @param array> $testToTables + */ + public function replaceTestTables(array $testToTables): void + { + foreach ($testToTables as $testFile => $tables) { + $testRel = $this->relative($testFile); + + if ($testRel === null) { + continue; + } + + $normalised = []; + + foreach ($tables as $table) { + $lower = strtolower($table); + + if ($lower !== '') { + $normalised[$lower] = true; + } + } + + $names = array_keys($normalised); + sort($names); + + $this->testTables[$testRel] = $names; + } + } + + /** + * Projects under Laravel conventionally keep migrations at + * `database/migrations/`. We recognise the directory as a prefix + * so nested subdirectories (a pattern some teams use for grouping + * — `database/migrations/tenant/`, `database/migrations/archived/`) + * are still routed through the table-intersection path. + */ + private function isMigrationPath(string $rel): bool + { + return str_starts_with($rel, 'database/migrations/') && str_ends_with($rel, '.php'); + } + + /** + * Reads `$rel` relative to the project root and extracts the + * tables it declares via `Schema::create/table/drop/rename`. + * Empty on missing/unreadable files or when the parser finds + * nothing — the caller escalates those cases to the watch + * pattern safety net. + * + * @return list + */ + private function tablesForMigration(string $rel): array + { + $absolute = rtrim($this->projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.$rel; + + if (! is_file($absolute)) { + return []; + } + + $content = @file_get_contents($absolute); + + if ($content === false) { + return []; + } + + return TableExtractor::fromMigrationSource($content); + } + /** * Drops edges whose test file no longer exists on disk. Prevents the graph * from keeping stale entries for deleted / renamed tests that would later @@ -420,6 +573,12 @@ public function pruneMissingTests(): void unset($this->edges[$testRel]); } } + + foreach (array_keys($this->testTables) as $testRel) { + if (! is_file($root.$testRel)) { + unset($this->testTables[$testRel]); + } + } } /** @@ -443,6 +602,28 @@ public static function decode(string $json, string $projectRoot): ?self $graph->edges = is_array($data['edges'] ?? null) ? $data['edges'] : []; $graph->baselines = is_array($data['baselines'] ?? null) ? $data['baselines'] : []; + if (isset($data['test_tables']) && is_array($data['test_tables'])) { + foreach ($data['test_tables'] as $testRel => $tables) { + if (! is_string($testRel)) { + continue; + } + if (! is_array($tables)) { + continue; + } + $names = []; + + foreach ($tables as $table) { + if (is_string($table) && $table !== '') { + $names[] = $table; + } + } + + if ($names !== []) { + $graph->testTables[$testRel] = $names; + } + } + } + return $graph; } @@ -460,6 +641,7 @@ public function encode(): ?string 'files' => $this->files, 'edges' => $this->edges, 'baselines' => $this->baselines, + 'test_tables' => $this->testTables, ]; $json = json_encode($payload, JSON_UNESCAPED_SLASHES); diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index 6f87f6829..dd4d60c8c 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -29,6 +29,16 @@ final class Recorder */ private array $perTestFiles = []; + /** + * Aggregated map: absolute test file → set. + * Populated by `TableTracker` from `DB::listen` callbacks; consumed + * at record finalize to populate the graph's `$testTables` edges + * that drive migration-change impact analysis. + * + * @var array> + */ + private array $perTestTables = []; + /** * Cached class → test file resolution. * @@ -170,6 +180,31 @@ public function linkSource(string $sourceFile): void $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; } + /** + * Records that the currently-running test queried `$table`. Called + * by `TableTracker` for every DML statement Laravel's `DB::listen` + * reports; the table name has already been extracted by + * `TableExtractor::fromSql()` so we just store it. No-op outside + * a test window, so the callback is safe to leave armed across + * setUp / tearDown boundaries. + */ + public function linkTable(string $table): void + { + if (! $this->active) { + return; + } + + if ($this->currentTestFile === null) { + return; + } + + if ($table === '') { + return; + } + + $this->perTestTables[$this->currentTestFile][strtolower($table)] = true; + } + /** * @return array> absolute test file → list of absolute source files. */ @@ -184,6 +219,22 @@ public function perTestFiles(): array return $out; } + /** + * @return array> absolute test file → sorted list of table names. + */ + public function perTestTables(): array + { + $out = []; + + foreach ($this->perTestTables as $testFile => $tables) { + $names = array_keys($tables); + sort($names); + $out[$testFile] = $names; + } + + return $out; + } + private function resolveTestFile(string $className, string $fallbackFile): ?string { if (array_key_exists($className, $this->classFileCache)) { @@ -249,6 +300,7 @@ public function reset(): void { $this->currentTestFile = null; $this->perTestFiles = []; + $this->perTestTables = []; $this->classFileCache = []; $this->active = false; } diff --git a/src/Plugins/Tia/TableExtractor.php b/src/Plugins/Tia/TableExtractor.php new file mode 100644 index 000000000..94c3a94d4 --- /dev/null +++ b/src/Plugins/Tia/TableExtractor.php @@ -0,0 +1,154 @@ + Sorted, deduped table names referenced by the + * SQL statement. Empty when the statement is + * DDL, empty, or unparseable. + */ + public static function fromSql(string $sql): array + { + $trimmed = ltrim($sql); + + if ($trimmed === '') { + return []; + } + + $prefix = strtolower(substr($trimmed, 0, 6)); + + $matched = false; + foreach (self::DML_PREFIXES as $dml) { + if (str_starts_with($prefix, $dml)) { + $matched = true; + + break; + } + } + + if (! $matched) { + return []; + } + + // Match `from`, `into`, `update`, `join` and capture the + // following identifier, tolerating the common quoting + // styles: "double", `back`, [bracket], or bare. + $pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i'; + + if (preg_match_all($pattern, $sql, $matches) === false) { + return []; + } + + $tables = []; + + for ($i = 0, $n = count($matches[0]); $i < $n; $i++) { + $name = $matches[1][$i] !== '' + ? $matches[1][$i] + : ($matches[2][$i] !== '' + ? $matches[2][$i] + : ($matches[3][$i] !== '' + ? $matches[3][$i] + : $matches[4][$i])); + if ($name === '') { + continue; + } + if (self::isSchemaMeta($name)) { + continue; + } + + $tables[strtolower($name)] = true; + } + + $out = array_keys($tables); + sort($out); + + return $out; + } + + /** + * @return list Table names referenced by `Schema::` calls + * in the given migration file contents. Empty + * when nothing matches — callers treat that + * as "fall back to the broad watch pattern". + */ + public static function fromMigrationSource(string $php): array + { + $pattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/'; + + if (preg_match_all($pattern, $php, $matches) === false) { + return []; + } + + $tables = []; + + foreach ($matches[1] as $i => $primary) { + // Group 1 always captures at least one char per the regex. + $tables[strtolower($primary)] = true; + + // Group 2 (`Schema::rename('old', 'new')`) is optional and + // absent from non-rename matches. + $secondary = $matches[2][$i] ?? ''; + if ($secondary !== '') { + $tables[strtolower($secondary)] = true; + } + } + + $out = array_keys($tables); + sort($out); + + return $out; + } + + /** + * Filters out driver-internal tables that show up as DB::listen + * targets without representing user schema: SQLite's master + * catalogue, Laravel's own `migrations` metadata. + */ + private static function isSchemaMeta(string $name): bool + { + $lower = strtolower($name); + + return in_array($lower, ['sqlite_master', 'sqlite_sequence', 'migrations'], true) + || str_starts_with($lower, 'pg_') + || str_starts_with($lower, 'information_schema'); + } +} diff --git a/src/Plugins/Tia/TableTracker.php b/src/Plugins/Tia/TableTracker.php new file mode 100644 index 000000000..1c85902f1 --- /dev/null +++ b/src/Plugins/Tia/TableTracker.php @@ -0,0 +1,123 @@ +isActive()) { + return; + } + + $containerClass = self::CONTAINER_CLASS; + + if (! class_exists($containerClass)) { + return; + } + + /** @var object $app */ + $app = $containerClass::getInstance(); + + if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) { + return; + } + + if ($app->bound(self::MARKER)) { + return; + } + + if (! $app->bound('db')) { + return; + } + + $app->instance(self::MARKER, true); + + $listener = static function (object $query) use ($recorder): void { + if (! property_exists($query, 'sql')) { + return; + } + + /** @var mixed $sql */ + $sql = $query->sql; + + if (! is_string($sql) || $sql === '') { + return; + } + + foreach (TableExtractor::fromSql($sql) as $table) { + $recorder->linkTable($table); + } + }; + + // Preferred path: `DatabaseManager::listen(Closure $callback)`. + // It's a real method — `method_exists` returns false because + // some Laravel versions compose it via a trait the reflection + // probe can't always see, so we gate via `is_callable` instead. + // This path pushes the listener onto every existing AND future + // connection, which is what we want for a process-wide capture. + /** @var object $db */ + $db = $app->make('db'); + + if (is_callable([$db, 'listen'])) { + /** @var callable $listen */ + $listen = [$db, 'listen']; + $listen($listener); + + return; + } + + // Fallback: register directly on the event dispatcher. Works + // as long as every connection shares the same dispatcher + // instance this app resolved to — true in vanilla setups, + // but not guaranteed with connections instantiated pre-arm + // that captured an older dispatcher. + if (! $app->bound('events')) { + return; + } + + /** @var object $events */ + $events = $app->make('events'); + + if (! method_exists($events, 'listen')) { + return; + } + + $events->listen('\\Illuminate\\Database\\Events\\QueryExecuted', $listener); + } +} From caabebf2a1b7f950df3955c51692fed67f605795 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 23 Apr 2026 10:56:17 -0700 Subject: [PATCH 037/160] wip --- src/Plugins/Tia.php | 13 ++++++++++--- src/Plugins/Tia/ChangedFiles.php | 31 +++++++++++++++++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 9a7af9318..c6e2f60cf 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -833,14 +833,21 @@ private function enterReplayMode(Graph $graph, string $projectRoot, array $argum return $arguments; } - $changed = $changedFiles->since($graph->recordedAtSha($this->branch)) ?? []; + $branchSha = $graph->recordedAtSha($this->branch); + $changed = $changedFiles->since($branchSha) ?? []; // 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)); + // again. Passing the recorded sha also catches reverts: a file + // that was edited last run but is now back to its committed + // form no longer looks "changed". + $changed = $changedFiles->filterUnchangedSinceLastRun( + $changed, + $graph->lastRunTree($this->branch), + $branchSha, + ); $affected = $changed === [] ? [] : $graph->affected($changed); diff --git a/src/Plugins/Tia/ChangedFiles.php b/src/Plugins/Tia/ChangedFiles.php index 2b511a390..37aeac924 100644 --- a/src/Plugins/Tia/ChangedFiles.php +++ b/src/Plugins/Tia/ChangedFiles.php @@ -42,7 +42,7 @@ public function __construct(private string $projectRoot) {} * @param array $lastRunTree path → content hash from last run. * @return array */ - public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): array + public function filterUnchangedSinceLastRun(array $files, array $lastRunTree, ?string $sha = null): array { if ($lastRunTree === []) { return $files; @@ -87,9 +87,36 @@ public function filterUnchangedSinceLastRun(array $files, array $lastRunTree): a $hash = ContentHash::of($absolute); - if ($hash === false || $hash !== $snapshot) { + if ($hash === false) { $remaining[] = $file; + + continue; + } + + if ($hash === $snapshot) { + // Same state as the last TIA invocation — unchanged. + continue; } + + // Differs from the snapshot, but may still be a revert back + // to the committed version (scenario: last run had an edit, + // this run reverted it). Skipping this check causes stale + // snapshots from previous scenarios to cascade into the + // current run's invalidation set. Cheap to verify via + // `git show :`. + if ($sha !== null && $sha !== '') { + $baselineContent = $this->contentAtSha($sha, $file); + + if ($baselineContent !== null) { + $baselineHash = ContentHash::ofContent($file, $baselineContent); + + if ($hash === $baselineHash) { + continue; + } + } + } + + $remaining[] = $file; } return $remaining; From 3d3c5d41acc2d3139e23e5645e35f220772f0e70 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Thu, 23 Apr 2026 12:29:24 -0700 Subject: [PATCH 038/160] wip --- bin/pest-tia-vite-deps.mjs | 181 +++++++++++++ src/Concerns/Testable.php | 2 + src/Plugins/Tia.php | 33 ++- src/Plugins/Tia/Fingerprint.php | 14 +- src/Plugins/Tia/Graph.php | 302 ++++++++++++++++++++++ src/Plugins/Tia/InertiaEdges.php | 170 ++++++++++++ src/Plugins/Tia/JsImportParser.php | 270 +++++++++++++++++++ src/Plugins/Tia/JsModuleGraph.php | 142 ++++++++++ src/Plugins/Tia/Recorder.php | 54 ++++ src/Plugins/Tia/WatchDefaults/Inertia.php | 17 +- 10 files changed, 1176 insertions(+), 9 deletions(-) create mode 100644 bin/pest-tia-vite-deps.mjs create mode 100644 src/Plugins/Tia/InertiaEdges.php create mode 100644 src/Plugins/Tia/JsImportParser.php create mode 100644 src/Plugins/Tia/JsModuleGraph.php diff --git a/bin/pest-tia-vite-deps.mjs b/bin/pest-tia-vite-deps.mjs new file mode 100644 index 000000000..8f29feda0 --- /dev/null +++ b/bin/pest-tia-vite-deps.mjs @@ -0,0 +1,181 @@ +#!/usr/bin/env node + +/** + * TIA Vite dependency resolver. + * + * Spins up a throwaway headless Vite dev server using the project's + * `vite.config.*`, walks every `resources/js/Pages/**` entry to warm + * up the module graph, then serializes the graph as a reverse map: + * + * { "": ["", ...], ... } + * + * The resulting JSON is written to stdout. Stderr is silent on + * success so Pest can parse stdout without stripping. + * + * Why this exists: at TIA record time we need to know which Inertia + * page components depend on each shared source file (Button.vue, + * Layouts/*.vue, etc.) so a later edit to one of those files can + * invalidate only the tests that rendered an affected page. Vite + * already knows this via its module graph — we borrow it. + * + * Called from `Pest\Plugins\Tia\JsModuleGraph::build()` as: + * + * node bin/pest-tia-vite-deps.mjs + * + * Environment: + * TIA_VITE_PAGES_DIR override the `resources/js/Pages` default. + * TIA_VITE_TIMEOUT_MS override the 20s internal watchdog. + */ + +import { readdir } from 'node:fs/promises' +import { existsSync } from 'node:fs' +import { createRequire } from 'node:module' +import { resolve, relative, extname, posix, sep, join } from 'node:path' +import { pathToFileURL } from 'node:url' + +const PAGE_EXTENSIONS = new Set(['.vue', '.tsx', '.jsx', '.svelte']) +const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd()) +const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/') +const TIMEOUT_MS = Number.parseInt(process.env.TIA_VITE_TIMEOUT_MS ?? '20000', 10) + +// Resolve Vite from the project's own `node_modules`, not from this +// helper's location (which lives under `vendor/pestphp/pest/bin/` and +// has no `node_modules`). `createRequire` anchored at the project +// root walks up from there, matching the resolution behaviour any +// project-local script would see. +async function loadVite() { + const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json')) + const vitePath = projectRequire.resolve('vite') + return await import(pathToFileURL(vitePath).href) +} + +const { createServer } = await loadVite() + +async function listPageFiles(pagesDir) { + if (!existsSync(pagesDir)) return [] + + const out = [] + const walk = async (dir) => { + let entries + try { entries = await readdir(dir, { withFileTypes: true }) } catch { return } + for (const entry of entries) { + const full = resolve(dir, entry.name) + if (entry.isDirectory()) { await walk(full); continue } + if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full) + } + } + + await walk(pagesDir) + return out +} + +function componentNameFor(pageAbs, pagesDir) { + const rel = relative(pagesDir, pageAbs).split(sep).join('/') + const ext = extname(rel) + return rel.slice(0, rel.length - ext.length) +} + +async function main() { + const pagesDir = resolve(PROJECT_ROOT, PAGES_REL) + const pages = await listPageFiles(pagesDir) + + if (pages.length === 0) { + process.stdout.write('{}') + return + } + + // Boot Vite in middleware mode (no port binding, no HMR server). + // We only need the module graph; transformRequest per page warms + // it without running a bundle. + const server = await createServer({ + configFile: undefined, // auto-detect vite.config.* + root: PROJECT_ROOT, + logLevel: 'silent', + clearScreen: false, + server: { + middlewareMode: true, + hmr: false, + watch: null, + }, + appType: 'custom', + optimizeDeps: { disabled: true }, + }) + + // Watchdog — don't let a pathological config hang the record run. + const killer = setTimeout(() => { + server.close().catch(() => {}).finally(() => process.exit(2)) + }, TIMEOUT_MS) + + // Reverse map: depSourcePath → Set. + const reverse = new Map() + + const pageComponentCache = new Map() + for (const page of pages) { + pageComponentCache.set(page, componentNameFor(page, pagesDir)) + } + + try { + for (const pagePath of pages) { + const pageComponent = pageComponentCache.get(pagePath) + const pageUrl = '/' + posix.relative( + PROJECT_ROOT.split(sep).join('/'), + pagePath.split(sep).join('/'), + ) + + try { + await server.transformRequest(pageUrl, { ssr: false }) + } catch { + // Transform errors (missing deps, syntax issues) shouldn't + // poison the whole graph — skip this page and continue. + continue + } + + const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false) + if (!pageModule) continue + + // BFS over importedModules, scoped to files inside the project. + const visited = new Set() + const queue = [pageModule] + while (queue.length) { + const mod = queue.shift() + for (const imported of mod.importedModules) { + const id = imported.file ?? imported.id + if (!id || visited.has(id)) continue + visited.add(id) + + // Skip files outside the project root (node_modules, etc.) + // and virtual modules (`\0`-prefixed ids from plugins). + if (id.startsWith('\0')) continue + if (!id.startsWith(PROJECT_ROOT)) continue + + const rel = relative(PROJECT_ROOT, id).split(sep).join('/') + const bucket = reverse.get(rel) ?? new Set() + bucket.add(pageComponent) + reverse.set(rel, bucket) + + queue.push(imported) + } + } + } + } finally { + clearTimeout(killer) + await server.close() + } + + const payload = Object.create(null) + const keys = [...reverse.keys()].sort() + for (const key of keys) { + payload[key] = [...reverse.get(key)].sort() + } + + process.stdout.write(JSON.stringify(payload)) +} + +try { + // Node 20 dynamic-import path — some environments are pickier than others. + void pathToFileURL // retained to silence tree-shakers referencing the import + await main() +} catch (err) { + process.stderr.write(String(err?.stack ?? err ?? 'unknown error')) + process.exit(1) +} diff --git a/src/Concerns/Testable.php b/src/Concerns/Testable.php index 64df36580..5657c4d8a 100644 --- a/src/Concerns/Testable.php +++ b/src/Concerns/Testable.php @@ -9,6 +9,7 @@ use Pest\Panic; use Pest\Plugins\Tia; use Pest\Plugins\Tia\BladeEdges; +use Pest\Plugins\Tia\InertiaEdges; use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\TableTracker; use Pest\Preset; @@ -328,6 +329,7 @@ protected function setUp(...$arguments): void if ($recorder instanceof Recorder) { BladeEdges::arm($recorder); TableTracker::arm($recorder); + InertiaEdges::arm($recorder); } $beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1]; diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index c6e2f60cf..c293eb608 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -14,6 +14,7 @@ use Pest\Plugins\Tia\CoverageCollector; use Pest\Plugins\Tia\Fingerprint; use Pest\Plugins\Tia\Graph; +use Pest\Plugins\Tia\JsModuleGraph; use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\ResultCollector; use Pest\Plugins\Tia\WatchPatterns; @@ -413,9 +414,10 @@ public function terminate(): void } $perTestTables = $recorder->perTestTables(); + $perTestInertia = $recorder->perTestInertiaComponents(); if (Parallel::isWorker()) { - $this->flushWorkerPartial($perTest, $perTestTables); + $this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia); $recorder->reset(); $this->coverageCollector->reset(); @@ -439,6 +441,8 @@ public function terminate(): void ); $graph->replaceEdges($perTest); $graph->replaceTestTables($perTestTables); + $graph->replaceTestInertiaComponents($perTestInertia); + $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); $graph->pruneMissingTests(); // Fold in the results collected during this same record run. The @@ -527,6 +531,7 @@ public function addOutput(int $exitCode): int $mergedFiles = []; $mergedTables = []; + $mergedInertia = []; foreach ($partialKeys as $key) { $data = $this->readPartial($key); @@ -555,6 +560,16 @@ public function addOutput(int $exitCode): int } } + foreach ($data['inertia'] as $testFile => $components) { + if (! isset($mergedInertia[$testFile])) { + $mergedInertia[$testFile] = []; + } + + foreach ($components as $component) { + $mergedInertia[$testFile][$component] = true; + } + } + $this->state->delete($key); } @@ -570,6 +585,12 @@ public function addOutput(int $exitCode): int $finalisedTables[$testFile] = array_keys($tableSet); } + $finalisedInertia = []; + + foreach ($mergedInertia as $testFile => $componentSet) { + $finalisedInertia[$testFile] = array_keys($componentSet); + } + // Empty-edges guard: if every worker returned no edges it almost // always means the coverage driver wasn't loaded in the workers // (common footgun with custom PHP ini scan dirs, Herd profiles, @@ -588,6 +609,8 @@ public function addOutput(int $exitCode): int $graph->replaceEdges($finalised); $graph->replaceTestTables($finalisedTables); + $graph->replaceTestInertiaComponents($finalisedInertia); + $graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot)); $graph->pruneMissingTests(); if (! $this->saveGraph($graph)) { @@ -979,12 +1002,14 @@ private function emitCoverageDriverMissing(): void /** * @param array> $perTestFiles * @param array> $perTestTables + * @param array> $perTestInertiaComponents */ - private function flushWorkerPartial(array $perTestFiles, array $perTestTables): void + private function flushWorkerPartial(array $perTestFiles, array $perTestTables, array $perTestInertiaComponents): void { $json = json_encode([ 'files' => $perTestFiles, 'tables' => $perTestTables, + 'inertia' => $perTestInertiaComponents, ], JSON_UNESCAPED_SLASHES); if ($json === false) { @@ -1122,7 +1147,7 @@ private function workerToken(): string } /** - * @return array{files: array>, tables: array>}|null + * @return array{files: array>, tables: array>, inertia: array>}|null */ private function readPartial(string $key): ?array { @@ -1140,10 +1165,12 @@ private function readPartial(string $key): ?array $filesSource = is_array($data['files'] ?? null) ? $data['files'] : []; $tablesSource = is_array($data['tables'] ?? null) ? $data['tables'] : []; + $inertiaSource = is_array($data['inertia'] ?? null) ? $data['inertia'] : []; return [ 'files' => $this->cleanPartialSection($filesSource), 'tables' => $this->cleanPartialSection($tablesSource), + 'inertia' => $this->cleanPartialSection($inertiaSource), ]; } diff --git a/src/Plugins/Tia/Fingerprint.php b/src/Plugins/Tia/Fingerprint.php index a570b26a7..aa53eee12 100644 --- a/src/Plugins/Tia/Fingerprint.php +++ b/src/Plugins/Tia/Fingerprint.php @@ -40,7 +40,19 @@ // coverage, which would leave every DB test invalidated by // any migration change — force a rebuild so the new edges // are populated. - private const int SCHEMA_VERSION = 6; + // v7: Graph gained per-test Inertia page-component edges + // (`$testInertiaComponents`) for surgical page-file + // invalidation. Worker partial now includes an `inertia` + // section. Old graphs have no component edges; without a + // rebuild Vue/React page edits would fall through to the + // broad watch pattern even when precise matching could have + // worked. + // v8: Graph gained `$jsFileToComponents` — reverse dependency + // map computed at record time from Vite's module graph (or + // the PHP fallback) so shared components / layouts / + // composables invalidate the specific pages they're used + // by, not every browser test. + private const int SCHEMA_VERSION = 8; /** * @return array{ diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 350be7c52..a1c6f1e4d 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -58,6 +58,33 @@ final class Graph */ private array $testTables = []; + /** + * Inertia page component edges: test file (relative) → list of + * component names the test server-side rendered (whatever was + * passed to `Inertia::render($component, …)`). Populated from + * `Recorder::perTestInertiaComponents()`; consumed at replay time + * so an edit to `resources/js/Pages/Users/Show.vue` only invalidates + * tests that rendered `Users/Show`. Same string-keyed shape as + * `$testTables` for the same diff-readable reasons. + * + * @var array> + */ + private array $testInertiaComponents = []; + + /** + * Inverted JS dependency map: project-relative source path under + * `resources/js/**` → list of Inertia page components that + * transitively import it. Populated at record time by + * `JsModuleGraph::build()` (Vite module graph via Node helper, + * with a PHP fallback). Replay uses this to route a + * `Components/Button.vue` edit directly to the pages that depend + * on it, intersecting against `$testInertiaComponents` for + * surgical invalidation. + * + * @var array> + */ + private array $jsFileToComponents = []; + /** * Environment fingerprint captured at record time. * @@ -199,6 +226,93 @@ public function affected(array $changedFiles): array } } + // Inertia page-component routing. When a Vue/React/Svelte page + // under `resources/js/Pages/` changes, map it to the component + // name Inertia would use (the path relative to `Pages/`, with + // the extension stripped) and intersect with the captured + // component edges. Only invalidates tests that actually + // rendered the page. Pages with no captured edges (never + // rendered during record, brand-new on this branch) fall + // through to the watch-pattern fallback via + // `$unknownPageComponents` — safe over-run. + $changedComponents = []; + $unknownPageComponents = []; + + foreach ($nonMigrationPaths as $rel) { + $component = $this->componentForInertiaPage($rel); + + if ($component === null) { + continue; + } + + if ($this->anyTestUses($this->testInertiaComponents, $component)) { + $changedComponents[$component] = true; + } else { + $unknownPageComponents[] = $rel; + } + } + + // Pages whose component already resolved precisely via the + // direct Inertia edges path must not leak back through any + // broader mechanism (either the JS-dep lookup below, or the + // watch pattern further down). + $preciselyHandledPages = []; + foreach ($nonMigrationPaths as $rel) { + $component = $this->componentForInertiaPage($rel); + + if ($component !== null && isset($changedComponents[$component])) { + $preciselyHandledPages[$rel] = true; + } + } + + // Shared JS files (Components, Layouts, composables, etc.) + // aren't Inertia pages but pages depend on them transitively. + // `$jsFileToComponents` was computed at record time by walking + // Vite's module graph, so a change to + // `resources/js/Components/Button.vue` resolves directly to + // the set of page components that import it. Union those into + // `$changedComponents`. Files that aren't in the JS dep map + // fall through to the watch pattern below — same safety-net + // path the Inertia block above uses for unresolved pages. + $sharedFilesResolved = []; + foreach ($nonMigrationPaths as $rel) { + if (isset($preciselyHandledPages[$rel])) { + continue; + } + + if (! isset($this->jsFileToComponents[$rel])) { + continue; + } + + $touchedAny = false; + foreach ($this->jsFileToComponents[$rel] as $pageComponent) { + if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) { + $changedComponents[$pageComponent] = true; + $touchedAny = true; + } + } + + if ($touchedAny) { + $sharedFilesResolved[$rel] = true; + } + } + + if ($changedComponents !== []) { + foreach ($this->testInertiaComponents as $testFile => $components) { + if (isset($affectedSet[$testFile])) { + continue; + } + + foreach ($components as $component) { + if (isset($changedComponents[$component])) { + $affectedSet[$testFile] = true; + + break; + } + } + } + } + // 1. Coverage-edge lookup (PHP → PHP). Migrations are already // handled above; skipping them here prevents their always-on // coverage edges from invalidating the whole DB suite. @@ -241,8 +355,19 @@ public function affected(array $changedFiles): array // (exotic syntax, raw SQL) are funneled back in here too so // broad invalidation still kicks in for edge cases we can't // parse. + // Exclude paths that were already routed precisely through + // either the Inertia page-component path or the shared-JS + // dependency path. Broadcasting them again via the watch + // pattern would re-add every test the pattern maps to, + // defeating the surgical match. $unknownToGraph = $unparseableMigrations; foreach ($nonMigrationPaths as $rel) { + if (isset($preciselyHandledPages[$rel])) { + continue; + } + if (isset($sharedFilesResolved[$rel])) { + continue; + } if (! isset($this->fileIds[$rel])) { $unknownToGraph[] = $rel; } @@ -521,6 +646,76 @@ public function replaceTestTables(array $testToTables): void } } + /** + * Replaces Inertia component edges for the given test files. Names + * preserve case (they're identifiers like `Users/Show`, not + * user-supplied strings) but duplicates are collapsed. Same + * partial-update policy as `replaceTestTables`. + * + * @param array> $testToComponents + */ + public function replaceTestInertiaComponents(array $testToComponents): void + { + foreach ($testToComponents as $testFile => $components) { + $testRel = $this->relative($testFile); + + if ($testRel === null) { + continue; + } + + $normalised = []; + + foreach ($components as $component) { + if ($component !== '') { + $normalised[$component] = true; + } + } + + $names = array_keys($normalised); + sort($names); + + $this->testInertiaComponents[$testRel] = $names; + } + } + + /** + * Replaces the whole JS dep map. Called at record time with the + * output of `JsModuleGraph::build()`. Unlike the test-level + * replacements above this is a wholesale overwrite — the + * resolver produces the full graph on every run. + * + * @param array> $fileToComponents + */ + public function replaceJsFileToComponents(array $fileToComponents): void + { + $out = []; + + foreach ($fileToComponents as $path => $components) { + if ($path === '') { + continue; + } + $names = []; + + foreach ($components as $component) { + if ($component !== '') { + $names[$component] = true; + } + } + + if ($names === []) { + continue; + } + + $keys = array_keys($names); + sort($keys); + $out[$path] = $keys; + } + + ksort($out); + + $this->jsFileToComponents = $out; + } + /** * Projects under Laravel conventionally keep migrations at * `database/migrations/`. We recognise the directory as a prefix @@ -559,6 +754,58 @@ private function tablesForMigration(string $rel): array return TableExtractor::fromMigrationSource($content); } + /** + * Maps a project-relative path to its Inertia component name if it + * lives under `resources/js/Pages/` with a recognised framework + * extension. Returns null otherwise so callers can cheaply ignore + * non-page files. Matches Inertia's resolver convention: strip the + * `resources/js/Pages/` prefix, strip the extension, preserve the + * remaining slashes (`Users/Show.vue` → `Users/Show`). + */ + private function componentForInertiaPage(string $rel): ?string + { + $prefix = 'resources/js/Pages/'; + + if (! str_starts_with($rel, $prefix)) { + return null; + } + + $tail = substr($rel, strlen($prefix)); + $dot = strrpos($tail, '.'); + + if ($dot === false) { + return null; + } + + $extension = substr($tail, $dot + 1); + + if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte'], true)) { + return null; + } + + $name = substr($tail, 0, $dot); + + return $name === '' ? null : $name; + } + + /** + * Whether any test's component set contains `$component`. Used to + * decide between precise edge matching and watch-pattern fallback + * for a changed Inertia page file. + * + * @param array> $edges + */ + private function anyTestUses(array $edges, string $component): bool + { + foreach ($edges as $components) { + if (in_array($component, $components, true)) { + return true; + } + } + + return false; + } + /** * Drops edges whose test file no longer exists on disk. Prevents the graph * from keeping stale entries for deleted / renamed tests that would later @@ -574,6 +821,12 @@ public function pruneMissingTests(): void } } + foreach (array_keys($this->testInertiaComponents) as $testRel) { + if (! is_file($root.$testRel)) { + unset($this->testInertiaComponents[$testRel]); + } + } + foreach (array_keys($this->testTables) as $testRel) { if (! is_file($root.$testRel)) { unset($this->testTables[$testRel]); @@ -624,6 +877,53 @@ public static function decode(string $json, string $projectRoot): ?self } } + if (isset($data['test_inertia_components']) && is_array($data['test_inertia_components'])) { + foreach ($data['test_inertia_components'] as $testRel => $components) { + if (! is_string($testRel)) { + continue; + } + if (! is_array($components)) { + continue; + } + $names = []; + + foreach ($components as $component) { + if (is_string($component) && $component !== '') { + $names[] = $component; + } + } + + if ($names !== []) { + $graph->testInertiaComponents[$testRel] = $names; + } + } + } + + if (isset($data['js_file_to_components']) && is_array($data['js_file_to_components'])) { + foreach ($data['js_file_to_components'] as $path => $components) { + if (! is_string($path)) { + continue; + } + if ($path === '') { + continue; + } + if (! is_array($components)) { + continue; + } + $names = []; + + foreach ($components as $component) { + if (is_string($component) && $component !== '') { + $names[] = $component; + } + } + + if ($names !== []) { + $graph->jsFileToComponents[$path] = $names; + } + } + } + return $graph; } @@ -642,6 +942,8 @@ public function encode(): ?string 'edges' => $this->edges, 'baselines' => $this->baselines, 'test_tables' => $this->testTables, + 'test_inertia_components' => $this->testInertiaComponents, + 'js_file_to_components' => $this->jsFileToComponents, ]; $json = json_encode($payload, JSON_UNESCAPED_SLASHES); diff --git a/src/Plugins/Tia/InertiaEdges.php b/src/Plugins/Tia/InertiaEdges.php new file mode 100644 index 000000000..fdf14c75f --- /dev/null +++ b/src/Plugins/Tia/InertiaEdges.php @@ -0,0 +1,170 @@ +` (full HTML shape). Both carry + * the component name in a structured payload we can parse cheaply. + * + * Same dep-free handshake as `BladeEdges` / `TableTracker`: string + * class lookup + method-capability probes so Pest's `require` stays + * Laravel-free. + * + * @internal + */ +final class InertiaEdges +{ + private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container'; + + private const string REQUEST_HANDLED_EVENT = '\\Illuminate\\Foundation\\Http\\Events\\RequestHandled'; + + /** + * App-scoped marker that makes `arm()` idempotent across per-test + * `setUp()` calls. Laravel reuses the same app across tests in + * most configurations — without this guard we'd stack one + * listener per test. + */ + private const string MARKER = 'pest.tia.inertia-edges-armed'; + + public static function arm(Recorder $recorder): void + { + if (! $recorder->isActive()) { + return; + } + + $containerClass = self::CONTAINER_CLASS; + + if (! class_exists($containerClass)) { + return; + } + + /** @var object $app */ + $app = $containerClass::getInstance(); + + if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) { + return; + } + + if ($app->bound(self::MARKER)) { + return; + } + + if (! $app->bound('events')) { + return; + } + + $app->instance(self::MARKER, true); + + /** @var object $events */ + $events = $app->make('events'); + + if (! method_exists($events, 'listen')) { + return; + } + + $events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void { + if (! property_exists($event, 'response')) { + return; + } + + /** @var mixed $response */ + $response = $event->response; + + if (! is_object($response)) { + return; + } + + $component = self::extractComponent($response); + + if ($component !== null) { + $recorder->linkInertiaComponent($component); + } + }); + } + + /** + * Pulls the Inertia component name out of a Laravel response, + * handling both XHR (`X-Inertia` + JSON body) and full HTML + * (`
`) shapes. Returns null for any + * non-Inertia response so the caller can ignore it cheaply. + */ + private static function extractComponent(object $response): ?string + { + // XHR path: Inertia sets an `X-Inertia: true` header and the + // body is JSON with a `component` key. + if (property_exists($response, 'headers') && is_object($response->headers)) { + $headers = $response->headers; + + if (method_exists($headers, 'has') && $headers->has('X-Inertia')) { + $content = self::readContent($response); + + if ($content !== null) { + /** @var mixed $decoded */ + $decoded = json_decode($content, true); + + if (is_array($decoded) + && isset($decoded['component']) + && is_string($decoded['component']) + && $decoded['component'] !== '') { + return $decoded['component']; + } + } + } + } + + // Initial-load HTML path: Inertia embeds the page payload in a + // `data-page` attribute on the root `
`. We only + // pay the regex cost when the body actually contains the + // attribute, so non-Inertia HTML responses are effectively a + // no-op. + $content = self::readContent($response); + + if ($content === null || ! str_contains($content, 'data-page=')) { + return null; + } + + if (preg_match('/\sdata-page="([^"]+)"/', $content, $match) !== 1) { + return null; + } + + $decoded = json_decode(html_entity_decode($match[1]), true); + + if (is_array($decoded) + && isset($decoded['component']) + && is_string($decoded['component']) + && $decoded['component'] !== '') { + return $decoded['component']; + } + + return null; + } + + private static function readContent(object $response): ?string + { + if (! method_exists($response, 'getContent')) { + return null; + } + + /** @var mixed $content */ + $content = $response->getContent(); + + return is_string($content) ? $content : null; + } +} diff --git a/src/Plugins/Tia/JsImportParser.php b/src/Plugins/Tia/JsImportParser.php new file mode 100644 index 000000000..4b655641e --- /dev/null +++ b/src/Plugins/Tia/JsImportParser.php @@ -0,0 +1,270 @@ +` blocks parsed whole; imports inside + * `