> */ private array $perTestFiles = []; /** @var array> */ private array $perTestTables = []; /** @var array> */ private array $perTestInertiaComponents = []; /** @var array */ private array $perTestUsesDatabase = []; /** @var array */ private array $classFileCache = []; /** @var array */ private array $classUsesDatabaseCache = []; // Source file → declared class names. Built incrementally as classes are autoloaded. // Used to walk the interface/trait/parent hierarchy which coverage drivers miss // (interfaces and empty traits emit no executable bytecode). /** @var array> */ private array $fileToClassNames = []; /** @var array */ private array $indexedClassNames = []; /** @var array> */ private array $classDependencyCache = []; /** @var array> */ private array $testImportFileCache = []; /** @var array */ private array $includedFilesAtTestStart = []; 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')) { // Probing with start/stop emits E_WARNING when coverage is off, which monitoring agents // (Sentry, Bugsnag) can surface as a real error. xdebug_info('mode') is silent. $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; } if ($this->currentTestFile !== null) { return; } $file = $this->resolveTestFile($className, $fallbackFile); if ($file === null) { return; } $this->currentTestFile = $file; $this->includedFilesAtTestStart = AutoloadEdges::snapshot(); if ($this->classUsesDatabase($className)) { $this->perTestUsesDatabase[$file] = true; } // Walk parent-class chain to link ancestor files. Empty base classes (e.g. a trait-only // TestCase) emit no executable bytecode, so the coverage driver never records them. $this->linkAncestorFiles($className); $this->linkImportedFiles($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 buffer; without it the next start() accumulates prior test coverage. \xdebug_stop_code_coverage(true); } foreach (array_keys($data) as $sourceFile) { $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; } foreach (AutoloadEdges::newProjectFiles( $this->includedFilesAtTestStart, AutoloadEdges::snapshot(), TestSuite::getInstance()->rootPath, $this->currentTestFile, ) as $sourceFile) { $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; } // Walk covered classes' interfaces/traits/parents. Interfaces have no executable bytecode, // so a signature change would leave implementing-class tests stale without this walk. $this->linkSourceDependencies(array_keys($data)); $this->currentTestFile = null; $this->includedFilesAtTestStart = []; } public function linkSource(string $sourceFile): void { if (! $this->active) { return; } if ($this->currentTestFile === null) { return; } if ($sourceFile === '') { return; } $this->perTestFiles[$this->currentTestFile][$sourceFile] = true; } /** @param iterable $sourceFiles */ public function linkSourcesForTest(string $testFile, iterable $sourceFiles): void { if (! $this->active) { return; } if ($testFile === '') { return; } foreach ($sourceFiles as $sourceFile) { if ($sourceFile === '') { continue; } $this->perTestFiles[$testFile][$sourceFile] = true; } } /** @param array $coveredFiles */ 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; } } } } 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; 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; } } /** @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 — includes parents' interfaces — so one pass suffices. foreach ($reflection->getInterfaceNames() as $iname) { $linkSymbol($iname); } 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); } 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(); } } private function linkImportedFiles(string $testFile): void { if ($this->currentTestFile === null) { return; } foreach ($this->importedFilesFor($testFile) as $file) { $this->perTestFiles[$this->currentTestFile][$file] = true; } } /** * @return list */ private function importedFilesFor(string $testFile): array { if (array_key_exists($testFile, $this->testImportFileCache)) { return $this->testImportFileCache[$testFile]; } $source = @file_get_contents($testFile); if ($source === false) { return $this->testImportFileCache[$testFile] = []; } $files = []; foreach ($this->importedClassNames($source) as $className) { $file = $this->findAutoloadFile($className); if ($file !== null && ! str_contains($file, DIRECTORY_SEPARATOR.'vendor'.DIRECTORY_SEPARATOR)) { $files[$file] = true; } } return $this->testImportFileCache[$testFile] = array_keys($files); } /** * @return list */ private function importedClassNames(string $source): array { preg_match_all('/^use\s+(?!function\s|const\s)([^;]+);/mi', $source, $matches); $classes = []; foreach ($matches[1] as $import) { $import = trim($import); if ($import === '') { continue; } $open = strpos($import, '{'); $close = strrpos($import, '}'); if ($open !== false && $close !== false && $close > $open) { $prefix = trim(trim(substr($import, 0, $open)), '\\'); $items = explode(',', substr($import, $open + 1, $close - $open - 1)); foreach ($items as $item) { $class = $this->normaliseImportedClass($prefix.'\\'.trim($item)); if ($class !== null) { $classes[$class] = true; } } continue; } $class = $this->normaliseImportedClass($import); if ($class !== null) { $classes[$class] = true; } } return array_keys($classes); } private function normaliseImportedClass(string $import): ?string { $import = trim(trim($import), '\\'); if ($import === '') { return null; } $parts = preg_split('/\s+as\s+/i', $import); if ($parts === false || $parts === []) { return null; } $class = trim(trim($parts[0]), '\\'); return $class === '' ? null : $class; } private function findAutoloadFile(string $className): ?string { foreach (spl_autoload_functions() as $loader) { if (! is_array($loader) || ! isset($loader[0]) || ! is_object($loader[0])) { continue; } if (! method_exists($loader[0], 'findFile')) { continue; } /** @var mixed $file */ $file = $loader[0]->findFile($className); if (is_string($file) && $file !== '') { $real = @realpath($file); return $real === false ? $file : $real; } } return null; } 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; } 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; } 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> */ public function perTestFiles(): array { $out = []; foreach ($this->perTestFiles as $testFile => $sources) { $out[$testFile] = array_keys($sources); } return $out; } /** @return array> */ public function perTestTables(): array { $out = []; foreach ($this->perTestTables as $testFile => $tables) { $names = array_keys($tables); sort($names); $out[$testFile] = $names; } return $out; } /** @return array> */ public function perTestInertiaComponents(): array { $out = []; foreach ($this->perTestInertiaComponents as $testFile => $components) { $names = array_keys($components); sort($names); $out[$testFile] = $names; } return $out; } /** @return array */ 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; } // Prefers Pest's `$__filename` static (the original .php file) over ReflectionClass::getFileName() // (which returns 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; } 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; } }