mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +02:00
Compare commits
6 Commits
b46f051550
...
81bfdbf8fe
| Author | SHA1 | Date | |
|---|---|---|---|
| 81bfdbf8fe | |||
| f45cbf43c5 | |||
| b9088d23fb | |||
| 7250185423 | |||
| e457eb0e9c | |||
| 48357c6f30 |
@ -17,11 +17,13 @@ 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;
|
||||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -87,7 +89,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
* flag forces an immediate retry (e.g. right after publishing a
|
* flag forces an immediate retry (e.g. right after publishing a
|
||||||
* baseline from CI for the first time).
|
* baseline from CI for the first time).
|
||||||
*/
|
*/
|
||||||
private const string REFETCH_OPTION = '--tia-refetch';
|
private const string REFETCH_OPTION = '--refetch';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State keys under which TIA persists its blobs. Kept here as constants
|
* State keys under which TIA persists its blobs. Kept here as constants
|
||||||
@ -104,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
|
||||||
@ -122,7 +135,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
* Cooldown marker keyed by `BaselineSync` after a failed fetch. Holds
|
* Cooldown marker keyed by `BaselineSync` after a failed fetch. Holds
|
||||||
* `{"until": <unix>}` — subsequent runs within the window skip the
|
* `{"until": <unix>}` — subsequent runs within the window skip the
|
||||||
* fetch attempt (and its `gh run list` network hop) until the
|
* fetch attempt (and its `gh run list` network hop) until the
|
||||||
* cooldown expires or the user passes `--tia-refetch`.
|
* cooldown expires or the user passes `--refetch`.
|
||||||
*/
|
*/
|
||||||
public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json';
|
public const string KEY_FETCH_COOLDOWN = 'fetch-cooldown.json';
|
||||||
|
|
||||||
@ -223,11 +236,22 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
private bool $recordingActive = false;
|
private bool $recordingActive = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when `--tia-refetch` is in the current argv — `BaselineSync`
|
* True when `--refetch` is in the current argv — `BaselineSync`
|
||||||
* uses it to bypass the post-failure fetch cooldown.
|
* uses it to bypass the post-failure fetch cooldown.
|
||||||
*/
|
*/
|
||||||
private bool $forceRefetch = false;
|
private bool $forceRefetch = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when `--fresh` is in the current argv — record-mode paths
|
||||||
|
* use it to gate `Graph::pruneMissingTests()`. On a partial record
|
||||||
|
* (default `--tia` after a branch switch, etc.) the working tree may
|
||||||
|
* not contain every test the shared graph knows about, so pruning
|
||||||
|
* would silently delete edges for tests that exist on other
|
||||||
|
* branches. `--fresh` rebuilds from scratch anyway, so pruning
|
||||||
|
* there is both safe and useful for cleaning up stale entries.
|
||||||
|
*/
|
||||||
|
private bool $freshRebuild = false;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly OutputInterface $output,
|
private readonly OutputInterface $output,
|
||||||
private readonly Recorder $recorder,
|
private readonly Recorder $recorder,
|
||||||
@ -347,6 +371,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
// silently ignore it here and let whatever else consumes it
|
// silently ignore it here and let whatever else consumes it
|
||||||
// handle it. The flag isn't popped in that branch.
|
// handle it. The flag isn't popped in that branch.
|
||||||
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
|
$forceRebuild = $freshRequested && ($enabled || $recordingGlobal || $replayingGlobal);
|
||||||
|
$this->freshRebuild = $forceRebuild;
|
||||||
|
|
||||||
if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
|
if (! $enabled && ! $this->forceRefetch && ! $recordingGlobal && ! $replayingGlobal) {
|
||||||
return $arguments;
|
return $arguments;
|
||||||
@ -415,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);
|
||||||
@ -443,7 +486,17 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$graph->replaceTestTables($perTestTables);
|
$graph->replaceTestTables($perTestTables);
|
||||||
$graph->replaceTestInertiaComponents($perTestInertia);
|
$graph->replaceTestInertiaComponents($perTestInertia);
|
||||||
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
||||||
$graph->pruneMissingTests();
|
|
||||||
|
// Pruning checks the local filesystem for each known test file —
|
||||||
|
// on a partial record (no `--fresh`) the current checkout may
|
||||||
|
// legitimately be missing tests that exist on other branches
|
||||||
|
// sharing this graph, so pruning would silently delete their
|
||||||
|
// edges. Stale entries for genuinely-deleted tests are harmless
|
||||||
|
// (test discovery never finds the file) and get cleaned up on
|
||||||
|
// the next `--fresh` rebuild.
|
||||||
|
if ($this->freshRebuild) {
|
||||||
|
$graph->pruneMissingTests();
|
||||||
|
}
|
||||||
|
|
||||||
// Fold in the results collected during this same record run. The
|
// Fold in the results collected during this same record run. The
|
||||||
// `AddsOutput` pass that runs `snapshotTestResults` fires *before*
|
// `AddsOutput` pass that runs `snapshotTestResults` fires *before*
|
||||||
@ -482,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`
|
||||||
@ -611,7 +666,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$graph->replaceTestTables($finalisedTables);
|
$graph->replaceTestTables($finalisedTables);
|
||||||
$graph->replaceTestInertiaComponents($finalisedInertia);
|
$graph->replaceTestInertiaComponents($finalisedInertia);
|
||||||
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
||||||
$graph->pruneMissingTests();
|
|
||||||
|
// See `terminate()` — same rationale: pruning by current
|
||||||
|
// working-tree presence would silently drop edges for tests
|
||||||
|
// owned by other branches sharing this graph. Only safe on
|
||||||
|
// `--fresh` rebuilds.
|
||||||
|
if ($this->freshRebuild) {
|
||||||
|
$graph->pruneMissingTests();
|
||||||
|
}
|
||||||
|
|
||||||
if (! $this->saveGraph($graph)) {
|
if (! $this->saveGraph($graph)) {
|
||||||
$this->output->writeln(' <fg=red>TIA</> failed to write graph.');
|
$this->output->writeln(' <fg=red>TIA</> failed to write graph.');
|
||||||
@ -658,9 +720,50 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$stored = $graph->fingerprint();
|
$stored = $graph->fingerprint();
|
||||||
|
|
||||||
if (! Fingerprint::structuralMatches($stored, $current)) {
|
if (! Fingerprint::structuralMatches($stored, $current)) {
|
||||||
$this->output->writeln(
|
$drift = Fingerprint::structuralDrift($stored, $current);
|
||||||
' <fg=yellow>TIA</> graph structure outdated — rebuilding.',
|
|
||||||
);
|
$this->output->writeln(sprintf(
|
||||||
|
' <fg=yellow>TIA</> graph structure outdated (%s).',
|
||||||
|
$this->formatStructuralDrift($drift),
|
||||||
|
));
|
||||||
|
|
||||||
|
// For composer.lock specifically, surface the actual
|
||||||
|
// package-version deltas. Saves the user a `git diff
|
||||||
|
// composer.lock | grep -E "name|version"` round-trip when
|
||||||
|
// a routine `composer update` invalidates the graph.
|
||||||
|
if (in_array('composer_lock', $drift, true)) {
|
||||||
|
$branchSha = $graph->recordedAtSha($this->branch);
|
||||||
|
if ($branchSha !== null) {
|
||||||
|
$summary = $this->composerLockDelta(
|
||||||
|
TestSuite::getInstance()->rootPath,
|
||||||
|
$branchSha,
|
||||||
|
);
|
||||||
|
if ($summary !== '') {
|
||||||
|
$this->output->writeln(' <fg=gray>'.$summary.'</>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try the remote baseline before paying for a local
|
||||||
|
// rebuild. CI runs the baseline workflow against every
|
||||||
|
// push to main, so the most common cause of structural
|
||||||
|
// drift (`composer update` landed on main, you pulled it,
|
||||||
|
// your branch hasn't diverged yet) is recoverable in
|
||||||
|
// ~5–30s of network instead of minutes of recording.
|
||||||
|
//
|
||||||
|
// Revalidation is the safety: even if the fetch succeeds,
|
||||||
|
// we only adopt the result when its stored fingerprint
|
||||||
|
// structurally matches the *current* one. A stale CI
|
||||||
|
// baseline (workflow hasn't run since the drift) gets
|
||||||
|
// dropped and we fall through to the local rebuild path.
|
||||||
|
$rebuilt = $this->tryRemoteBaselineForDrift($current);
|
||||||
|
|
||||||
|
if ($rebuilt instanceof Graph) {
|
||||||
|
return $this->reconcileFingerprint($rebuilt, $current);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->output->writeln(' <fg=yellow>TIA</> rebuilding graph from scratch.');
|
||||||
|
|
||||||
$this->state->delete(self::KEY_GRAPH);
|
$this->state->delete(self::KEY_GRAPH);
|
||||||
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
$this->state->delete(self::KEY_COVERAGE_CACHE);
|
||||||
|
|
||||||
@ -789,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1027,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) {
|
||||||
@ -1356,4 +1491,237 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
return $coverage->coverage === true;
|
return $coverage->coverage === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to short-circuit a structural-drift rebuild by fetching
|
||||||
|
* a fresh CI-recorded baseline. Returns the loaded `Graph` only if
|
||||||
|
* the fetched payload structurally matches the *current* fingerprint
|
||||||
|
* — i.e., CI has already recorded against the new shape and we can
|
||||||
|
* safely use those edges. Any other outcome (no GitHub remote, fetch
|
||||||
|
* cooldown, no successful CI run, fetched-graph-still-drifts) → null,
|
||||||
|
* caller falls back to local rebuild.
|
||||||
|
*
|
||||||
|
* @param array{structural: array<string, mixed>, environmental: array<string, mixed>} $current
|
||||||
|
*/
|
||||||
|
private function tryRemoteBaselineForDrift(array $current): ?Graph
|
||||||
|
{
|
||||||
|
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||||
|
|
||||||
|
if (! $this->baselineSync->fetchIfAvailable($projectRoot, false)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fetched = $this->loadGraph($projectRoot);
|
||||||
|
|
||||||
|
if (! $fetched instanceof Graph) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! Fingerprint::structuralMatches($fetched->fingerprint(), $current)) {
|
||||||
|
$this->output->writeln(
|
||||||
|
' <fg=yellow>TIA</> fetched baseline still drifts — discarding.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->output->writeln(
|
||||||
|
' <fg=green>TIA</> fetched baseline matches — skipping local rebuild.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return $fetched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps `Fingerprint::structuralDrift()` field names to a human
|
||||||
|
* label suitable for the `(reason)` part of the rebuild banner.
|
||||||
|
*
|
||||||
|
* @param list<string> $drift
|
||||||
|
*/
|
||||||
|
private function formatStructuralDrift(array $drift): string
|
||||||
|
{
|
||||||
|
static $labels = [
|
||||||
|
'composer_lock' => 'composer.lock',
|
||||||
|
'composer_json' => 'composer.json',
|
||||||
|
'phpunit_xml' => 'phpunit.xml',
|
||||||
|
'phpunit_xml_dist' => 'phpunit.xml.dist',
|
||||||
|
'vite_config' => 'vite.config',
|
||||||
|
'pest_factory' => 'Pest internals',
|
||||||
|
'pest_method_factory' => 'Pest internals',
|
||||||
|
];
|
||||||
|
|
||||||
|
$seen = [];
|
||||||
|
foreach ($drift as $key) {
|
||||||
|
$seen[$labels[$key] ?? $key] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($seen === []) {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', array_keys($seen));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Diffs `composer.lock` between the recorded SHA and the current
|
||||||
|
* working tree, returns a one-line summary like:
|
||||||
|
*
|
||||||
|
* "laravel/framework 12.30 → 12.31, + pestphp/pest 4.7"
|
||||||
|
*
|
||||||
|
* Empty string when git is unavailable, the sha doesn't have the
|
||||||
|
* file, the file can't be parsed, or there are no version
|
||||||
|
* deltas (a content-hash-only edit, vendor URL change, etc.).
|
||||||
|
*/
|
||||||
|
private function composerLockDelta(string $projectRoot, string $sha): string
|
||||||
|
{
|
||||||
|
$current = @file_get_contents($projectRoot.'/composer.lock');
|
||||||
|
if ($current === false) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$process = new Process(['git', 'show', $sha.':composer.lock'], $projectRoot);
|
||||||
|
$process->setTimeout(5.0);
|
||||||
|
$process->run();
|
||||||
|
|
||||||
|
if (! $process->isSuccessful()) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$oldVersions = $this->lockVersions($process->getOutput());
|
||||||
|
$newVersions = $this->lockVersions($current);
|
||||||
|
|
||||||
|
if ($oldVersions === [] && $newVersions === []) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$changes = [];
|
||||||
|
foreach ($newVersions as $name => $version) {
|
||||||
|
if (! isset($oldVersions[$name])) {
|
||||||
|
$changes[] = '+ '.$name.' '.$version;
|
||||||
|
} elseif ($oldVersions[$name] !== $version) {
|
||||||
|
$changes[] = $name.' '.$oldVersions[$name].' → '.$version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($oldVersions as $name => $version) {
|
||||||
|
if (! isset($newVersions[$name])) {
|
||||||
|
$changes[] = '− '.$name.' '.$version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($changes === []) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
sort($changes);
|
||||||
|
|
||||||
|
// Cap at a sensible number — a wholesale `composer update`
|
||||||
|
// could list 50+ packages and bury the prompt.
|
||||||
|
$maxShown = 8;
|
||||||
|
if (count($changes) > $maxShown) {
|
||||||
|
$extra = count($changes) - $maxShown;
|
||||||
|
$changes = array_slice($changes, 0, $maxShown);
|
||||||
|
$changes[] = sprintf('… +%d more', $extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
private function lockVersions(string $json): array
|
||||||
|
{
|
||||||
|
$data = json_decode($json, true);
|
||||||
|
|
||||||
|
if (! is_array($data)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = [];
|
||||||
|
|
||||||
|
foreach (['packages', 'packages-dev'] as $section) {
|
||||||
|
if (! isset($data[$section])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($data[$section])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($data[$section] as $package) {
|
||||||
|
if (! is_array($package)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$name = $package['name'] ?? null;
|
||||||
|
$version = $package['version'] ?? null;
|
||||||
|
|
||||||
|
if (is_string($name) && is_string($version)) {
|
||||||
|
$out[$name] = $version;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,7 +67,7 @@ final readonly class BaselineSync
|
|||||||
* Rationale: when the remote workflow hasn't published yet, every
|
* Rationale: when the remote workflow hasn't published yet, every
|
||||||
* `pest --tia` invocation would otherwise re-hit `gh run list` and
|
* `pest --tia` invocation would otherwise re-hit `gh run list` and
|
||||||
* re-print the publish instructions — noisy + slow. Back off for a
|
* re-print the publish instructions — noisy + slow. Back off for a
|
||||||
* day, let the user override with `--tia-refetch`.
|
* day, let the user override with `--refetch`.
|
||||||
*/
|
*/
|
||||||
private const int FETCH_COOLDOWN_SECONDS = 86400;
|
private const int FETCH_COOLDOWN_SECONDS = 86400;
|
||||||
|
|
||||||
@ -82,7 +82,7 @@ final readonly class BaselineSync
|
|||||||
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
|
* landed; coverage is best-effort since plain `--tia` (no `--coverage`)
|
||||||
* never reads it.
|
* never reads it.
|
||||||
*
|
*
|
||||||
* `$force = true` (driven by `--tia-refetch`) ignores the post-failure
|
* `$force = true` (driven by `--refetch`) ignores the post-failure
|
||||||
* cooldown so the user can retry on demand without waiting out the
|
* cooldown so the user can retry on demand without waiting out the
|
||||||
* 24h window.
|
* 24h window.
|
||||||
*/
|
*/
|
||||||
@ -97,7 +97,7 @@ final readonly class BaselineSync
|
|||||||
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
|
if (! $force && ($remaining = $this->cooldownRemaining()) !== null) {
|
||||||
$this->output->writeln(sprintf(
|
$this->output->writeln(sprintf(
|
||||||
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. '
|
' <fg=yellow>TIA</> last fetch found no baseline — next auto-retry in %s. '
|
||||||
.'Override with <fg=cyan>--tia-refetch</>.',
|
.'Override with <fg=cyan>--refetch</>.',
|
||||||
$this->formatDuration($remaining),
|
$this->formatDuration($remaining),
|
||||||
));
|
));
|
||||||
|
|
||||||
@ -367,6 +367,15 @@ YAML;
|
|||||||
return $m[1];
|
return $m[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SSH URL form: ssh://[user@]github.com[:port]/org/repo(.git).
|
||||||
|
// Some teams configure this explicitly to pin the SSH port; the
|
||||||
|
// colon-separated form above doesn't match. Mirrors the parser
|
||||||
|
// in `Storage::originIdentity` so the same remote produces the
|
||||||
|
// same project key for both storage and remote-fetch.
|
||||||
|
if (preg_match('#^ssh://(?:[^@/]+@)?github\.com(?::\d+)?/([\w.-]+/[\w.-]+?)(?:\.git)?/?$#i', $url, $m) === 1) {
|
||||||
|
return $m[1];
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -283,6 +283,11 @@ final readonly class ChangedFiles
|
|||||||
'.phpunit.result.cache',
|
'.phpunit.result.cache',
|
||||||
'vendor/',
|
'vendor/',
|
||||||
'node_modules/',
|
'node_modules/',
|
||||||
|
// Laravel regenerates these from manifest state
|
||||||
|
// (package.json, service providers) at boot — they're
|
||||||
|
// fully derived, not authored. Treating them as
|
||||||
|
// "changes" just flaps the diff noisily.
|
||||||
|
'bootstrap/cache/',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($prefixes as $prefix) {
|
foreach ($prefixes as $prefix) {
|
||||||
|
|||||||
@ -9,15 +9,22 @@ namespace Pest\Plugins\Tia;
|
|||||||
* or its recorded results stale. The fingerprint is split into two buckets:
|
* or its recorded results stale. The fingerprint is split into two buckets:
|
||||||
*
|
*
|
||||||
* - **structural** — describes what the graph's *edges* were recorded
|
* - **structural** — describes what the graph's *edges* were recorded
|
||||||
* against. If any of these drift (`composer.lock`, `tests/Pest.php`,
|
* against. If any of these drift (`composer.lock`, `composer.json`,
|
||||||
* Pest's factory codegen, etc.) the edges themselves are potentially
|
* `phpunit.xml{,.dist}`, `vite.config.*`, Pest's factory codegen) the
|
||||||
* wrong and the graph must rebuild from scratch.
|
* edges themselves are potentially wrong and the graph must rebuild
|
||||||
|
* from scratch. `tests/TestCase.php` and `tests/Pest.php` are
|
||||||
|
* intentionally NOT here — those are handled by per-test ancestor
|
||||||
|
* linking (`Recorder::linkAncestorFiles`) and the Php watch pattern
|
||||||
|
* respectively, which give precise invalidation rather than a wholesale
|
||||||
|
* rebuild.
|
||||||
* - **environmental** — describes the *runtime* the results were captured
|
* - **environmental** — describes the *runtime* the results were captured
|
||||||
* on (PHP minor, extension set, Pest version). Drift here means the
|
* on (PHP minor, extension set). Drift here means the edges are still
|
||||||
* edges are still trustworthy, but the cached per-test results (pass/
|
* trustworthy, but the cached per-test results (pass/fail/time) may
|
||||||
* fail/time) may not reproduce on this machine. Tia's handler drops the
|
* not reproduce on this machine. Tia's handler drops the branch's
|
||||||
* branch's results + coverage cache and re-runs to freshen them, rather
|
* results + coverage cache and re-runs to freshen them, rather than
|
||||||
* than re-recording from scratch.
|
* re-recording from scratch. Pest's own version is intentionally NOT
|
||||||
|
* here — `composer.lock`'s structural hash already moves whenever the
|
||||||
|
* installed Pest version changes.
|
||||||
*
|
*
|
||||||
* Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and
|
* Legacy flat-shape graphs (schema ≤ 3) are read as structurally stale and
|
||||||
* rebuilt on first load; the schema bump in the structural bucket takes
|
* rebuilt on first load; the schema bump in the structural bucket takes
|
||||||
@ -60,7 +67,19 @@ final readonly class Fingerprint
|
|||||||
// Vite config change reshapes the module dependency graph
|
// Vite config change reshapes the module dependency graph
|
||||||
// that `JsModuleGraph` records; without a graph rebuild
|
// that `JsModuleGraph` records; without a graph rebuild
|
||||||
// the stored `$jsFileToComponents` map silently goes stale.
|
// the stored `$jsFileToComponents` map silently goes stale.
|
||||||
private const int SCHEMA_VERSION = 10;
|
// v11: `composer.json` added (autoload-dev / extra discovery
|
||||||
|
// changes). `tests/TestCase.php` and `tests/Pest.php` are
|
||||||
|
// intentionally NOT fingerprinted — they're handled by the
|
||||||
|
// watch pattern + `Recorder::linkAncestorFiles` reflection
|
||||||
|
// walk, which gives precise per-test invalidation rather
|
||||||
|
// than a wholesale rebuild that trashes the entire graph.
|
||||||
|
// v12: PHP/JS structural inputs (pest_factory*, vite.config.*)
|
||||||
|
// now hash via `ContentHash::of()` so cosmetic comment +
|
||||||
|
// whitespace edits don't fire rebuilds. composer.json and
|
||||||
|
// composer.lock hash a behavioural subset — description,
|
||||||
|
// keywords, scripts, authors, install timestamps, dist
|
||||||
|
// URLs etc. no longer drift the structural fingerprint.
|
||||||
|
private const int SCHEMA_VERSION = 12;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{
|
* @return array{
|
||||||
@ -73,18 +92,35 @@ final readonly class Fingerprint
|
|||||||
return [
|
return [
|
||||||
'structural' => [
|
'structural' => [
|
||||||
'schema' => self::SCHEMA_VERSION,
|
'schema' => self::SCHEMA_VERSION,
|
||||||
'composer_lock' => self::hashIfExists($projectRoot.'/composer.lock'),
|
// `composer.lock` hashed against a *behavioural*
|
||||||
|
// subset (per-package version + reference + autoload +
|
||||||
|
// extra). Skips per-package install timestamps, dist
|
||||||
|
// URLs, support links, descriptions — none of which
|
||||||
|
// affect what code runs.
|
||||||
|
'composer_lock' => self::composerLockHash($projectRoot),
|
||||||
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
'phpunit_xml' => self::hashIfExists($projectRoot.'/phpunit.xml'),
|
||||||
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
'phpunit_xml_dist' => self::hashIfExists($projectRoot.'/phpunit.xml.dist'),
|
||||||
'pest_php' => self::hashIfExists($projectRoot.'/tests/Pest.php'),
|
|
||||||
// Pest's generated classes bake the code-generation logic
|
// Pest's generated classes bake the code-generation logic
|
||||||
// in — if TestCaseFactory changes (new attribute, different
|
// in — if TestCaseFactory changes (new attribute, different
|
||||||
// method signature, etc.) every previously-recorded edge is
|
// method signature, etc.) every previously-recorded edge is
|
||||||
// stale. Hashing the factory sources makes path-repo /
|
// stale. Hashing via `ContentHash::of()` so cosmetic edits
|
||||||
// dev-main installs automatically rebuild their graphs when
|
// (comments, formatting) don't drift the fingerprint.
|
||||||
// Pest itself is edited.
|
'pest_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
||||||
'pest_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseFactory.php'),
|
'pest_method_factory' => self::contentHashOrNull(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
||||||
'pest_method_factory' => self::hashIfExists(__DIR__.'/../../Factories/TestCaseMethodFactory.php'),
|
// `vite.config.*` reshapes the module graph
|
||||||
|
// `JsModuleGraph` records at the next `--tia` run; if
|
||||||
|
// the config drifts without a rebuild, the stored
|
||||||
|
// `$jsFileToComponents` map is silently stale.
|
||||||
|
// `viteConfigHash` itself uses `ContentHash::of()` so
|
||||||
|
// a comment-only edit to vite.config doesn't rebuild.
|
||||||
|
'vite_config' => self::viteConfigHash($projectRoot),
|
||||||
|
// `composer.json` hashed against a behavioural subset:
|
||||||
|
// autoload(-dev), require(-dev), extra (Laravel
|
||||||
|
// package discovery), repositories, minimum-stability,
|
||||||
|
// and the platform / allow-plugins entries from
|
||||||
|
// `config`. Cosmetic fields (description, keywords,
|
||||||
|
// scripts, authors, funding, support) are excluded.
|
||||||
|
'composer_json' => self::composerJsonHash($projectRoot),
|
||||||
],
|
],
|
||||||
'environmental' => [
|
'environmental' => [
|
||||||
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
|
// PHP **minor** only (8.4, not 8.4.19) — CI's resolved patch
|
||||||
@ -92,7 +128,6 @@ final readonly class Fingerprint
|
|||||||
// the patch rarely changes anything test-visible.
|
// the patch rarely changes anything test-visible.
|
||||||
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
|
'php_minor' => PHP_MAJOR_VERSION.'.'.PHP_MINOR_VERSION,
|
||||||
'extensions' => self::extensionsFingerprint($projectRoot),
|
'extensions' => self::extensionsFingerprint($projectRoot),
|
||||||
'pest' => self::readPestVersion($projectRoot),
|
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@ -115,6 +150,45 @@ final readonly class Fingerprint
|
|||||||
return $aStructural === $bStructural;
|
return $aStructural === $bStructural;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of structural field names that drifted between
|
||||||
|
* the stored and current fingerprints. Empty list = no drift.
|
||||||
|
* Caller uses this to tell the user *why* the graph rebuilt — a
|
||||||
|
* generic "graph outdated" message leaves people staring at
|
||||||
|
* unrelated diffs.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $stored
|
||||||
|
* @param array<string, mixed> $current
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function structuralDrift(array $stored, array $current): array
|
||||||
|
{
|
||||||
|
$a = self::structuralOnly($stored);
|
||||||
|
$b = self::structuralOnly($current);
|
||||||
|
|
||||||
|
$drifts = [];
|
||||||
|
|
||||||
|
foreach ($a as $key => $value) {
|
||||||
|
if ($key === 'schema') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (($b[$key] ?? null) !== $value) {
|
||||||
|
$drifts[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($b as $key => $value) {
|
||||||
|
if ($key === 'schema') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! array_key_exists($key, $a) && $value !== null) {
|
||||||
|
$drifts[] = $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($drifts));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a list of field names that drifted between the stored and
|
* Returns a list of field names that drifted between the stored and
|
||||||
* current environmental fingerprints. Empty list = no drift. Caller
|
* current environmental fingerprints. Empty list = no drift. Caller
|
||||||
@ -193,6 +267,206 @@ final readonly class Fingerprint
|
|||||||
return $normalised;
|
return $normalised;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combined hash of every `vite.config.{ts,js,mjs,cjs,mts}` present
|
||||||
|
* at the project root. Most projects have exactly one; we accept
|
||||||
|
* any of the five recognised extensions without assuming which
|
||||||
|
* the user picked. Returns null when no config file exists —
|
||||||
|
* treated as "no Vite project" by the matcher, no drift.
|
||||||
|
*/
|
||||||
|
private static function viteConfigHash(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
|
||||||
|
$hash = self::contentHashOrNull($projectRoot.'/'.$name);
|
||||||
|
|
||||||
|
if ($hash !== null) {
|
||||||
|
$parts[] = $name.':'.$hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $parts === [] ? null : hash('xxh128', implode("\n", $parts));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavioural subset of `composer.json`. Keeps the keys that
|
||||||
|
* actually move test outcomes (autoload, require, extra,
|
||||||
|
* repositories, minimum-stability, platform / allow-plugins
|
||||||
|
* config) and drops cosmetic ones (description, keywords,
|
||||||
|
* scripts, authors, funding, homepage, support). Falls back to
|
||||||
|
* a raw hash on parse errors so any change still rebuilds.
|
||||||
|
*/
|
||||||
|
private static function composerJsonHash(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
$path = $projectRoot.'/composer.json';
|
||||||
|
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (! is_array($data)) {
|
||||||
|
$hash = @hash_file('xxh128', $path);
|
||||||
|
|
||||||
|
return $hash === false ? null : $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
$config = is_array($data['config'] ?? null) ? $data['config'] : [];
|
||||||
|
$relevantConfig = array_intersect_key($config, [
|
||||||
|
'platform' => true,
|
||||||
|
'allow-plugins' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$relevant = [
|
||||||
|
'autoload' => $data['autoload'] ?? null,
|
||||||
|
'autoload-dev' => $data['autoload-dev'] ?? null,
|
||||||
|
'require' => $data['require'] ?? null,
|
||||||
|
'require-dev' => $data['require-dev'] ?? null,
|
||||||
|
'extra' => $data['extra'] ?? null,
|
||||||
|
'repositories' => $data['repositories'] ?? null,
|
||||||
|
'minimum-stability' => $data['minimum-stability'] ?? null,
|
||||||
|
'prefer-stable' => $data['prefer-stable'] ?? null,
|
||||||
|
'config' => $relevantConfig === [] ? null : $relevantConfig,
|
||||||
|
];
|
||||||
|
|
||||||
|
self::sortRecursively($relevant);
|
||||||
|
|
||||||
|
$json = json_encode($relevant);
|
||||||
|
|
||||||
|
return $json === false ? null : hash('xxh128', $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavioural subset of `composer.lock`. For every package in
|
||||||
|
* `packages` and `packages-dev`, keeps version + dist/source
|
||||||
|
* reference (commit SHA — catches dev-branch updates that don't
|
||||||
|
* bump the version string) + autoload(-dev) + extra (Laravel
|
||||||
|
* package discovery). Drops install timestamps, dist URLs,
|
||||||
|
* support links, descriptions, etc. — none of which change what
|
||||||
|
* code runs.
|
||||||
|
*/
|
||||||
|
private static function composerLockHash(string $projectRoot): ?string
|
||||||
|
{
|
||||||
|
$path = $projectRoot.'/composer.lock';
|
||||||
|
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = @file_get_contents($path);
|
||||||
|
|
||||||
|
if ($raw === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = json_decode($raw, true);
|
||||||
|
|
||||||
|
if (! is_array($data)) {
|
||||||
|
$hash = @hash_file('xxh128', $path);
|
||||||
|
|
||||||
|
return $hash === false ? null : $hash;
|
||||||
|
}
|
||||||
|
|
||||||
|
$relevant = [
|
||||||
|
'platform' => $data['platform'] ?? null,
|
||||||
|
'platform-dev' => $data['platform-dev'] ?? null,
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach (['packages', 'packages-dev'] as $section) {
|
||||||
|
if (! isset($data[$section])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! is_array($data[$section])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$packages = [];
|
||||||
|
|
||||||
|
foreach ($data[$section] as $package) {
|
||||||
|
if (! is_array($package)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$name = $package['name'] ?? null;
|
||||||
|
|
||||||
|
if (! is_string($name)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$packages[$name] = [
|
||||||
|
'version' => $package['version'] ?? null,
|
||||||
|
'reference' => self::lockReference($package),
|
||||||
|
'autoload' => $package['autoload'] ?? null,
|
||||||
|
'autoload-dev' => $package['autoload-dev'] ?? null,
|
||||||
|
'extra' => $package['extra'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($packages);
|
||||||
|
$relevant[$section] = $packages;
|
||||||
|
}
|
||||||
|
|
||||||
|
self::sortRecursively($relevant);
|
||||||
|
|
||||||
|
$json = json_encode($relevant);
|
||||||
|
|
||||||
|
return $json === false ? null : hash('xxh128', $json);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $package
|
||||||
|
*/
|
||||||
|
private static function lockReference(array $package): ?string
|
||||||
|
{
|
||||||
|
$dist = is_array($package['dist'] ?? null) ? $package['dist'] : [];
|
||||||
|
$source = is_array($package['source'] ?? null) ? $package['source'] : [];
|
||||||
|
|
||||||
|
$reference = $dist['reference'] ?? $source['reference'] ?? null;
|
||||||
|
|
||||||
|
return is_string($reference) ? $reference : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively sorts associative arrays by key so semantically
|
||||||
|
* equivalent JSON produces the same hash regardless of key
|
||||||
|
* ordering. Lists (numeric arrays) keep their order — they're
|
||||||
|
* meaningful in `repositories`, `autoload.files`, etc.
|
||||||
|
*/
|
||||||
|
private static function sortRecursively(mixed &$value): void
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isAssoc = ! array_is_list($value);
|
||||||
|
|
||||||
|
if ($isAssoc) {
|
||||||
|
ksort($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($value as &$child) {
|
||||||
|
self::sortRecursively($child);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function contentHashOrNull(string $path): ?string
|
||||||
|
{
|
||||||
|
if (! is_file($path)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$hash = ContentHash::of($path);
|
||||||
|
|
||||||
|
return $hash === false ? null : $hash;
|
||||||
|
}
|
||||||
|
|
||||||
private static function hashIfExists(string $path): ?string
|
private static function hashIfExists(string $path): ?string
|
||||||
{
|
{
|
||||||
if (! is_file($path)) {
|
if (! is_file($path)) {
|
||||||
@ -281,33 +555,4 @@ final readonly class Fingerprint
|
|||||||
|
|
||||||
return array_values(array_unique($extensions));
|
return array_values(array_unique($extensions));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static function readPestVersion(string $projectRoot): string
|
|
||||||
{
|
|
||||||
$installed = $projectRoot.'/vendor/composer/installed.json';
|
|
||||||
|
|
||||||
if (! is_file($installed)) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
$raw = @file_get_contents($installed);
|
|
||||||
|
|
||||||
if ($raw === false) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
$data = json_decode($raw, true);
|
|
||||||
|
|
||||||
if (! is_array($data) || ! isset($data['packages']) || ! is_array($data['packages'])) {
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($data['packages'] as $package) {
|
|
||||||
if (is_array($package) && ($package['name'] ?? null) === 'pestphp/pest') {
|
|
||||||
return (string) ($package['version'] ?? 'unknown');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ namespace Pest\Plugins\Tia;
|
|||||||
|
|
||||||
use Pest\Support\Container;
|
use Pest\Support\Container;
|
||||||
use PHPUnit\Framework\TestStatus\TestStatus;
|
use PHPUnit\Framework\TestStatus\TestStatus;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* File-level Test Impact Analysis graph.
|
* File-level Test Impact Analysis graph.
|
||||||
@ -226,17 +227,18 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inertia page-component routing. When a Vue/React/Svelte page
|
// Inertia page-component routing. When a page under
|
||||||
// under `resources/js/Pages/` changes, map it to the component
|
// `resources/js/Pages/` changes, map it to the component name
|
||||||
// name Inertia would use (the path relative to `Pages/`, with
|
// Inertia would use (the path relative to `Pages/`, extension
|
||||||
// the extension stripped) and intersect with the captured
|
// stripped) and intersect with the captured component edges.
|
||||||
// component edges. Only invalidates tests that actually
|
// Only invalidates tests that actually rendered the page.
|
||||||
// rendered the page. Pages with no captured edges (never
|
// Pages with no captured edges (never rendered during record,
|
||||||
// rendered during record, brand-new on this branch) fall
|
// brand-new on this branch) fall through to the watch-pattern
|
||||||
// through to the watch-pattern fallback via
|
// fallback — safe over-run. Pages handled here are tracked in
|
||||||
// `$unknownPageComponents` — safe over-run.
|
// `$preciselyHandledPages` so the watch broadcast and JS-dep
|
||||||
|
// lookup don't re-route them.
|
||||||
$changedComponents = [];
|
$changedComponents = [];
|
||||||
$unknownPageComponents = [];
|
$preciselyHandledPages = [];
|
||||||
|
|
||||||
foreach ($nonMigrationPaths as $rel) {
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
$component = $this->componentForInertiaPage($rel);
|
$component = $this->componentForInertiaPage($rel);
|
||||||
@ -247,20 +249,6 @@ final class Graph
|
|||||||
|
|
||||||
if ($this->anyTestUses($this->testInertiaComponents, $component)) {
|
if ($this->anyTestUses($this->testInertiaComponents, $component)) {
|
||||||
$changedComponents[$component] = true;
|
$changedComponents[$component] = true;
|
||||||
} else {
|
|
||||||
$unknownPageComponents[] = $rel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pages whose component already resolved precisely via the
|
|
||||||
// direct Inertia edges path must not leak back through any
|
|
||||||
// broader mechanism (either the JS-dep lookup below, or the
|
|
||||||
// watch pattern further down).
|
|
||||||
$preciselyHandledPages = [];
|
|
||||||
foreach ($nonMigrationPaths as $rel) {
|
|
||||||
$component = $this->componentForInertiaPage($rel);
|
|
||||||
|
|
||||||
if ($component !== null && isset($changedComponents[$component])) {
|
|
||||||
$preciselyHandledPages[$rel] = true;
|
$preciselyHandledPages[$rel] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -297,6 +285,83 @@ final class Graph
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Orphan detection for NEW JS files. `$jsFileToComponents` is
|
||||||
|
// a record-time snapshot; files added since (a fresh Vue
|
||||||
|
// component, a new shared util, etc.) are absent from it.
|
||||||
|
// Today the broad watch pattern catches them — correct but
|
||||||
|
// pessimistic: a JS file that literally no page imports
|
||||||
|
// would still invalidate the entire browser dir.
|
||||||
|
//
|
||||||
|
// Fix: for each new JS file in the changed set, ask Vite
|
||||||
|
// (strict mode — no PHP fallback) which pages transitively
|
||||||
|
// import it. If none → orphan, suppress the broadcast. If
|
||||||
|
// some → precise union with their tests' components. The
|
||||||
|
// Node helper is the only resolver trustworthy enough to
|
||||||
|
// honour a *negative* answer (the PHP parser can silently
|
||||||
|
// miss custom aliases). When Node is unreachable we leave
|
||||||
|
// the files alone and let the watch pattern do its job.
|
||||||
|
$newJsFiles = [];
|
||||||
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
|
if (isset($preciselyHandledPages[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($sharedFilesResolved[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($this->jsFileToComponents[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (! str_starts_with($rel, 'resources/js/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$newJsFiles[] = $rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($newJsFiles !== []) {
|
||||||
|
$freshMap = JsModuleGraph::buildStrict($this->projectRoot);
|
||||||
|
|
||||||
|
if ($freshMap === null) {
|
||||||
|
// Vite resolver was unavailable (Node missing, cold-start
|
||||||
|
// timeout, vite.config refused to load). Falling back to
|
||||||
|
// the broad watch pattern is the correct call, but
|
||||||
|
// doing so silently can make a slow replay feel
|
||||||
|
// inexplicable — surface a single line so the user
|
||||||
|
// knows precision was downgraded for these files.
|
||||||
|
$output = Container::getInstance()->get(OutputInterface::class);
|
||||||
|
if ($output instanceof OutputInterface) {
|
||||||
|
$output->writeln(sprintf(
|
||||||
|
' <fg=yellow>TIA</> Vite resolver unavailable — falling back to watch pattern for %d new JS file(s).',
|
||||||
|
count($newJsFiles),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
foreach ($newJsFiles as $rel) {
|
||||||
|
$pages = $freshMap[$rel] ?? [];
|
||||||
|
|
||||||
|
if ($pages === []) {
|
||||||
|
// Vite itself says nothing imports this file.
|
||||||
|
// Safe to skip — mark handled so the watch
|
||||||
|
// pattern below doesn't re-broadcast it.
|
||||||
|
$sharedFilesResolved[$rel] = true;
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$touchedAny = false;
|
||||||
|
foreach ($pages as $pageComponent) {
|
||||||
|
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
|
||||||
|
$changedComponents[$pageComponent] = true;
|
||||||
|
$touchedAny = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($touchedAny) {
|
||||||
|
$sharedFilesResolved[$rel] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if ($changedComponents !== []) {
|
if ($changedComponents !== []) {
|
||||||
foreach ($this->testInertiaComponents as $testFile => $components) {
|
foreach ($this->testInertiaComponents as $testFile => $components) {
|
||||||
if (isset($affectedSet[$testFile])) {
|
if (isset($affectedSet[$testFile])) {
|
||||||
@ -680,9 +745,13 @@ final class Graph
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Replaces the whole JS dep map. Called at record time with the
|
* Replaces the whole JS dep map. Called at record time with the
|
||||||
* output of `JsModuleGraph::build()`. Unlike the test-level
|
* output of `JsModuleGraph::build()`. Empty input is treated as a
|
||||||
* replacements above this is a wholesale overwrite — the
|
* resolver failure (Node missing, Vite refused to load, transient
|
||||||
* resolver produces the full graph on every run.
|
* `npm install`) rather than a legitimate "no JS pages" signal —
|
||||||
|
* we keep the previous map. Stale entries for genuinely-deleted
|
||||||
|
* pages are harmless because deleted files never enter the
|
||||||
|
* changed set; over-broadcasting every JS edit through the watch
|
||||||
|
* pattern after a flaky Node run would be a real regression.
|
||||||
*
|
*
|
||||||
* @param array<string, array<int, string>> $fileToComponents
|
* @param array<string, array<int, string>> $fileToComponents
|
||||||
*/
|
*/
|
||||||
@ -711,6 +780,10 @@ final class Graph
|
|||||||
$out[$path] = $keys;
|
$out[$path] = $keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($out === []) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ksort($out);
|
ksort($out);
|
||||||
|
|
||||||
$this->jsFileToComponents = $out;
|
$this->jsFileToComponents = $out;
|
||||||
@ -779,7 +852,7 @@ final class Graph
|
|||||||
|
|
||||||
$extension = substr($tail, $dot + 1);
|
$extension = substr($tail, $dot + 1);
|
||||||
|
|
||||||
if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte'], true)) {
|
if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'], true)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -51,6 +51,24 @@ final class JsModuleGraph
|
|||||||
return JsImportParser::parse($projectRoot);
|
return JsImportParser::parse($projectRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strict variant — only runs the Node helper, never falls back to
|
||||||
|
* the PHP parser. Returns null when Node isn't available or Vite
|
||||||
|
* won't load.
|
||||||
|
*
|
||||||
|
* Used at replay time when we need to *trust a negative result*
|
||||||
|
* (i.e., "no page imports this file, so it's orphan, safe to
|
||||||
|
* skip"). The PHP fallback is conservative on positives but can
|
||||||
|
* miss imports that rely on custom aliases or plugins — negative
|
||||||
|
* results from it cannot be trusted for orphan pruning.
|
||||||
|
*
|
||||||
|
* @return array<string, list<string>>|null
|
||||||
|
*/
|
||||||
|
public static function buildStrict(string $projectRoot): ?array
|
||||||
|
{
|
||||||
|
return self::tryNodeHelper($projectRoot);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when the project looks like a Vite + Node project we can
|
* True when the project looks like a Vite + Node project we can
|
||||||
* ask for a module graph. Gate for callers that want to skip the
|
* ask for a module graph. Gate for callers that want to skip the
|
||||||
|
|||||||
@ -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;
|
||||||
@ -81,21 +100,19 @@ final class Recorder
|
|||||||
if (function_exists('pcov\\start')) {
|
if (function_exists('pcov\\start')) {
|
||||||
$this->driver = 'pcov';
|
$this->driver = 'pcov';
|
||||||
$this->driverAvailable = true;
|
$this->driverAvailable = true;
|
||||||
} elseif (function_exists('xdebug_start_code_coverage')) {
|
} elseif (function_exists('xdebug_start_code_coverage') && function_exists('xdebug_info')) {
|
||||||
// Xdebug is loaded. Probe whether coverage mode is active by
|
// Xdebug 3+ exposes the active mode set via `xdebug_info`,
|
||||||
// attempting a start — it emits E_WARNING when the mode is off.
|
// so we can ask directly instead of probing with a
|
||||||
// We capture the warning via a temporary error handler.
|
// start/stop pair. The probe approach used to emit
|
||||||
$probeOk = true;
|
// E_WARNING when coverage mode was off; with monitoring
|
||||||
set_error_handler(static function () use (&$probeOk): bool {
|
// agents (Sentry, Bugsnag) hooked into the error
|
||||||
$probeOk = false;
|
// handler stack that warning could be reported as a
|
||||||
|
// real error. `xdebug_info('mode')` is silent and
|
||||||
|
// returns the active modes as a list, so a presence
|
||||||
|
// check is enough.
|
||||||
|
$modes = \xdebug_info('mode');
|
||||||
|
|
||||||
return true;
|
if (is_array($modes) && in_array('coverage', $modes, true)) {
|
||||||
});
|
|
||||||
\xdebug_start_code_coverage();
|
|
||||||
restore_error_handler();
|
|
||||||
|
|
||||||
if ($probeOk) {
|
|
||||||
\xdebug_stop_code_coverage(false);
|
|
||||||
$this->driver = 'xdebug';
|
$this->driver = 'xdebug';
|
||||||
$this->driverAvailable = true;
|
$this->driverAvailable = true;
|
||||||
}
|
}
|
||||||
@ -128,6 +145,23 @@ 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
|
||||||
|
// file as a source dependency of this test. Captures the common
|
||||||
|
// `tests/TestCase.php` case (where the user's base may be
|
||||||
|
// trait-only and have no executable lines for the coverage
|
||||||
|
// driver to pick up), and any deeper hierarchy. Vendor parents
|
||||||
|
// are skipped — those are pinned by `composer.lock` and don't
|
||||||
|
// need per-test edges. Same idea applies to traits used by the
|
||||||
|
// ancestors: a trait's body executes when the test method
|
||||||
|
// calls into it, so coverage already captures it; we only need
|
||||||
|
// the explicit walk for ancestors whose own bodies might be
|
||||||
|
// empty.
|
||||||
|
$this->linkAncestorFiles($className);
|
||||||
|
|
||||||
if ($this->driver === 'pcov') {
|
if ($this->driver === 'pcov') {
|
||||||
\pcov\clear();
|
\pcov\clear();
|
||||||
\pcov\start();
|
\pcov\start();
|
||||||
@ -191,6 +225,79 @@ final class Recorder
|
|||||||
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
$this->perTestFiles[$this->currentTestFile][$sourceFile] = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records every project-local ancestor class's defining file as a
|
||||||
|
* source dependency of the currently-running test. PCOV / Xdebug
|
||||||
|
* record *executable lines* — a base class whose body is just
|
||||||
|
* `class TestCase extends BaseTestCase { use CreatesApplication; }`
|
||||||
|
* has no executable bytecode of its own, so the driver doesn't
|
||||||
|
* emit a line for it and it never enters the graph through the
|
||||||
|
* usual coverage path. This walk fills that gap by asking
|
||||||
|
* reflection for each parent's file and linking it explicitly.
|
||||||
|
*/
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`
|
||||||
@ -288,6 +395,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)) {
|
||||||
@ -355,7 +470,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -105,30 +105,88 @@ final class TableExtractor
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<string> Table names referenced by `Schema::` calls
|
* @return list<string> Table names referenced by `Schema::` calls,
|
||||||
* in the given migration file contents. Empty
|
* raw DDL, or DML inside the given migration
|
||||||
* when nothing matches — callers treat that
|
* file contents. Empty when nothing matches —
|
||||||
* as "fall back to the broad watch pattern".
|
* callers treat that as "fall back to the
|
||||||
|
* broad watch pattern".
|
||||||
|
*
|
||||||
|
* Three passes:
|
||||||
|
* 1. `Schema::create|table|drop|dropIfExists|dropColumn[s]|rename`
|
||||||
|
* captures the conventional Laravel migration shape.
|
||||||
|
* 2. Raw DDL fallback: scans for `CREATE / ALTER / DROP /
|
||||||
|
* TRUNCATE / RENAME TABLE <name>` patterns inside string
|
||||||
|
* literals (i.e. `DB::statement('CREATE TABLE …')`,
|
||||||
|
* `DB::unprepared('ALTER TABLE …')`).
|
||||||
|
* 3. DML inside migration bodies — `INSERT INTO`, `UPDATE … SET`,
|
||||||
|
* `DELETE FROM`, and Laravel's fluent `DB::table('foo')`.
|
||||||
|
* Catches the seeded-lookup-table case where a migration
|
||||||
|
* populates rows that tests later read.
|
||||||
|
*
|
||||||
|
* False positives possible when the same syntax appears in a
|
||||||
|
* comment or unrelated string, but over-attribution is
|
||||||
|
* correctness-safe.
|
||||||
*/
|
*/
|
||||||
public static function fromMigrationSource(string $php): array
|
public static function fromMigrationSource(string $php): array
|
||||||
{
|
{
|
||||||
$pattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
|
|
||||||
|
|
||||||
if (preg_match_all($pattern, $php, $matches) === false) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$tables = [];
|
$tables = [];
|
||||||
|
|
||||||
foreach ($matches[1] as $i => $primary) {
|
// Pass 1: Schema:: calls. `dropColumn` (singular) covers
|
||||||
// Group 1 always captures at least one char per the regex.
|
// `Schema::table('users', fn ($t) => $t->dropColumn('foo'))`
|
||||||
$tables[strtolower($primary)] = true;
|
// — the closure body's column op is on Blueprint, but the
|
||||||
|
// outer `Schema::table('users', …)` is what we capture here.
|
||||||
|
$schemaPattern = '/Schema::\s*(?:create|table|drop|dropIfExists|dropColumn|dropColumns|rename)\s*\(\s*[\'"]([^\'"]+)[\'"](?:\s*,\s*[\'"]([^\'"]+)[\'"])?/';
|
||||||
|
|
||||||
// Group 2 (`Schema::rename('old', 'new')`) is optional and
|
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
||||||
// absent from non-rename matches.
|
foreach ($matches[1] as $i => $primary) {
|
||||||
$secondary = $matches[2][$i] ?? '';
|
$tables[strtolower($primary)] = true;
|
||||||
if ($secondary !== '') {
|
|
||||||
$tables[strtolower($secondary)] = true;
|
$secondary = $matches[2][$i] ?? '';
|
||||||
|
if ($secondary !== '') {
|
||||||
|
$tables[strtolower($secondary)] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 2: raw DDL fallback. Matches the table name following
|
||||||
|
// `CREATE/ALTER/DROP/TRUNCATE/RENAME TABLE` (plus Postgres'
|
||||||
|
// `IF EXISTS` / `IF NOT EXISTS` variants), with optional
|
||||||
|
// ANSI / MySQL / SQL Server quoting.
|
||||||
|
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
|
||||||
|
|
||||||
|
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
|
||||||
|
foreach ($matches[1] as $primary) {
|
||||||
|
$lower = strtolower($primary);
|
||||||
|
if (! self::isSchemaMeta($lower)) {
|
||||||
|
$tables[$lower] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3: DML inside migration bodies. Migrations that seed
|
||||||
|
// lookup tables via `DB::statement('INSERT INTO roles …')`,
|
||||||
|
// `DB::table('statuses')->insert(…)`, `UPDATE foo SET …`, or
|
||||||
|
// `DELETE FROM bar` are common in Laravel. Without picking
|
||||||
|
// these up, an edit to the seed payload would route through
|
||||||
|
// only the schema'd tables and silently skip every test that
|
||||||
|
// reads from the populated table. Fluent-builder calls
|
||||||
|
// (`DB::table('x')`) and raw SQL strings are both covered.
|
||||||
|
$dmlPatterns = [
|
||||||
|
'/INSERT\s+(?:IGNORE\s+)?INTO\s+["`\[]?(\w+)["`\]]?/i',
|
||||||
|
'/UPDATE\s+["`\[]?(\w+)["`\]]?\s+SET\b/i',
|
||||||
|
'/DELETE\s+FROM\s+["`\[]?(\w+)["`\]]?/i',
|
||||||
|
'/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($dmlPatterns as $pattern) {
|
||||||
|
if (preg_match_all($pattern, $php, $matches) === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach ($matches[1] as $name) {
|
||||||
|
$lower = strtolower($name);
|
||||||
|
if (! self::isSchemaMeta($lower)) {
|
||||||
|
$tables[$lower] = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -43,12 +43,18 @@ final readonly class Inertia implements WatchDefault
|
|||||||
'resources/js/Pages/**/*.tsx' => [$browserDir],
|
'resources/js/Pages/**/*.tsx' => [$browserDir],
|
||||||
'resources/js/Pages/**/*.jsx' => [$browserDir],
|
'resources/js/Pages/**/*.jsx' => [$browserDir],
|
||||||
'resources/js/Pages/**/*.svelte' => [$browserDir],
|
'resources/js/Pages/**/*.svelte' => [$browserDir],
|
||||||
|
'resources/js/Pages/**/*.ts' => [$browserDir],
|
||||||
|
'resources/js/Pages/**/*.js' => [$browserDir],
|
||||||
|
|
||||||
// Shared layouts / components consumed by pages.
|
// Shared layouts / components consumed by pages.
|
||||||
'resources/js/Layouts/**/*.vue' => [$browserDir],
|
'resources/js/Layouts/**/*.vue' => [$browserDir],
|
||||||
'resources/js/Layouts/**/*.tsx' => [$browserDir],
|
'resources/js/Layouts/**/*.tsx' => [$browserDir],
|
||||||
|
'resources/js/Layouts/**/*.ts' => [$browserDir],
|
||||||
|
'resources/js/Layouts/**/*.js' => [$browserDir],
|
||||||
'resources/js/Components/**/*.vue' => [$browserDir],
|
'resources/js/Components/**/*.vue' => [$browserDir],
|
||||||
'resources/js/Components/**/*.tsx' => [$browserDir],
|
'resources/js/Components/**/*.tsx' => [$browserDir],
|
||||||
|
'resources/js/Components/**/*.ts' => [$browserDir],
|
||||||
|
'resources/js/Components/**/*.js' => [$browserDir],
|
||||||
|
|
||||||
// SSR entry point.
|
// SSR entry point.
|
||||||
'resources/js/ssr.js' => [$browserDir],
|
'resources/js/ssr.js' => [$browserDir],
|
||||||
|
|||||||
@ -43,6 +43,21 @@ final readonly class Php implements WatchDefault
|
|||||||
// tracked by the coverage driver.
|
// tracked by the coverage driver.
|
||||||
'phpunit.xml.dist' => [$testPath],
|
'phpunit.xml.dist' => [$testPath],
|
||||||
|
|
||||||
|
// `tests/Pest.php` is loaded once per suite (during BootFiles)
|
||||||
|
// so its `pest()->extend()`, `expect()->extend()`, helpers,
|
||||||
|
// etc. execute outside the per-test coverage window — no
|
||||||
|
// edge captures it. Watch-pattern broadcast triggers a
|
||||||
|
// replay of every test (results refresh) without a full
|
||||||
|
// record-mode graph rebuild.
|
||||||
|
$testPath.'/Pest.php' => [$testPath],
|
||||||
|
|
||||||
|
// Pest dataset definitions are loaded once at boot, outside
|
||||||
|
// the per-test coverage window — no edge captures them. A
|
||||||
|
// change to a shared dataset can flip the result of any test
|
||||||
|
// that uses it, so broadcast every dataset edit to the full
|
||||||
|
// suite.
|
||||||
|
$testPath.'/Datasets/**/*.php' => [$testPath],
|
||||||
|
|
||||||
// Test fixtures — JSON, CSV, XML, TXT data files consumed by
|
// Test fixtures — JSON, CSV, XML, TXT data files consumed by
|
||||||
// assertions. A fixture change can flip a test result.
|
// assertions. A fixture change can flip a test result.
|
||||||
$testPath.'/Fixtures/**/*.json' => [$testPath],
|
$testPath.'/Fixtures/**/*.json' => [$testPath],
|
||||||
|
|||||||
Reference in New Issue
Block a user