diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index 46148178..951c11c8 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -17,6 +17,7 @@ use Pest\Plugins\Tia\Graph; use Pest\Plugins\Tia\JsModuleGraph; use Pest\Plugins\Tia\Recorder; use Pest\Plugins\Tia\ResultCollector; +use Pest\Plugins\Tia\TableExtractor; use Pest\Plugins\Tia\WatchPatterns; use Pest\Support\Container; use Pest\TestSuite; @@ -105,6 +106,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-'; + /** + * Sentinel dropped by a recording worker that found no usable + * coverage driver in its own process. Workers can have a different + * PHP env from the parent (Herd profile, custom ini scandir, CI + * runners that strip extensions), so the parent's driver check + * doesn't catch this. The parent reads these at end-of-run and + * surfaces a single warning so partial coverage loss isn't + * silent. + */ + private const string KEY_WORKER_NO_DRIVER_PREFIX = 'worker-no-driver-'; + /** * Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage` * run. Stored as bytes so the backend stays JSON/file-agnostic — the @@ -428,6 +440,24 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $perTestTables = $recorder->perTestTables(); $perTestInertia = $recorder->perTestInertiaComponents(); + $perTestUsesDatabase = $recorder->perTestUsesDatabase(); + + // Tests that use Laravel's DB-resetting traits (`RefreshDatabase`, + // `DatabaseMigrations`, `DatabaseTransactions`) but recorded zero + // queries during their body — typical seeded-fixture / attribute- + // assertion tests — would otherwise have empty `$testTables` and + // get silently skipped on migration changes. The migrations and + // seed DML run during `parent::setUp()` before `TableTracker` + // arms, so we can't capture them. Instead, conservatively union + // the project-wide migration table set into those tests so any + // schema change re-runs them. + if ($perTestUsesDatabase !== []) { + $perTestTables = $this->augmentDatabaseTestTables( + $perTestTables, + $perTestUsesDatabase, + $projectRoot, + ); + } if (Parallel::isWorker()) { $this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia); @@ -505,6 +535,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return $exitCode; } + $this->reportMissingWorkerDrivers(); + // 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` @@ -860,9 +892,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $recorder = $this->recorder; 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. + // Worker PHP can differ from the parent (Herd profile, custom + // `php.ini` scan dir, stripped CI runner). Drop a sentinel so + // the parent surfaces a single warning at end-of-run instead + // of letting the missing per-test edges and results pass + // unnoticed. + $this->state->write( + self::KEY_WORKER_NO_DRIVER_PREFIX.$this->workerToken().'.json', + '{}', + ); + return $arguments; } @@ -1098,6 +1137,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX); } + /** + * Reads per-worker "no driver available" sentinels and surfaces a + * single warning to the parent's terminal. Self-clears so the + * sentinel doesn't leak into the next run. No-op when every worker + * had a usable coverage driver. + */ + private function reportMissingWorkerDrivers(): void + { + $keys = $this->state->keysWithPrefix(self::KEY_WORKER_NO_DRIVER_PREFIX); + + if ($keys === []) { + return; + } + + foreach ($keys as $key) { + $this->state->delete($key); + } + + $this->output->writeln(sprintf( + ' TIA %d worker(s) had no coverage driver — their per-test edges and results were dropped. ' + .'Install / enable pcov or xdebug (mode: coverage) in the worker PHP and rerun.', + count($keys), + )); + } + private function purgeWorkerPartials(): void { foreach ($this->collectWorkerEdgesPartials() as $key) { @@ -1562,6 +1626,69 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return implode(', ', $changes); } + /** + * Unions the project's full migration-defined table set into every + * test that uses a Laravel DB-resetting trait. Captures the + * seeded-attribute case where `parent::setUp()` ran inserts before + * `TableTracker` armed and the test body issued no further queries + * — without this, those tests would have empty `$testTables` and + * be silently skipped on migration changes. + * + * Tests that DID record specific tables in their body keep those + * (the union is additive). The migration scan is cheap (one pass + * over `database/migrations/`) and only runs once per record. + * + * @param array> $perTestTables + * @param array $perTestUsesDatabase + * @return array> + */ + private function augmentDatabaseTestTables(array $perTestTables, array $perTestUsesDatabase, string $projectRoot): array + { + $migrationDir = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.'database'.DIRECTORY_SEPARATOR.'migrations'; + + if (! is_dir($migrationDir)) { + return $perTestTables; + } + + $allTables = []; + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($migrationDir, \FilesystemIterator::SKIP_DOTS), + ); + + foreach ($iterator as $fileInfo) { + if (! $fileInfo->isFile()) { + continue; + } + if (! str_ends_with(strtolower((string) $fileInfo->getPathname()), '.php')) { + continue; + } + + $content = @file_get_contents((string) $fileInfo->getPathname()); + + if ($content === false) { + continue; + } + + foreach (TableExtractor::fromMigrationSource($content) as $table) { + $allTables[strtolower($table)] = true; + } + } + + if ($allTables === []) { + return $perTestTables; + } + + foreach (array_keys($perTestUsesDatabase) as $testFile) { + $existing = $perTestTables[$testFile] ?? []; + $merged = array_fill_keys($existing, true) + $allTables; + $names = array_keys($merged); + sort($names); + $perTestTables[$testFile] = $names; + } + + return $perTestTables; + } + /** * @return array package name → version */ diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index e46b99a8..bdbbec51 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -50,6 +50,18 @@ final class Recorder */ 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. * @@ -57,6 +69,13 @@ final class Recorder */ private array $classFileCache = []; + /** + * Cached class → "uses Laravel DB trait" introspection result. + * + * @var array + */ + private array $classUsesDatabaseCache = []; + private bool $active = false; private bool $driverChecked = false; @@ -128,6 +147,10 @@ final class Recorder $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 @@ -238,6 +261,45 @@ final class Recorder } } + /** + * 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` @@ -335,6 +397,14 @@ final class Recorder 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)) { @@ -402,7 +472,9 @@ final class Recorder $this->perTestFiles = []; $this->perTestTables = []; $this->perTestInertiaComponents = []; + $this->perTestUsesDatabase = []; $this->classFileCache = []; + $this->classUsesDatabaseCache = []; $this->active = false; } }