. * * @var array> */ 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 = []; /** * Aggregated map: absolute test file → set. * Populated by `InertiaEdges` from Inertia responses observed at * request-handled time; consumed at record finalize to populate * the graph's per-test component edges that drive Vue / React * page-file impact analysis. * * @var array> */ private array $perTestInertiaComponents = []; /** * Set of absolute test files whose class hierarchy uses one of * Laravel's database-resetting traits (`RefreshDatabase`, * `DatabaseMigrations`, `DatabaseTransactions`). Captured at * `beginTest` so the finalize path can augment their table edges * even when seeders / pre-test DML fired before `TableTracker` * armed. * * @var array */ private array $perTestUsesDatabase = []; /** * Cached class → test file resolution. * * @var array */ private array $classFileCache = []; /** * Cached class → "uses Laravel DB trait" introspection result. * * @var array */ private array $classUsesDatabaseCache = []; /** * Reverse map of project-local source file → list of class / * interface / trait names declared in it. Built incrementally as * tests run and new classes get autoloaded; consumed by * `linkSourceDependencies()` so a test's covered file's * declared classes can be walked for their interfaces, traits, * and parents (which the coverage driver doesn't capture * because interface declarations and empty traits emit no * executable bytecode). * * @var array> */ private array $fileToClassNames = []; /** * Names already folded into `$fileToClassNames`. Lets the * incremental refresher skip classes seen in a previous test. * * @var array */ private array $indexedClassNames = []; /** * Cached "files this class transitively depends on (interfaces, * traits, parent chain, parents' interfaces and traits)" for * project-local class names. Avoids re-walking the same * hierarchy on every test that touches the same class. * * @var array> */ private array $classDependencyCache = []; private bool $active = false; private bool $driverChecked = false; private bool $driverAvailable = false; private string $driver = 'none'; 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') && function_exists('xdebug_info')) { // Xdebug 3+ exposes the active mode set via `xdebug_info`, // so we can ask directly instead of probing with a // start/stop pair. The probe approach used to emit // E_WARNING when coverage mode was off; with monitoring // agents (Sentry, Bugsnag) hooked into the error // handler stack that warning could be reported as a // real error. `xdebug_info('mode')` is silent and // returns the active modes as a list, so a presence // check is enough. $modes = \xdebug_info('mode'); if (is_array($modes) && in_array('coverage', $modes, true)) { $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->classUsesDatabase($className)) { $this->perTestUsesDatabase[$file] = true; } // Walk the parent-class chain and link each ancestor's defining // file as a source dependency of this test. Captures the common // `tests/TestCase.php` case (where the user's base may be // trait-only and have no executable lines for the coverage // driver to pick up), and any deeper hierarchy. Vendor parents // are skipped — those are pinned by `composer.lock` and don't // need per-test edges. Same idea applies to traits used by the // ancestors: a trait's body executes when the test method // calls into it, so coverage already captures it; we only need // the explicit walk for ancestors whose own bodies might be // empty. $this->linkAncestorFiles($className); 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; } // Walk each covered class's interfaces / traits / parent chain // and link those files explicitly. Interface declarations have // no executable bytecode, so coverage drivers never emit lines // for them — without this walk, a signature change to an // interface like `Viewable` would leave the cached results of // every test that exercises an implementing class stale, // because the interface file never enters the graph through // the coverage path. $this->linkSourceDependencies(array_keys($data)); $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; } /** * For each project-local source file the coverage driver * captured for this test, finds the classes / interfaces / traits * declared in it and links every file in their declarative * hierarchy: implemented interfaces (transitive), used traits, * and parent classes (with their own interfaces and traits). * * Coverage drivers only record executable lines, so an interface * signature change (e.g. adding a return type to a `Viewable` * method) never registers — the interface file has no bytecode * to instrument. Without this walk, every class implementing the * interface would silently keep its stale cached result through * the change, even though `--parallel` (no TIA) catches the * incompatibility immediately. * * @param array $coveredFiles absolute paths from coverage */ private function linkSourceDependencies(array $coveredFiles): void { if ($this->currentTestFile === null) { return; } $this->refreshClassMap(); foreach ($coveredFiles as $coveredFile) { if (! isset($this->fileToClassNames[$coveredFile])) { continue; } foreach ($this->fileToClassNames[$coveredFile] as $name) { foreach ($this->classDependencies($name) as $depFile) { $this->perTestFiles[$this->currentTestFile][$depFile] = true; } } } } /** * Incrementally folds every project-local class / interface / * trait declared since the last refresh into `$fileToClassNames`. * PHP only ever appends to its declared-symbol lists (classes * never get unloaded), so iterating from `$indexedClassNames`'s * cardinality forward is sufficient — and over a long suite this * is dominated by the first test, since most classes are loaded * by then. */ private function refreshClassMap(): void { $names = array_merge( get_declared_classes(), get_declared_interfaces(), get_declared_traits(), ); foreach ($names as $name) { if (isset($this->indexedClassNames[$name])) { continue; } $this->indexedClassNames[$name] = true; // Names came directly from `get_declared_*`, so the // class/interface/trait is guaranteed loaded — but // `class_exists($name, false)` (no autoload) keeps the // string narrowed to `class-string` for static analysis // and the `ReflectionClass` constructor stays in its // documented happy path. if (! class_exists($name, false) && ! interface_exists($name, false) && ! trait_exists($name, false)) { continue; } $reflection = new ReflectionClass($name); if ($reflection->isInternal()) { continue; } $file = $reflection->getFileName(); if (! is_string($file)) { continue; } if (str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) { continue; } $this->fileToClassNames[$file][] = $name; } } /** * Returns the project-local files the named class declaratively * depends on: implemented interfaces (transitive), used traits, * and the entire parent chain (each with their own interfaces * and traits). Cached per class because the answer is invariant * across a single process. * * @return list */ private function classDependencies(string $className): array { if (isset($this->classDependencyCache[$className])) { return $this->classDependencyCache[$className]; } if (! class_exists($className, false) && ! interface_exists($className, false) && ! trait_exists($className, false)) { return $this->classDependencyCache[$className] = []; } $reflection = new ReflectionClass($className); $files = []; $linkSymbol = static function (string $name) use (&$files): void { if (! class_exists($name, false) && ! interface_exists($name, false) && ! trait_exists($name, false)) { return; } $r = new ReflectionClass($name); if ($r->isInternal()) { return; } $f = $r->getFileName(); if (! is_string($f) || str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) { return; } $files[$f] = true; }; // `getInterfaceNames()` is transitive — it returns interfaces // from parent classes and parent interfaces too — so a single // pass covers the whole interface graph. foreach ($reflection->getInterfaceNames() as $iname) { $linkSymbol($iname); } // Direct + ancestor traits. `getTraitNames()` doesn't recurse // into traits-using-traits, but that's a rare pattern in // application code; if a project genuinely needs it, the // coverage driver will pick up the executed bytecode of the // outer trait and the dependency walk runs against the // resulting class anyway. foreach ($reflection->getTraitNames() as $tname) { $linkSymbol($tname); } $parent = $reflection->getParentClass(); while ($parent !== false && ! $parent->isInternal()) { $f = $parent->getFileName(); if (is_string($f) && ! str_contains($f, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) { $files[$f] = true; } foreach ($parent->getTraitNames() as $tname) { $linkSymbol($tname); } $parent = $parent->getParentClass(); } return $this->classDependencyCache[$className] = array_keys($files); } /** * Records every project-local ancestor class's defining file as a * source dependency of the currently-running test. PCOV / Xdebug * record *executable lines* — a base class whose body is just * `class TestCase extends BaseTestCase { use CreatesApplication; }` * has no executable bytecode of its own, so the driver doesn't * emit a line for it and it never enters the graph through the * usual coverage path. This walk fills that gap by asking * reflection for each parent's file and linking it explicitly. */ private function linkAncestorFiles(string $className): void { if (! class_exists($className, false)) { return; } $reflection = new ReflectionClass($className); $parent = $reflection->getParentClass(); while ($parent !== false) { if ($parent->isInternal()) { break; } $file = $parent->getFileName(); if (is_string($file) && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) { $this->perTestFiles[(string) $this->currentTestFile][$file] = true; } $parent = $parent->getParentClass(); } } /** * True when `$className` (or any of its ancestors) uses one of * Laravel's database-resetting traits. Walking up `getTraits()` is * necessary because Pest test classes are eval'd from the * generated `*.php` test file and the trait usually lives on a * shared `tests/TestCase.php` ancestor. Result is cached per class * — class hierarchies don't change within a process. */ private function classUsesDatabase(string $className): bool { if (array_key_exists($className, $this->classUsesDatabaseCache)) { return $this->classUsesDatabaseCache[$className]; } if (! class_exists($className, false)) { return $this->classUsesDatabaseCache[$className] = false; } static $needles = [ 'Illuminate\\Foundation\\Testing\\RefreshDatabase' => true, 'Illuminate\\Foundation\\Testing\\DatabaseMigrations' => true, 'Illuminate\\Foundation\\Testing\\DatabaseTransactions' => true, ]; $reflection = new ReflectionClass($className); do { foreach (array_keys($reflection->getTraits()) as $traitName) { if (isset($needles[$traitName])) { return $this->classUsesDatabaseCache[$className] = true; } } $reflection = $reflection->getParentClass(); } while ($reflection !== false && ! $reflection->isInternal()); return $this->classUsesDatabaseCache[$className] = false; } /** * 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; } /** * Records that the currently-running test server-side-rendered the * named Inertia component. The name is whatever * `Inertia::render($component, …)` was called with — typically a * slash-separated path like `Users/Show` that maps to * `resources/js/Pages/Users/Show.vue`. No-op outside a test window * so the underlying listener can stay armed without leaking * state between tests. */ public function linkInertiaComponent(string $component): void { if (! $this->active) { return; } if ($this->currentTestFile === null) { return; } if ($component === '') { return; } $this->perTestInertiaComponents[$this->currentTestFile][$component] = true; } /** * @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; } /** * @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; } /** * @return array> absolute test file → sorted list of Inertia component names. */ public function perTestInertiaComponents(): array { $out = []; foreach ($this->perTestInertiaComponents as $testFile => $components) { $names = array_keys($components); sort($names); $out[$testFile] = $names; } return $out; } /** * @return array absolute test file → true for tests using a Laravel DB-resetting trait. */ public function perTestUsesDatabase(): array { return $this->perTestUsesDatabase; } 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; } $reflection = new ReflectionClass($className); 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; } /** * 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->perTestTables = []; $this->perTestInertiaComponents = []; $this->perTestUsesDatabase = []; $this->classFileCache = []; $this->classUsesDatabaseCache = []; $this->fileToClassNames = []; $this->indexedClassNames = []; $this->classDependencyCache = []; $this->active = false; } }