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\JsModuleGraph;
|
||||||
use Pest\Plugins\Tia\Recorder;
|
use Pest\Plugins\Tia\Recorder;
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
use Pest\Plugins\Tia\ResultCollector;
|
||||||
|
use Pest\Plugins\Tia\TableExtractor;
|
||||||
use Pest\Plugins\Tia\WatchPatterns;
|
use Pest\Plugins\Tia\WatchPatterns;
|
||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
use Pest\TestSuite;
|
use Pest\TestSuite;
|
||||||
@ -105,6 +106,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
private const string KEY_WORKER_RESULTS_PREFIX = 'worker-results-';
|
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`
|
* Raw-serialised `CodeCoverage` snapshot from the last `--tia --coverage`
|
||||||
* run. Stored as bytes so the backend stays JSON/file-agnostic — the
|
* 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();
|
$perTestTables = $recorder->perTestTables();
|
||||||
$perTestInertia = $recorder->perTestInertiaComponents();
|
$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()) {
|
if (Parallel::isWorker()) {
|
||||||
$this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia);
|
$this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia);
|
||||||
@ -505,6 +535,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $exitCode;
|
return $exitCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->reportMissingWorkerDrivers();
|
||||||
|
|
||||||
// After a successful replay run, advance the recorded SHA to HEAD
|
// After a successful replay run, advance the recorded SHA to HEAD
|
||||||
// so the next run only diffs against what changed since NOW, not
|
// so the next run only diffs against what changed since NOW, not
|
||||||
// since the original recording. Without this, re-running `--tia`
|
// since the original recording. Without this, re-running `--tia`
|
||||||
@ -860,9 +892,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$recorder = $this->recorder;
|
$recorder = $this->recorder;
|
||||||
|
|
||||||
if (! $recorder->driverAvailable()) {
|
if (! $recorder->driverAvailable()) {
|
||||||
// Driver availability is per-process. If the driver is missing
|
// Worker PHP can differ from the parent (Herd profile, custom
|
||||||
// here, silently skip — the parent has already warned during
|
// `php.ini` scan dir, stripped CI runner). Drop a sentinel so
|
||||||
// its own boot.
|
// 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;
|
return $arguments;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1098,6 +1137,31 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return $this->state->keysWithPrefix(self::KEY_WORKER_EDGES_PREFIX);
|
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
|
private function purgeWorkerPartials(): void
|
||||||
{
|
{
|
||||||
foreach ($this->collectWorkerEdgesPartials() as $key) {
|
foreach ($this->collectWorkerEdgesPartials() as $key) {
|
||||||
@ -1562,6 +1626,69 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
return implode(', ', $changes);
|
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
|
* @return array<string, string> package name → version
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -50,6 +50,18 @@ final class Recorder
|
|||||||
*/
|
*/
|
||||||
private array $perTestInertiaComponents = [];
|
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.
|
* Cached class → test file resolution.
|
||||||
*
|
*
|
||||||
@ -57,6 +69,13 @@ final class Recorder
|
|||||||
*/
|
*/
|
||||||
private array $classFileCache = [];
|
private array $classFileCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached class → "uses Laravel DB trait" introspection result.
|
||||||
|
*
|
||||||
|
* @var array<string, bool>
|
||||||
|
*/
|
||||||
|
private array $classUsesDatabaseCache = [];
|
||||||
|
|
||||||
private bool $active = false;
|
private bool $active = false;
|
||||||
|
|
||||||
private bool $driverChecked = false;
|
private bool $driverChecked = false;
|
||||||
@ -128,6 +147,10 @@ final class Recorder
|
|||||||
|
|
||||||
$this->currentTestFile = $file;
|
$this->currentTestFile = $file;
|
||||||
|
|
||||||
|
if ($this->classUsesDatabase($className)) {
|
||||||
|
$this->perTestUsesDatabase[$file] = true;
|
||||||
|
}
|
||||||
|
|
||||||
// Walk the parent-class chain and link each ancestor's defining
|
// Walk the parent-class chain and link each ancestor's defining
|
||||||
// file as a source dependency of this test. Captures the common
|
// file as a source dependency of this test. Captures the common
|
||||||
// `tests/TestCase.php` case (where the user's base may be
|
// `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
|
* Records that the currently-running test queried `$table`. Called
|
||||||
* by `TableTracker` for every DML statement Laravel's `DB::listen`
|
* by `TableTracker` for every DML statement Laravel's `DB::listen`
|
||||||
@ -335,6 +397,14 @@ final class Recorder
|
|||||||
return $out;
|
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
|
private function resolveTestFile(string $className, string $fallbackFile): ?string
|
||||||
{
|
{
|
||||||
if (array_key_exists($className, $this->classFileCache)) {
|
if (array_key_exists($className, $this->classFileCache)) {
|
||||||
@ -402,7 +472,9 @@ final class Recorder
|
|||||||
$this->perTestFiles = [];
|
$this->perTestFiles = [];
|
||||||
$this->perTestTables = [];
|
$this->perTestTables = [];
|
||||||
$this->perTestInertiaComponents = [];
|
$this->perTestInertiaComponents = [];
|
||||||
|
$this->perTestUsesDatabase = [];
|
||||||
$this->classFileCache = [];
|
$this->classFileCache = [];
|
||||||
|
$this->classUsesDatabaseCache = [];
|
||||||
$this->active = false;
|
$this->active = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user