mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 10:52:14 +02:00
wip
This commit is contained in:
@ -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
|
||||
*/
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user