This commit is contained in:
nuno maduro
2026-04-27 13:11:48 +01:00
parent b9088d23fb
commit f45cbf43c5
2 changed files with 202 additions and 3 deletions

View File

@ -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(
' <fg=yellow>TIA</> %d worker(s) had no coverage driver — their per-test edges and results were dropped. '
.'Install / enable <fg=cyan>pcov</> or <fg=cyan>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<string, array<int, string>> $perTestTables
* @param array<string, true> $perTestUsesDatabase
* @return array<string, array<int, string>>
*/
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<string, string> package name → version
*/

View File

@ -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<string, true>
*/
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<string, bool>
*/
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<string, true> 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;
}
}