From 81eacd79fd028bbed26e398335570f67599e4222 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Sat, 18 Jul 2026 02:50:20 +0100 Subject: [PATCH] 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 ( 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 --- bin/pest-tia-vite-deps.mjs | 111 ++++++++++++++++++- src/Plugins/Tia.php | 40 ++++++- src/Plugins/Tia/FileState.php | 4 + src/Plugins/Tia/Graph.php | 38 ++++++- src/Plugins/Tia/JsModuleGraph.php | 4 +- src/Plugins/Tia/Recorder.php | 42 ++++++- src/Plugins/Tia/TableExtractor.php | 102 +++++++++++------ tests/Unit/Plugins/Tia/FileState.php | 42 +++++++ tests/Unit/Plugins/Tia/Graph.php | 127 ++++++++++++++++++++++ tests/Unit/Plugins/Tia/Recorder.php | 61 +++++++++++ tests/Unit/Plugins/Tia/TableExtractor.php | 96 ++++++++++++++++ tests/Unit/Plugins/Tia/ViteDepsHelper.php | 111 +++++++++++++++++++ 12 files changed, 728 insertions(+), 50 deletions(-) create mode 100644 tests/Unit/Plugins/Tia/FileState.php create mode 100644 tests/Unit/Plugins/Tia/Recorder.php create mode 100644 tests/Unit/Plugins/Tia/TableExtractor.php diff --git a/bin/pest-tia-vite-deps.mjs b/bin/pest-tia-vite-deps.mjs index 32fb6fd8..031a7cdb 100644 --- a/bin/pest-tia-vite-deps.mjs +++ b/bin/pest-tia-vite-deps.mjs @@ -25,7 +25,21 @@ const PAGE_DIR_CANDIDATES = [ async function loadRolldown() { const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json')) - const path = projectRequire.resolve('rolldown') + let path = null + + try { path = projectRequire.resolve('rolldown') } catch {} + + if (path === null) { + // rolldown-vite installs (vite@npm:rolldown-vite) ship rolldown as a + // dependency of the vite package rather than a top-level install. + try { + const viteRequire = createRequire(projectRequire.resolve('vite/package.json')) + path = viteRequire.resolve('rolldown') + } catch {} + } + + if (path === null) return null + return await import(pathToFileURL(path).href) } @@ -90,6 +104,74 @@ export async function loadAliasFromTsconfig(projectRoot = PROJECT_ROOT) { return alias } +const VITE_CONFIG_FILES = [ + 'vite.config.ts', + 'vite.config.js', + 'vite.config.mjs', + 'vite.config.cjs', + 'vite.config.mts', + 'vite.config.cts', +] + +function resolveAliasTarget(projectRoot, target) { + if (target.startsWith('/')) { + // Vite resolves a leading slash against the project root, unless the + // config author used a genuinely absolute path. + return existsSync(target) ? target : resolve(projectRoot, '.' + target) + } + + return resolve(projectRoot, target) +} + +async function usesLaravelVitePlugin(projectRoot) { + const p = join(projectRoot, 'package.json') + if (!existsSync(p)) return false + + try { + const pkg = JSON.parse(await readFile(p, 'utf8')) + return Boolean(pkg.dependencies?.['laravel-vite-plugin'] ?? pkg.devDependencies?.['laravel-vite-plugin']) + } catch { + return false + } +} + +export async function loadAliasFromViteConfig(projectRoot = PROJECT_ROOT) { + const alias = {} + let source = null + + for (const name of VITE_CONFIG_FILES) { + const p = join(projectRoot, name) + if (!existsSync(p)) continue + try { source = await readFile(p, 'utf8') } catch { continue } + break + } + + if (source !== null) { + // The config is executable code we cannot import safely, so extract alias + // entries textually: a quoted `@…`/`~…` key, then the last quoted path in + // the value expression — covers `'@': '/resources/js'` as well as + // `'@': path.resolve(__dirname, 'resources/js')`. The value stops at a + // top-level comma; call arguments are kept via the paren group. + for (const m of source.matchAll(/['"`]([@~][\w./-]*)['"`]\s*:\s*((?:\([^)\n]*\)|[^,\n])*)/g)) { + const key = m[1] + if (alias[key] !== undefined) continue + + const paths = [...m[2].matchAll(/['"`]([^'"`]+)['"`]/g)].map((p) => p[1]) + const target = paths.length > 0 ? paths[paths.length - 1] : null + if (!target || target.includes('*')) continue + + alias[key] = resolveAliasTarget(projectRoot, target) + } + } + + // laravel-vite-plugin registers '@' → resources/js by default. + if (alias['@'] === undefined && (await usesLaravelVitePlugin(projectRoot))) { + alias['@'] = resolve(projectRoot, 'resources/js') + } + + return alias +} + async function listPageFiles(pagesDir) { if (!existsSync(pagesDir)) return [] @@ -153,8 +235,16 @@ async function main() { return } - const { rolldown } = await loadRolldown() - const alias = await loadAliasFromTsconfig() + const loaded = await loadRolldown() + + if (loaded === null) { + process.stdout.write('{}') + return + } + + const { rolldown } = loaded + // The vite config is what the dev server actually resolves with — let it win. + const alias = { ...(await loadAliasFromTsconfig()), ...(await loadAliasFromViteConfig()) } const aliasKeys = Object.keys(alias) const graph = new Map() @@ -199,7 +289,7 @@ async function main() { cwd: PROJECT_ROOT, resolve: { alias, - extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'], + extensions: ['.tsx', '.ts', '.jsx', '.js', '.mts', '.cts', '.mjs', '.cjs', '.json', '.vue', '.svelte'], }, transform: { jsx: 'preserve' }, treeshake: false, @@ -224,6 +314,11 @@ async function main() { stack.add(id) const acc = new Set() + // A set computed while skipping an in-stack (cyclic) dependency is missing + // that dependency's subtree and must not be memoized — the ancestor call + // completes it for the current traversal, but a cached copy would leak the + // incomplete set into other pages' traversals. + let complete = true const deps = graph.get(id) if (deps) { for (const dep of deps) { @@ -232,13 +327,17 @@ async function main() { const rel = relative(PROJECT_ROOT, dep).split(sep).join('/') acc.add(rel) } - if (stack.has(dep)) continue + if (stack.has(dep)) { + complete = false + continue + } const child = computeTransitive(dep, stack) if (child) for (const r of child) acc.add(r) + if (!transitiveCache.has(dep)) complete = false } } stack.delete(id) - transitiveCache.set(id, acc) + if (complete) transitiveCache.set(id, acc) return acc } diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index dc0df77f..401d6f0c 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -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> $coverage + * @param array> $linked + * @return array> + */ + 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; } diff --git a/src/Plugins/Tia/FileState.php b/src/Plugins/Tia/FileState.php index 91dc7892..c8618da6 100644 --- a/src/Plugins/Tia/FileState.php +++ b/src/Plugins/Tia/FileState.php @@ -89,6 +89,10 @@ final class FileState implements State $keys = []; foreach ($matches as $path) { + if (str_ends_with($path, '.tmp')) { + continue; + } + $keys[] = basename($path); } diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 1f72f67f..420fe54a 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -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 . + 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 */ diff --git a/src/Plugins/Tia/JsModuleGraph.php b/src/Plugins/Tia/JsModuleGraph.php index e4c2dc20..4d318e4c 100644 --- a/src/Plugins/Tia/JsModuleGraph.php +++ b/src/Plugins/Tia/JsModuleGraph.php @@ -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; } diff --git a/src/Plugins/Tia/Recorder.php b/src/Plugins/Tia/Recorder.php index 0240aa9f..56b91690 100644 --- a/src/Plugins/Tia/Recorder.php +++ b/src/Plugins/Tia/Recorder.php @@ -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; } } diff --git a/src/Plugins/Tia/TableExtractor.php b/src/Plugins/Tia/TableExtractor.php index 0a5052b8..06a41b91 100644 --- a/src/Plugins/Tia/TableExtractor.php +++ b/src/Plugins/Tia/TableExtractor.php @@ -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 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); diff --git a/tests/Unit/Plugins/Tia/FileState.php b/tests/Unit/Plugins/Tia/FileState.php new file mode 100644 index 00000000..a03017a5 --- /dev/null +++ b/tests/Unit/Plugins/Tia/FileState.php @@ -0,0 +1,42 @@ +root = sys_get_temp_dir().'/pest-tia-file-state-'.bin2hex(random_bytes(4)); + mkdir($this->root, 0755, true); +}); + +afterEach(function (): void { + foreach (glob($this->root.'/*') ?: [] as $file) { + @unlink($file); + } + + @rmdir($this->root); +}); + +describe('keysWithPrefix()', function (): void { + it('lists keys matching the prefix', function (): void { + $state = new FileState($this->root); + $state->write('worker-edges-a.json', '{}'); + $state->write('worker-edges-b.json', '{}'); + $state->write('worker-results-a.json', '{}'); + + $keys = $state->keysWithPrefix('worker-edges-'); + sort($keys); + + expect($keys)->toBe(['worker-edges-a.json', 'worker-edges-b.json']); + }); + + it('ignores in-flight temporary files from concurrent writes', function (): void { + $state = new FileState($this->root); + $state->write('worker-edges-a.json', '{}'); + + // Simulate another process mid-write: its temp file exists but has not been renamed yet. + file_put_contents($this->root.'/worker-edges-b.json.'.bin2hex(random_bytes(4)).'.tmp', '{'); + + expect($state->keysWithPrefix('worker-edges-'))->toBe(['worker-edges-a.json']); + }); +}); diff --git a/tests/Unit/Plugins/Tia/Graph.php b/tests/Unit/Plugins/Tia/Graph.php index 786c4171..443f19cc 100644 --- a/tests/Unit/Plugins/Tia/Graph.php +++ b/tests/Unit/Plugins/Tia/Graph.php @@ -3,6 +3,133 @@ declare(strict_types=1); use Pest\Plugins\Tia\Graph; +use Pest\Plugins\Tia\WatchPatterns; +use Pest\Support\Container; +use PHPUnit\Framework\TestStatus\TestStatus; + +describe('shouldRerunStatus()', function (): void { + it('re-runs failures and errors, replays successes', function (): void { + $graph = new Graph(sys_get_temp_dir()); + + expect($graph->shouldRerunStatus(TestStatus::failure('boom')))->toBeTrue() + ->and($graph->shouldRerunStatus(TestStatus::error('boom')))->toBeTrue() + ->and($graph->shouldRerunStatus(TestStatus::success()))->toBeFalse(); + }); +}); + +describe('applyMigrationChanges()', function (): void { + beforeEach(function (): void { + $this->projectRoot = sys_get_temp_dir().'/pest-tia-graph-'.bin2hex(random_bytes(4)); + mkdir($this->projectRoot.'/database/migrations', 0755, true); + file_put_contents( + $this->projectRoot.'/database/migrations/2024_01_01_000000_create_orders_table.php', + "watchPatterns = new WatchPatterns; + Container::getInstance()->add(WatchPatterns::class, $this->watchPatterns); + }); + + afterEach(function (): void { + @unlink($this->projectRoot.'/database/migrations/2024_01_01_000000_create_orders_table.php'); + @rmdir($this->projectRoot.'/database/migrations'); + @rmdir($this->projectRoot.'/database'); + @rmdir($this->projectRoot); + + Container::getInstance()->add(WatchPatterns::class, new WatchPatterns); + }); + + it('selects tests whose recorded tables intersect the changed migration', function (): void { + $graph = new Graph($this->projectRoot); + $graph->link('tests/Feature/OrderTest.php', 'app/Models/Order.php'); + $graph->link('tests/Feature/UserTest.php', 'app/Models/User.php'); + $graph->replaceTestTables([ + 'tests/Feature/OrderTest.php' => ['orders'], + 'tests/Feature/UserTest.php' => ['users'], + ]); + + $affected = $graph->affected(['database/migrations/2024_01_01_000000_create_orders_table.php']); + + expect($affected)->toBe(['tests/Feature/OrderTest.php']); + }); + + it('falls back to watch patterns when no table usage was recorded at all', function (): void { + $this->watchPatterns->add(['database/migrations/**' => 'tests/Feature']); + + $graph = new Graph($this->projectRoot); + $graph->link('tests/Feature/OrderTest.php', 'app/Models/Order.php'); + + $affected = $graph->affected(['database/migrations/2024_01_01_000000_create_orders_table.php']); + + expect($affected)->toBe(['tests/Feature/OrderTest.php']); + }); +}); + +describe('rerun tracking', function (): void { + it('reruns cached failures via their file', function (): void { + $graph = new Graph(sys_get_temp_dir()); + $graph->setResult('main', 'Tests\FooTest::it fails', 7, 'boom', 0.1, 1, 'tests/Feature/FooTest.php'); + $graph->setResult('main', 'Tests\BarTest::it passes', 0, '', 0.1, 1, 'tests/Feature/BarTest.php'); + + expect($graph->testFilesToRerun('main'))->toBe(['tests/Feature/FooTest.php']) + ->and($graph->hasUnlocatedTestsToRerun('main'))->toBeFalse(); + }); + + it('flags cached failures whose file is unknown', function (): void { + $graph = new Graph(sys_get_temp_dir()); + $graph->setResult('main', 'Tests\EvalTest::it fails', 7, 'boom', 0.1, 1); + + expect($graph->testFilesToRerun('main'))->toBeEmpty() + ->and($graph->hasUnlocatedTestsToRerun('main'))->toBeTrue(); + }); +}); + +describe('applyBladeStaticChanges()', function (): void { + beforeEach(function (): void { + $this->projectRoot = sys_get_temp_dir().'/pest-tia-blade-'.bin2hex(random_bytes(4)); + mkdir($this->projectRoot.'/resources/views/components/card', 0755, true); + + file_put_contents($this->projectRoot.'/resources/views/page.blade.php', '
'); + file_put_contents($this->projectRoot.'/resources/views/components/card/index.blade.php', '
{{ $slot }}
'); + file_put_contents($this->projectRoot.'/resources/views/components/unused.blade.php', '
never referenced
'); + + $this->watchPatterns = new WatchPatterns; + Container::getInstance()->add(WatchPatterns::class, $this->watchPatterns); + }); + + afterEach(function (): void { + @unlink($this->projectRoot.'/resources/views/page.blade.php'); + @unlink($this->projectRoot.'/resources/views/components/card/index.blade.php'); + @unlink($this->projectRoot.'/resources/views/components/unused.blade.php'); + @rmdir($this->projectRoot.'/resources/views/components/card'); + @rmdir($this->projectRoot.'/resources/views/components'); + @rmdir($this->projectRoot.'/resources/views'); + @rmdir($this->projectRoot.'/resources'); + @rmdir($this->projectRoot); + + Container::getInstance()->add(WatchPatterns::class, new WatchPatterns); + }); + + it('maps an anonymous index component to the views that render it', function (): void { + $graph = new Graph($this->projectRoot); + $graph->link('tests/Feature/PageTest.php', 'resources/views/page.blade.php'); + + $affected = $graph->affected(['resources/views/components/card/index.blade.php']); + + expect($affected)->toBe(['tests/Feature/PageTest.php']); + }); + + it('falls back to watch patterns for components with no matched usage', function (): void { + $this->watchPatterns->add(['resources/views/**' => 'tests/Feature']); + + $graph = new Graph($this->projectRoot); + $graph->link('tests/Feature/PageTest.php', 'resources/views/page.blade.php'); + + $affected = $graph->affected(['resources/views/components/unused.blade.php']); + + expect($affected)->toBe(['tests/Feature/PageTest.php']); + }); +}); describe('markKnownTestFiles()', function (): void { it('makes a test file with no edges known', function (): void { diff --git a/tests/Unit/Plugins/Tia/Recorder.php b/tests/Unit/Plugins/Tia/Recorder.php new file mode 100644 index 00000000..d5a8acf4 --- /dev/null +++ b/tests/Unit/Plugins/Tia/Recorder.php @@ -0,0 +1,61 @@ +activateLinkTracking(); + + $recorder->beginTest('Some\Missing\TestClass', 'it does things', '/project/tests/Feature/OrderTest.php'); + $recorder->linkTable('orders'); + $recorder->linkTable('users'); + $recorder->endTest(); + + expect($recorder->perTestTables()) + ->toBe(['/project/tests/Feature/OrderTest.php' => ['orders', 'users']]); + }); + + it('tracks inertia components without a coverage driver', function (): void { + $recorder = new Recorder; + $recorder->activateLinkTracking(); + + $recorder->beginTest('Some\Missing\TestClass', 'it renders', '/project/tests/Feature/DashboardTest.php'); + $recorder->linkInertiaComponent('Dashboard/Index'); + $recorder->endTest(); + + expect($recorder->perTestInertiaComponents()) + ->toBe(['/project/tests/Feature/DashboardTest.php' => ['Dashboard/Index']]); + }); + + it('tracks linked sources across consecutive tests', function (): void { + $recorder = new Recorder; + $recorder->activateLinkTracking(); + + $recorder->beginTest('Some\Missing\TestClass', 'first', '/project/tests/Feature/FirstTest.php'); + $recorder->linkSource('/project/resources/views/welcome.blade.php'); + $recorder->endTest(); + + // A second test must start cleanly — endTest resets state even with no driver. + $recorder->beginTest('Some\Missing\TestClass', 'second', '/project/tests/Feature/SecondTest.php'); + $recorder->linkSource('/project/resources/views/about.blade.php'); + $recorder->endTest(); + + expect($recorder->perTestFiles())->toBe([ + '/project/tests/Feature/FirstTest.php' => ['/project/resources/views/welcome.blade.php'], + '/project/tests/Feature/SecondTest.php' => ['/project/resources/views/about.blade.php'], + ]); + }); + + it('records nothing while inactive', function (): void { + $recorder = new Recorder; + + $recorder->beginTest('Some\Missing\TestClass', 'it does things', '/project/tests/Feature/OrderTest.php'); + $recorder->linkTable('orders'); + $recorder->endTest(); + + expect($recorder->perTestTables())->toBeEmpty(); + }); +}); diff --git a/tests/Unit/Plugins/Tia/TableExtractor.php b/tests/Unit/Plugins/Tia/TableExtractor.php new file mode 100644 index 00000000..b30c2a02 --- /dev/null +++ b/tests/Unit/Plugins/Tia/TableExtractor.php @@ -0,0 +1,96 @@ +toBe(['users']) + ->and(TableExtractor::fromSql('INSERT INTO orders (id) VALUES (1)'))->toBe(['orders']) + ->and(TableExtractor::fromSql('UPDATE posts SET title = ?'))->toBe(['posts']) + ->and(TableExtractor::fromSql('DELETE FROM sessions WHERE id = ?'))->toBe(['sessions']); + }); + + it('extracts tables from joins', function (): void { + expect(TableExtractor::fromSql('select * from orders join users on users.id = orders.user_id')) + ->toBe(['orders', 'users']); + }); + + it('extracts tables from CTE queries', function (): void { + $sql = 'WITH recent AS (SELECT * FROM orders WHERE created_at > ?) SELECT * FROM recent JOIN users ON users.id = recent.user_id'; + + expect(TableExtractor::fromSql($sql))->toBe(['orders', 'recent', 'users']); + }); + + it('extracts tables from REPLACE INTO', function (): void { + expect(TableExtractor::fromSql('REPLACE INTO settings (key, value) VALUES (?, ?)')) + ->toBe(['settings']); + }); + + it('records the table, not the schema, for qualified identifiers', function (): void { + expect(TableExtractor::fromSql('select * from public.users'))->toBe(['users']) + ->and(TableExtractor::fromSql('select * from "public"."users"'))->toBe(['users']) + ->and(TableExtractor::fromSql('select * from `analytics`.`events`'))->toBe(['events']) + ->and(TableExtractor::fromSql('UPDATE public.posts SET title = ?'))->toBe(['posts']); + }); + + it('handles quoted identifiers', function (): void { + expect(TableExtractor::fromSql('select * from "users"'))->toBe(['users']) + ->and(TableExtractor::fromSql('select * from `users`'))->toBe(['users']) + ->and(TableExtractor::fromSql('select * from [users]'))->toBe(['users']); + }); + + it('ignores schema metadata tables', function (): void { + expect(TableExtractor::fromSql("select * from sqlite_master where type = 'table'"))->toBeEmpty() + ->and(TableExtractor::fromSql('select * from pg_catalog.pg_tables'))->toBeEmpty() + ->and(TableExtractor::fromSql('select * from information_schema.tables'))->toBeEmpty(); + }); + + it('returns nothing for non-DML statements', function (): void { + expect(TableExtractor::fromSql('PRAGMA foreign_keys = ON'))->toBeEmpty() + ->and(TableExtractor::fromSql(''))->toBeEmpty() + ->and(TableExtractor::fromSql(' '))->toBeEmpty(); + }); +}); + +describe('fromMigrationSource()', function (): void { + it('extracts tables from Schema builder calls', function (): void { + $php = <<<'PHP' + Schema::create('users', function (Blueprint $table) {}); + Schema::table('orders', function (Blueprint $table) {}); + Schema::rename('old_posts', 'posts'); + Schema::dropIfExists('sessions'); + PHP; + + expect(TableExtractor::fromMigrationSource($php)) + ->toBe(['old_posts', 'orders', 'posts', 'sessions', 'users']); + }); + + it('extracts tables from raw DDL statements', function (): void { + $php = <<<'PHP' + DB::statement('ALTER TABLE users ADD COLUMN age INT'); + DB::statement('CREATE TABLE IF NOT EXISTS invoices (id INT)'); + PHP; + + expect(TableExtractor::fromMigrationSource($php))->toBe(['invoices', 'users']); + }); + + it('records the table, not the schema, in qualified DDL and DML', function (): void { + $php = <<<'PHP' + DB::statement('ALTER TABLE public.users ADD COLUMN age INT'); + DB::statement('CREATE TABLE "analytics"."events" (id INT)'); + DB::statement('INSERT INTO public.settings (key) VALUES (1)'); + DB::statement('DELETE FROM `public`.`sessions`'); + DB::table('public.audits')->delete(); + PHP; + + expect(TableExtractor::fromMigrationSource($php)) + ->toBe(['audits', 'events', 'sessions', 'settings', 'users']); + }); + + it('extracts tables from DB::table calls', function (): void { + expect(TableExtractor::fromMigrationSource("DB::table('permissions')->insert([]);")) + ->toBe(['permissions']); + }); +}); diff --git a/tests/Unit/Plugins/Tia/ViteDepsHelper.php b/tests/Unit/Plugins/Tia/ViteDepsHelper.php index b61f55c5..e88aee4f 100644 --- a/tests/Unit/Plugins/Tia/ViteDepsHelper.php +++ b/tests/Unit/Plugins/Tia/ViteDepsHelper.php @@ -259,6 +259,104 @@ function tiaAliasResults(): array return $cache = ['roots' => $roots, 'aliases' => $aliases]; } +function tiaViteAliasFixtures(): array +{ + return [ + 'root-slash-literal' => [ + ['vite.config.js' => "export default { resolve: { alias: { '@': '/resources/js' } } }"], + ['@' => 'resources/js'], + ], + 'path-resolve' => [ + ['vite.config.ts' => "export default { resolve: { alias: { '@': path.resolve(__dirname, 'resources/js') } } }"], + ['@' => 'resources/js'], + ], + 'file-url' => [ + ['vite.config.mjs' => "export default { resolve: { alias: { '@': fileURLToPath(new URL('./resources/js', import.meta.url)) } } }"], + ['@' => 'resources/js'], + ], + 'tilde-alias' => [ + ['vite.config.js' => "export default { resolve: { alias: { '~': path.resolve(__dirname, 'resources') } } }"], + ['~' => 'resources'], + ], + 'multiple-aliases' => [ + ['vite.config.js' => "export default { resolve: { alias: { '@': '/resources/js', '@css': '/resources/css' } } }"], + ['@' => 'resources/js', '@css' => 'resources/css'], + ], + 'laravel-plugin-default' => [ + [ + 'vite.config.js' => "import laravel from 'laravel-vite-plugin'\nexport default { plugins: [laravel({ input: ['resources/js/app.ts'] })] }", + 'package.json' => tiaJson(['devDependencies' => ['laravel-vite-plugin' => '^1.0']]), + ], + ['@' => 'resources/js'], + ], + 'no-alias-no-plugin' => [ + [ + 'vite.config.js' => 'export default { plugins: [] }', + 'package.json' => tiaJson(['devDependencies' => ['vue' => '^3.0']]), + ], + [], + ], + 'no-config' => [ + [], + [], + ], + ]; +} + +function tiaViteAliasResults(): array +{ + static $cache = null; + if ($cache !== null) { + return $cache; + } + + $roots = []; + $written = []; + $payload = []; + + foreach (tiaViteAliasFixtures() as $name => [$files]) { + $root = sys_get_temp_dir().'/pest-tia-vite-'.bin2hex(random_bytes(6)); + mkdir($root, 0755, true); + foreach ($files as $fname => $content) { + file_put_contents($root.'/'.$fname, $content); + $written[] = $root.'/'.$fname; + } + $root = str_replace('\\', '/', $root); + $roots[$name] = $root; + $payload[] = ['name' => $name, 'root' => $root]; + } + + $inputFile = tempnam(sys_get_temp_dir(), 'tia-vite-alias-'); + file_put_contents($inputFile, json_encode($payload)); + + $helper = str_replace('\\', '/', tiaViteHelperPath()); + $input = str_replace('\\', '/', $inputFile); + + $script = <<mustRun(); + + $aliases = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + + @unlink($inputFile); + foreach ($written as $f) { + @unlink($f); + } + foreach ($roots as $r) { + @rmdir($r); + } + + return $cache = ['roots' => $roots, 'aliases' => $aliases]; +} + beforeEach(function (): void { if ((new ExecutableFinder)->find('node') === null) { $this->markTestSkipped('node is not available.'); @@ -298,3 +396,16 @@ it('builds the expected alias map from a tsconfig', function (string $name): voi expect($results['aliases'][$name])->toEqual($expected); })->with(array_keys(tiaAliasFixtures())); + +it('builds the expected alias map from a vite config', function (string $name): void { + $results = tiaViteAliasResults(); + [, $expectedRelative] = tiaViteAliasFixtures()[$name]; + + $root = $results['roots'][$name]; + $expected = []; + foreach ($expectedRelative as $key => $relative) { + $expected[$key] = $root.'/'.$relative; + } + + expect($results['aliases'][$name])->toEqual($expected); +})->with(array_keys(tiaViteAliasFixtures()));