mirror of
https://github.com/pestphp/pest.git
synced 2026-07-24 10:30:03 +02:00
fix: tia engine correctness issues
- keep table/Inertia/database link-tracking alive in piggyback-coverage recording, and merge link-only edges into the piggybacked edge set - route migration changes through the watch-pattern fallback when the graph has no recorded table usage - re-run cached failures whose test file cannot be located, downgrading filtered runs to full replay instead of silently skipping them - align non-filtered replay with the failOn*/displayDetailsOn* rerun policy - resolve anonymous index Blade components (<x-card> from card/index.blade.php) and stop swallowing components whose static usage walk selected no tests - parse CTE/REPLACE queries and schema-qualified identifiers in TableExtractor - scope-filter the xdebug recording path like pcov - ignore in-flight .tmp files in FileState::keysWithPrefix and delete unreadable worker partials - vite deps helper: resolve rolldown through rolldown-vite installs and degrade gracefully, honour vite.config resolve.alias (incl. the laravel-vite-plugin '@' default), resolve .vue/.svelte/.mts/.cts extensions, and stop memoizing cycle-tainted transitive sets Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+38
-2
@@ -296,7 +296,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$result = $this->replayGraph->getResult($this->branch, $testId);
|
||||
|
||||
if ($result instanceof TestStatus) {
|
||||
if ($result->isFailure() || $result->isError()) {
|
||||
if ($this->replayGraph->shouldRerunStatus($result)) {
|
||||
$this->executedCount++;
|
||||
|
||||
return null;
|
||||
@@ -402,7 +402,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
|
||||
$projectRoot = TestSuite::getInstance()->rootPath;
|
||||
$perTest = $this->piggybackCoverage
|
||||
? $this->coverageCollector->perTestFiles()
|
||||
? $this->mergePerTestFiles($this->coverageCollector->perTestFiles(), $recorder->perTestFiles())
|
||||
: $recorder->perTestFiles();
|
||||
|
||||
if ($perTest === []) {
|
||||
@@ -715,6 +715,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
if ($this->piggybackCoverage) {
|
||||
$this->recorder->activateLinkTracking();
|
||||
$this->recordingActive = true;
|
||||
|
||||
return $arguments;
|
||||
@@ -782,6 +783,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
private function activateWorkerRecorderForReplay(array $arguments): array
|
||||
{
|
||||
if ($this->piggybackCoverage) {
|
||||
$this->recorder->activateLinkTracking();
|
||||
$this->recordingActive = true;
|
||||
|
||||
return $arguments;
|
||||
@@ -834,6 +836,13 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$affectedFromChanges = $changed === [] ? [] : $graph->affected($changed);
|
||||
$rerunFromCache = [];
|
||||
|
||||
if ($this->filteredMode && $graph->hasUnlocatedTestsToRerun($this->branch)) {
|
||||
$this->filteredMode = false;
|
||||
|
||||
$this->renderBadge('WARN', 'Some cached tests due a re-run could not be located on disk.');
|
||||
$this->renderChild('Running the full suite with replay instead of a filtered run.');
|
||||
}
|
||||
|
||||
if ($this->filteredMode) {
|
||||
$rerunFromCache = $graph->testFilesToRerun($this->branch);
|
||||
}
|
||||
@@ -1014,6 +1023,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
}
|
||||
|
||||
if ($this->piggybackCoverage) {
|
||||
$recorder->activateLinkTracking();
|
||||
$this->recordingActive = true;
|
||||
|
||||
$this->output->writeln('');
|
||||
@@ -1235,6 +1245,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$data = $this->readPartial($key);
|
||||
|
||||
if ($data === null) {
|
||||
$this->state->delete($key);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1368,6 +1380,26 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
$this->saveGraph($graph);
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of two per-test edge maps — piggybacked line-coverage edges plus
|
||||
* the recorder's link-tracked edges (rendered Blade views, ...), which
|
||||
* never appear in line coverage.
|
||||
*
|
||||
* @param array<string, array<int, string>> $coverage
|
||||
* @param array<string, array<int, string>> $linked
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
private function mergePerTestFiles(array $coverage, array $linked): array
|
||||
{
|
||||
foreach ($linked as $testFile => $sources) {
|
||||
$existing = $coverage[$testFile] ?? [];
|
||||
|
||||
$coverage[$testFile] = array_values(array_unique([...$existing, ...$sources]));
|
||||
}
|
||||
|
||||
return $coverage;
|
||||
}
|
||||
|
||||
private function seedResultsInto(Graph $graph): void
|
||||
{
|
||||
/** @var ResultCollector $collector */
|
||||
@@ -1379,6 +1411,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
||||
foreach ($results as $testId => $result) {
|
||||
$file = $result['file'] ?? null;
|
||||
|
||||
if ($file === null || str_contains($file, "eval()'d")) {
|
||||
$file = $this->resolveFailedTestFile($testId);
|
||||
}
|
||||
|
||||
if (is_string($file) && $file !== '') {
|
||||
$touchedFiles[$file] = true;
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ final class FileState implements State
|
||||
$keys = [];
|
||||
|
||||
foreach ($matches as $path) {
|
||||
if (str_ends_with($path, '.tmp')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$keys[] = basename($path);
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,13 @@ final class Graph
|
||||
*/
|
||||
private function applyMigrationChanges(array $migrationPaths, array &$affectedSet): array
|
||||
{
|
||||
// With no recorded table usage at all, table intersection can never
|
||||
// select anything — route every migration change through the
|
||||
// watch-pattern fallback instead of silently skipping tests.
|
||||
if ($this->testTables === []) {
|
||||
return $migrationPaths;
|
||||
}
|
||||
|
||||
$changedTables = [];
|
||||
$unparseable = [];
|
||||
|
||||
@@ -446,13 +453,14 @@ final class Graph
|
||||
|
||||
$bladeAffected = $this->affectedByStaticBladeUsage($rel);
|
||||
|
||||
// Only a walk that actually selected tests counts as handled — a
|
||||
// component whose usage the static walk missed must still reach
|
||||
// the watch-pattern fallback instead of being silently swallowed.
|
||||
if ($bladeAffected !== []) {
|
||||
foreach ($bladeAffected as $testFile) {
|
||||
$affectedSet[$testFile] = true;
|
||||
}
|
||||
|
||||
$staticallyHandled[$rel] = true;
|
||||
} elseif ($this->isBladeComponentPath($rel)) {
|
||||
$staticallyHandled[$rel] = true;
|
||||
}
|
||||
}
|
||||
@@ -690,8 +698,16 @@ final class Graph
|
||||
|
||||
private function shouldRerun(int $status): bool
|
||||
{
|
||||
$testStatus = TestStatus::from($status);
|
||||
return $this->shouldRerunStatus(TestStatus::from($status));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a cached result with this status must be re-executed rather
|
||||
* than replayed, honouring the configured failOn* / displayDetailsOn*
|
||||
* policies.
|
||||
*/
|
||||
public function shouldRerunStatus(TestStatus $testStatus): bool
|
||||
{
|
||||
if ($testStatus->isFailure() || $testStatus->isError()) {
|
||||
return true;
|
||||
}
|
||||
@@ -1249,7 +1265,21 @@ final class Graph
|
||||
$tail = substr($tail, 0, -strlen('.blade.php'));
|
||||
$name = str_replace('/', '.', $tail);
|
||||
|
||||
return $name === '' ? [] : [$name, str_replace('_', '-', $name)];
|
||||
if ($name === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$names = [$name, str_replace('_', '-', $name)];
|
||||
|
||||
// Anonymous index components: components/card/index.blade.php resolves as <x-card>.
|
||||
if (str_ends_with($name, '.index') && $name !== '.index') {
|
||||
$base = substr($name, 0, -strlen('.index'));
|
||||
|
||||
$names[] = $base;
|
||||
$names[] = str_replace('_', '-', $base);
|
||||
}
|
||||
|
||||
return array_values(array_unique($names));
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
|
||||
@@ -163,7 +163,9 @@ final class JsModuleGraph
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
|
||||
$nodeModules = $projectRoot.DIRECTORY_SEPARATOR.'node_modules';
|
||||
|
||||
if (! is_dir($nodeModules.DIRECTORY_SEPARATOR.'vite') && ! is_dir($nodeModules.DIRECTORY_SEPARATOR.'rolldown')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,8 @@ final class Recorder
|
||||
|
||||
private bool $active = false;
|
||||
|
||||
private bool $captureCoverage = false;
|
||||
|
||||
private bool $driverChecked = false;
|
||||
|
||||
private bool $driverAvailable = false;
|
||||
@@ -43,6 +45,17 @@ final class Recorder
|
||||
private ?SourceScope $sourceScope = null;
|
||||
|
||||
public function activate(): void
|
||||
{
|
||||
$this->active = true;
|
||||
$this->captureCoverage = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable per-test link tracking (tables, Inertia components, database
|
||||
* usage, rendered views) without driving pcov/xdebug — for runs where
|
||||
* coverage edges are piggybacked from an existing PHPUnit coverage session.
|
||||
*/
|
||||
public function activateLinkTracking(): void
|
||||
{
|
||||
$this->active = true;
|
||||
}
|
||||
@@ -75,7 +88,11 @@ final class Recorder
|
||||
|
||||
public function beginTest(string $className, string $methodName, string $fallbackFile): void
|
||||
{
|
||||
if (! $this->active || ! $this->driverAvailable()) {
|
||||
if (! $this->active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->captureCoverage && ! $this->driverAvailable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -95,6 +112,10 @@ final class Recorder
|
||||
$this->perTestUsesDatabase[$file] = true;
|
||||
}
|
||||
|
||||
if (! $this->captureCoverage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->driver === 'pcov') {
|
||||
\pcov\clear();
|
||||
\pcov\start();
|
||||
@@ -107,7 +128,13 @@ final class Recorder
|
||||
|
||||
public function endTest(): void
|
||||
{
|
||||
if (! $this->active || ! $this->driverAvailable() || $this->currentTestFile === null) {
|
||||
if (! $this->active || $this->currentTestFile === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->captureCoverage || ! $this->driverAvailable()) {
|
||||
$this->currentTestFile = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -132,7 +159,15 @@ final class Recorder
|
||||
$data = \xdebug_get_code_coverage();
|
||||
\xdebug_stop_code_coverage(true);
|
||||
|
||||
$coveredFiles = array_keys($data);
|
||||
$scope = $this->sourceScope();
|
||||
|
||||
foreach (array_keys($data) as $file) {
|
||||
if (! $scope->contains($file)) {
|
||||
unset($data[$file]);
|
||||
}
|
||||
}
|
||||
|
||||
$coveredFiles = $this->filesWithExecutedLines($data);
|
||||
}
|
||||
|
||||
foreach ($coveredFiles as $sourceFile) {
|
||||
@@ -351,5 +386,6 @@ final class Recorder
|
||||
$this->classUsesDatabaseCache = [];
|
||||
$this->sourceScope = null;
|
||||
$this->active = false;
|
||||
$this->captureCoverage = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,12 @@ namespace Pest\Plugins\Tia;
|
||||
*/
|
||||
final class TableExtractor
|
||||
{
|
||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete'];
|
||||
private const array DML_PREFIXES = ['select', 'insert', 'update', 'delete', 'with', 'replace'];
|
||||
|
||||
/**
|
||||
* A single (optionally quoted) identifier segment.
|
||||
*/
|
||||
private const string IDENTIFIER = '(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|\w+)';
|
||||
|
||||
/**
|
||||
* @return list<string> Sorted, deduped table names referenced by the
|
||||
@@ -22,14 +27,15 @@ final class TableExtractor
|
||||
return [];
|
||||
}
|
||||
|
||||
$prefix = strtolower(substr($trimmed, 0, 6));
|
||||
$matched = array_any(self::DML_PREFIXES, fn (string $dml): bool => str_starts_with($prefix, $dml));
|
||||
|
||||
if (! $matched) {
|
||||
if (preg_match('/^[a-zA-Z]+/', $trimmed, $prefixMatch) !== 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pattern = '/(?:\bfrom|\binto|\bupdate|\bjoin)\s+(?:"([^"]+)"|`([^`]+)`|\[([^\]]+)\]|(\w+))/i';
|
||||
if (! in_array(strtolower($prefixMatch[0]), self::DML_PREFIXES, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$pattern = '/\b(?:from|into|update|join)\s+('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)/i';
|
||||
|
||||
if (preg_match_all($pattern, $sql, $matches) === false) {
|
||||
return [];
|
||||
@@ -37,14 +43,9 @@ final class TableExtractor
|
||||
|
||||
$tables = [];
|
||||
|
||||
for ($i = 0, $n = count($matches[0]); $i < $n; $i++) {
|
||||
$name = $matches[1][$i] !== ''
|
||||
? $matches[1][$i]
|
||||
: ($matches[2][$i] !== ''
|
||||
? $matches[2][$i]
|
||||
: ($matches[3][$i] !== ''
|
||||
? $matches[3][$i]
|
||||
: $matches[4][$i]));
|
||||
foreach ($matches[1] as $qualified) {
|
||||
$name = self::unqualified($qualified);
|
||||
|
||||
if ($name === '') {
|
||||
continue;
|
||||
}
|
||||
@@ -72,40 +73,40 @@ final class TableExtractor
|
||||
|
||||
if (preg_match_all($schemaPattern, $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $i => $primary) {
|
||||
$tables[strtolower($primary)] = true;
|
||||
$tables[strtolower(self::lastDottedSegment($primary))] = true;
|
||||
|
||||
$secondary = $matches[2][$i] ?? '';
|
||||
if ($secondary !== '') {
|
||||
$tables[strtolower($secondary)] = true;
|
||||
$tables[strtolower(self::lastDottedSegment($secondary))] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$ddlPattern = '/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+["`\[]?(\w+)["`\]]?/i';
|
||||
$qualified = '('.self::IDENTIFIER.'(?:\s*\.\s*'.self::IDENTIFIER.')*)';
|
||||
|
||||
if (preg_match_all($ddlPattern, $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $primary) {
|
||||
$lower = strtolower($primary);
|
||||
if (! self::isSchemaMeta($lower)) {
|
||||
$sqlPatterns = [
|
||||
'/(?:CREATE|ALTER|DROP|TRUNCATE|RENAME)\s+TABLE(?:\s+IF\s+(?:NOT\s+)?EXISTS)?\s+'.$qualified.'/i',
|
||||
'/INSERT\s+(?:IGNORE\s+)?INTO\s+'.$qualified.'/i',
|
||||
'/UPDATE\s+'.$qualified.'\s+SET\b/i',
|
||||
'/DELETE\s+FROM\s+'.$qualified.'/i',
|
||||
];
|
||||
|
||||
foreach ($sqlPatterns as $pattern) {
|
||||
if (preg_match_all($pattern, $php, $matches) === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($matches[1] as $name) {
|
||||
$lower = strtolower(self::unqualified($name));
|
||||
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||
$tables[$lower] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
if (preg_match_all('/DB::table\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $php, $matches) !== false) {
|
||||
foreach ($matches[1] as $name) {
|
||||
$lower = strtolower($name);
|
||||
if (! self::isSchemaMeta($lower)) {
|
||||
$lower = strtolower(self::lastDottedSegment($name));
|
||||
if ($lower !== '' && ! self::isSchemaMeta($lower)) {
|
||||
$tables[$lower] = true;
|
||||
}
|
||||
}
|
||||
@@ -117,6 +118,39 @@ final class TableExtractor
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The table segment of a possibly schema-qualified identifier chain,
|
||||
* e.g. `"public"."users"` or `analytics.events` yield `users` / `events`.
|
||||
* Empty when any segment is schema metadata (`information_schema.tables`, ...).
|
||||
*/
|
||||
private static function unqualified(string $qualified): string
|
||||
{
|
||||
$name = '';
|
||||
|
||||
foreach (explode('.', $qualified) as $segment) {
|
||||
$segment = trim($segment, " \t\n\r\"`[]");
|
||||
|
||||
if ($segment === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::isSchemaMeta($segment)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$name = $segment;
|
||||
}
|
||||
|
||||
return $name;
|
||||
}
|
||||
|
||||
private static function lastDottedSegment(string $name): string
|
||||
{
|
||||
$position = strrpos($name, '.');
|
||||
|
||||
return $position === false ? $name : substr($name, $position + 1);
|
||||
}
|
||||
|
||||
private static function isSchemaMeta(string $name): bool
|
||||
{
|
||||
$lower = strtolower($name);
|
||||
|
||||
Reference in New Issue
Block a user