From eb88513bafc2f111073b5545462a25fba0bf9148 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Fri, 7 Aug 2026 14:27:26 +0100 Subject: [PATCH] fix: livewire --- src/Plugins/Tia/Graph.php | 183 +++++++++++++++++- tests/.snapshots/success.txt | 17 +- tests/Features/Tia/LivewireComponents.php | 123 ++++++++++++ .../overlays/livewire-watch/tests/Pest.php | 12 ++ tests/Unit/Plugins/Tia/Graph.php | 115 +++++++++++ tests/Visual/Parallel.php | 4 +- 6 files changed, 446 insertions(+), 8 deletions(-) create mode 100644 tests/Features/Tia/LivewireComponents.php create mode 100644 tests/Fixtures/Tia/overlays/livewire-watch/tests/Pest.php diff --git a/src/Plugins/Tia/Graph.php b/src/Plugins/Tia/Graph.php index 80811fef..e9171bae 100644 --- a/src/Plugins/Tia/Graph.php +++ b/src/Plugins/Tia/Graph.php @@ -18,6 +18,19 @@ use PHPUnit\TextUI\Configuration\Registry; */ final class Graph { + /** + * Livewire's generated-file directories, relative to its cache directory, + * mapped to the extension each one writes. Only these three land in the + * graph — scripts and styles are never rendered or executed by PHP. + * + * @var array + */ + private const array LIVEWIRE_GENERATED_PATHS = [ + '/livewire/views/' => '.blade.php', + '/livewire/placeholders/' => '.blade.php', + '/livewire/classes/' => '.php', + ]; + /** @var array */ private array $files = []; @@ -103,14 +116,15 @@ final class Graph $this->applyTestFileChanges($nonMigrationPaths, $affectedSet); - $staticallyHandledBlade = $this->applyBladeStaticChanges($nonMigrationPaths, $affectedSet); + $handledBlade = $this->applyBladeStaticChanges($nonMigrationPaths, $affectedSet) + + $this->applyLivewireComponentChanges($nonMigrationPaths, $affectedSet); $this->applyWatchPatternFallback( $nonMigrationPaths, $unparseableMigrations, $preciselyHandledPages, $sharedFilesResolved, - $staticallyHandledBlade, + $handledBlade, $affectedSet, ); @@ -489,12 +503,171 @@ final class Graph return $staticallyHandled; } + /** + * Livewire compiles single- and multi-file components into generated files + * under the (per-worker) compiled view directory, so the graph only ever + * holds those generated paths — never the component source the developer + * edited. Reproduce Livewire's hash to walk that mapping backwards. + * + * @param list $nonMigrationPaths + * @param array $affectedSet + * @return array + */ + private function applyLivewireComponentChanges(array $nonMigrationPaths, array &$affectedSet): array + { + $generatedIds = $this->livewireGeneratedFileIds(); + + if ($generatedIds === []) { + return []; + } + + /** @var array> $sourcesByGeneratedId */ + $sourcesByGeneratedId = []; + + foreach ($nonMigrationPaths as $rel) { + foreach ($this->livewireSourcePaths($rel) as $sourcePath) { + foreach ($generatedIds[$this->livewireHash($sourcePath)] ?? [] as $id) { + $sourcesByGeneratedId[$id][$rel] = true; + } + } + } + + if ($sourcesByGeneratedId === []) { + return []; + } + + $handled = []; + + foreach ($this->edges as $testFile => $ids) { + foreach ($ids as $id) { + if (! isset($sourcesByGeneratedId[$id])) { + continue; + } + + $affectedSet[$testFile] = true; + $handled += $sourcesByGeneratedId[$id]; + } + } + + return $handled; + } + + /** + * The component sources whose Livewire hash a changed file could carry: the + * file itself when it is a single-file component, and its directory when it + * sits inside a multi-file component — a class or asset sibling of the view + * is compiled under the directory's hash, not its own. + * + * @return list + */ + private function livewireSourcePaths(string $rel): array + { + $sourcePaths = []; + + if (str_ends_with($rel, '.blade.php')) { + $sourcePaths[] = $rel; + } + + $componentDirectory = dirname($rel); + + if ($this->isLivewireMultiFileDirectory($componentDirectory)) { + $sourcePaths[] = $componentDirectory; + } + + return $sourcePaths; + } + + /** + * Mirrors Livewire\Finder\Finder::hasValidMultiFileComponentSource(): a + * multi-file component is a directory holding both ".php" and + * ".blade.php", where "" is the directory name with the ⚡ + * marker stripped, collapsed to "index" for the index convention. + */ + private function isLivewireMultiFileDirectory(string $componentDirectory): bool + { + $directoryName = basename($componentDirectory); + + if (str_contains($directoryName, 'index')) { + $directoryName = 'index'; + } + + $componentName = preg_replace('/⚡[\x{FE0E}\x{FE0F}]?/u', '', $directoryName); + + if ($componentName === null || $componentName === '') { + return false; + } + + $source = $this->projectRoot.'/'.$componentDirectory.'/'.$componentName; + + return is_file($source.'.php') && is_file($source.'.blade.php'); + } + + /** + * Mirrors Livewire\Compiler\CacheManager::getHash(): the first eight hex + * digits of md5() over the source path relative to base_path(), leading + * separator included. Should Livewire ever change that scheme, nothing + * matches and the watch-pattern fallback takes over again. + */ + private function livewireHash(string $sourcePath): string + { + return substr(md5(DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sourcePath)), 0, 8); + } + + /** + * Index every Livewire-generated file already in the graph by its hash. The + * same component yields one entry per parallel worker, so a hash maps to a + * list of ids rather than a single one. + * + * @return array> + */ + private function livewireGeneratedFileIds(): array + { + $generated = []; + + foreach ($this->fileIds as $path => $id) { + $hash = $this->livewireGeneratedHash($path); + + if ($hash === null) { + continue; + } + + $generated[$hash][] = $id; + } + + return $generated; + } + + private function livewireGeneratedHash(string $path): ?string + { + $normalized = '/'.ltrim($path, '/'); + + foreach (self::LIVEWIRE_GENERATED_PATHS as $directory => $extension) { + if (! str_ends_with($normalized, $extension)) { + continue; + } + + $position = strrpos($normalized, $directory); + + if ($position === false) { + continue; + } + + $hash = substr($normalized, $position + strlen($directory), -strlen($extension)); + + if (preg_match('/^[0-9a-f]{8}$/', $hash) === 1) { + return $hash; + } + } + + return null; + } + /** * @param list $nonMigrationPaths * @param list $unparseableMigrations * @param array $preciselyHandledPages * @param array $sharedFilesResolved - * @param array $staticallyHandledBlade + * @param array $handledBlade * @param array $affectedSet */ private function applyWatchPatternFallback( @@ -502,7 +675,7 @@ final class Graph array $unparseableMigrations, array $preciselyHandledPages, array $sharedFilesResolved, - array $staticallyHandledBlade, + array $handledBlade, array &$affectedSet, ): void { $unknownToGraph = $unparseableMigrations; @@ -514,7 +687,7 @@ final class Graph if (isset($sharedFilesResolved[$rel])) { continue; } - if (isset($staticallyHandledBlade[$rel])) { + if (isset($handledBlade[$rel])) { continue; } if (! isset($this->fileIds[$rel])) { diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 7b94de6b..79b93237 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -1707,6 +1707,12 @@ ✓ a skip outranks an issue triggered on the way to it ✓ a suppressed issue is not recorded + PASS Tests\Features\Tia\LivewireComponents + ✓ a changed single-file component selects only the tests that rendered it, across workers + ✓ a changed multi-file component class selects the tests that rendered the component + ✓ a deleted single-file component selects the tests that rendered it + ✓ a Blade file with no generated view still falls back to the watch pattern + PASS Tests\Features\Tia\PartialRunWriteTier ✓ a filtered run rewrites only the test that ran ✓ a filtered run under --tia announces that tia does not apply @@ -2106,6 +2112,15 @@ ✓ rerun tracking → it flags cached failures whose file is unknown ✓ applyBladeStaticChanges() → it maps an anonymous index component to the views that render it ✓ applyBladeStaticChanges() → it falls back to watch patterns for components with no matched usage + ✓ Livewire component views → it maps a changed component to generated views from different workers + ✓ Livewire component views → it maps documented SFC locations with dataset "default pages namespace" + ✓ Livewire component views → it maps documented SFC locations with dataset "default layouts namespace without emoji" + ✓ Livewire component views → it maps documented SFC locations with dataset "additional component location" + ✓ Livewire component views → it maps documented MFC locations using the component directory hash with dataset "default component location" + ✓ Livewire component views → it maps documented MFC locations using the component directory hash with dataset "default component location without emoji" + ✓ Livewire component views → it maps documented MFC locations using the component directory hash with dataset "index convention" + ✓ Livewire component views → it preserves direct view edges for class-based components + ✓ Livewire component views → it falls back to watch patterns when no generated view matches ✓ markKnownTestFiles() → it makes a test file with no edges known ✓ markKnownTestFiles() → it does not clobber edges of an already-known test file ✓ markKnownTestFiles() → it ignores paths outside the project root @@ -2417,4 +2432,4 @@ ✓ pass with dataset with ('my-datas-set-value') ✓ within describe → pass with dataset with ('my-datas-set-value') - Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1743 passed (3953 assertions) \ No newline at end of file + Tests: 1 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1756 passed (3974 assertions) \ No newline at end of file diff --git a/tests/Features/Tia/LivewireComponents.php b/tests/Features/Tia/LivewireComponents.php new file mode 100644 index 00000000..c90746c2 --- /dev/null +++ b/tests/Features/Tia/LivewireComponents.php @@ -0,0 +1,123 @@ +/livewire/{views,classes}/.` and + * renders that, so the recorded graph only ever holds the generated path. These + * helpers seed the graph the way a real recording run would leave it. + */ +function tiaLivewireHash(string $sourcePath): string +{ + return substr(md5(DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sourcePath)), 0, 8); +} + +/** + * @param array $generatedToTest generated path → test file that rendered it + */ +function tiaSeedWithGeneratedViews(Project $project, array $generatedToTest): void +{ + $project->seed('master'); + + $project->mutateGraph(function (array $graph) use ($generatedToTest): array { + foreach ($generatedToTest as $generated => $testFile) { + $id = count($graph['files']); + $graph['files'][$id] = $generated; + $graph['edges'][$testFile][] = $id; + } + + return $graph; + }); +} + +test('a changed single-file component selects only the tests that rendered it, across workers', function (): void { + $project = Project::make('master', 'livewire-watch'); + $project->write('resources/views/pages/⚡orders.blade.php', "
orders
\n"); + $project->git()->commit('add the component'); + + $hash = tiaLivewireHash('resources/views/pages/⚡orders.blade.php'); + + // The same component, compiled once per parallel worker. + tiaSeedWithGeneratedViews($project, [ + 'storage/framework/views/test_1/livewire/views/'.$hash.'.blade.php' => 'tests/Unit/GreeterTest.php', + 'storage/framework/views/test_2/livewire/views/'.$hash.'.blade.php' => 'tests/Unit/CalculatorTest.php', + ]); + + $project->write('resources/views/pages/⚡orders.blade.php', "
orders v2
\n"); + $project->snapshot(); + + $result = $project->pest('--tia'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->affected())->toBe(4, $result->describe()) + ->and($result->replayed())->toBe(2, $result->describe()); +})->skipOnWindows(); + +test('a changed multi-file component class selects the tests that rendered the component', function (): void { + $project = Project::make('master', 'livewire-watch'); + $project->write('resources/views/components/⚡counter/counter.blade.php', "
{{ \$count }}
\n"); + $project->write('resources/views/components/⚡counter/counter.php', "git()->commit('add the component'); + + // A multi-file component is compiled under the hash of its *directory*, and + // its class is what PHP executes — so the class sibling, not the view, is + // what the graph can be reached through. + tiaSeedWithGeneratedViews($project, [ + 'storage/framework/views/livewire/classes/'.tiaLivewireHash('resources/views/components/⚡counter').'.php' => 'tests/Unit/GreeterTest.php', + ]); + + $project->write('resources/views/components/⚡counter/counter.php', "snapshot(); + + $result = $project->pest('--tia'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->affected())->toBe(2, $result->describe()) + ->and($result->replayed())->toBe(4, $result->describe()); +})->skipOnWindows(); + +test('a deleted single-file component selects the tests that rendered it', function (): void { + $project = Project::make('master', 'livewire-watch'); + $project->write('resources/views/pages/⚡orders.blade.php', "
orders
\n"); + $project->git()->commit('add the component'); + + tiaSeedWithGeneratedViews($project, [ + 'storage/framework/views/livewire/views/'.tiaLivewireHash('resources/views/pages/⚡orders.blade.php').'.blade.php' => 'tests/Unit/GreeterTest.php', + ]); + + unlink($project->path('resources/views/pages/⚡orders.blade.php')); + $project->snapshot(); + + $result = $project->pest('--tia'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->affected())->toBe(2, $result->describe()) + ->and($result->replayed())->toBe(4, $result->describe()); +})->skipOnWindows(); + +test('a Blade file with no generated view still falls back to the watch pattern', function (): void { + $project = Project::make('master', 'livewire-watch'); + $project->write('resources/views/pages/⚡orders.blade.php', "
orders
\n"); + $project->write('resources/views/unrelated.blade.php', "
unrelated
\n"); + $project->git()->commit('add the views'); + + tiaSeedWithGeneratedViews($project, [ + 'storage/framework/views/livewire/views/'.tiaLivewireHash('resources/views/pages/⚡orders.blade.php').'.blade.php' => 'tests/Unit/GreeterTest.php', + ]); + + $project->write('resources/views/unrelated.blade.php', "
unrelated v2
\n"); + $project->snapshot(); + + $result = $project->pest('--tia'); + + expect($result->exitCode)->toBe(0, $result->describe()) + ->and($result->affected())->toBe(Project::TOTAL_TESTS, $result->describe()) + ->and($result->replayed())->toBe(0, $result->describe()); +})->skipOnWindows(); diff --git a/tests/Fixtures/Tia/overlays/livewire-watch/tests/Pest.php b/tests/Fixtures/Tia/overlays/livewire-watch/tests/Pest.php new file mode 100644 index 00000000..ce847cfb --- /dev/null +++ b/tests/Fixtures/Tia/overlays/livewire-watch/tests/Pest.php @@ -0,0 +1,12 @@ +tia()->watch(['resources/views/**' => 'tests']); diff --git a/tests/Unit/Plugins/Tia/Graph.php b/tests/Unit/Plugins/Tia/Graph.php index 1d82e4ac..36a19333 100644 --- a/tests/Unit/Plugins/Tia/Graph.php +++ b/tests/Unit/Plugins/Tia/Graph.php @@ -147,6 +147,121 @@ describe('applyBladeStaticChanges()', function (): void { }); }); +describe('Livewire component views', function (): void { + beforeEach(function (): void { + $this->projectRoot = sys_get_temp_dir().'/pest-tia-livewire-sfc-'.bin2hex(random_bytes(4)); + mkdir($this->projectRoot, 0755, true); + + $this->watchPatterns = new WatchPatterns; + $this->watchPatterns->add(['resources/views/**' => 'tests/Feature']); + Container::getInstance()->add(WatchPatterns::class, $this->watchPatterns); + }); + + afterEach(function (): void { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->projectRoot, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($files as $file) { + assert($file instanceof SplFileInfo); + + if ($file->isDir()) { + @rmdir($file->getPathname()); + } else { + @unlink($file->getPathname()); + } + } + + @rmdir($this->projectRoot); + + Container::getInstance()->add(WatchPatterns::class, new WatchPatterns); + }); + + it('maps a changed component to generated views from different workers', function (): void { + $ordersPath = 'resources/views/components/orders.blade.php'; + $usersPath = 'resources/views/components/admin/⚡users.blade.php'; + $ordersHash = substr(md5(DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $ordersPath)), 0, 8); + $usersHash = substr(md5(DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $usersPath)), 0, 8); + + mkdir(dirname($this->projectRoot.'/'.$ordersPath), 0755, true); + mkdir(dirname($this->projectRoot.'/'.$usersPath), 0755, true); + file_put_contents($this->projectRoot.'/'.$ordersPath, '
Orders
'); + file_put_contents($this->projectRoot.'/'.$usersPath, '
Users
'); + + $graph = new Graph($this->projectRoot); + $graph->link('tests/Feature/OrdersTest.php', 'storage/framework/views/test_1/livewire/views/'.$ordersHash.'.blade.php'); + $graph->link('tests/Feature/UsersTest.php', 'storage/framework/views/test_2/livewire/views/'.$usersHash.'.blade.php'); + + expect($graph->affected([$ordersPath]))->toBe(['tests/Feature/OrdersTest.php']); + }); + + it('maps documented SFC locations', function (string $sourcePath): void { + $hash = substr(md5(DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $sourcePath)), 0, 8); + + mkdir(dirname($this->projectRoot.'/'.$sourcePath), 0755, true); + file_put_contents($this->projectRoot.'/'.$sourcePath, '
Component
'); + + $graph = new Graph($this->projectRoot); + $graph->link('tests/Feature/ComponentTest.php', 'storage/framework/views/test_3/livewire/views/'.$hash.'.blade.php'); + $graph->link('tests/Feature/UnrelatedTest.php', 'storage/framework/views/test_4/livewire/views/deadbeef.blade.php'); + + expect($graph->affected([$sourcePath]))->toBe(['tests/Feature/ComponentTest.php']); + })->with([ + 'default pages namespace' => ['resources/views/pages/post/⚡create.blade.php'], + 'default layouts namespace without emoji' => ['resources/views/layouts/app.blade.php'], + 'additional component location' => ['resources/views/widgets/orders.blade.php'], + ]); + + it('maps documented MFC locations using the component directory hash', function (string $componentDirectory, string $viewPath): void { + $hash = substr(md5(DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $componentDirectory)), 0, 8); + $classPath = $componentDirectory.'/'.basename($viewPath, '.blade.php').'.php'; + + mkdir($this->projectRoot.'/'.$componentDirectory, 0755, true); + file_put_contents($this->projectRoot.'/'.$viewPath, '
Component
'); + file_put_contents($this->projectRoot.'/'.$classPath, 'projectRoot); + $graph->link('tests/Feature/ComponentTest.php', 'storage/framework/views/test_5/livewire/views/'.$hash.'.blade.php'); + $graph->link('tests/Feature/UnrelatedTest.php', 'storage/framework/views/test_6/livewire/views/deadbeef.blade.php'); + + expect($graph->affected([$viewPath]))->toBe(['tests/Feature/ComponentTest.php']); + })->with([ + 'default component location' => ['resources/views/components/post/⚡create', 'resources/views/components/post/⚡create/create.blade.php'], + 'default component location without emoji' => ['resources/views/components/post/create', 'resources/views/components/post/create/create.blade.php'], + 'index convention' => ['resources/views/components/post/⚡index', 'resources/views/components/post/⚡index/index.blade.php'], + ]); + + it('preserves direct view edges for class-based components', function (): void { + $viewPath = 'resources/views/livewire/create-post.blade.php'; + + mkdir(dirname($this->projectRoot.'/'.$viewPath), 0755, true); + file_put_contents($this->projectRoot.'/'.$viewPath, '
Create post
'); + + $graph = new Graph($this->projectRoot); + $graph->link('tests/Feature/CreatePostTest.php', $viewPath); + + expect($graph->affected([$viewPath]))->toBe(['tests/Feature/CreatePostTest.php']); + }); + + it('falls back to watch patterns when no generated view matches', function (): void { + $ordersPath = 'resources/views/components/orders.blade.php'; + $ordersHash = substr(md5(DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $ordersPath)), 0, 8); + $unmatchedPath = 'resources/views/components/unmatched.blade.php'; + + mkdir(dirname($this->projectRoot.'/'.$ordersPath), 0755, true); + file_put_contents($this->projectRoot.'/'.$ordersPath, '
Orders
'); + file_put_contents($this->projectRoot.'/'.$unmatchedPath, '
Unmatched
'); + + $graph = new Graph($this->projectRoot); + $graph->link('tests/Feature/OrdersTest.php', 'storage/framework/views/test_1/livewire/views/'.$ordersHash.'.blade.php'); + $graph->link('tests/Feature/UsersTest.php', 'storage/framework/views/test_2/livewire/views/deadbeef.blade.php'); + + expect($graph->affected([$unmatchedPath])) + ->toBe(['tests/Feature/OrdersTest.php', 'tests/Feature/UsersTest.php']); + }); +}); + describe('markKnownTestFiles()', function (): void { it('makes a test file with no edges known', function (): void { $graph = new Graph(sys_get_temp_dir()); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index 39f64116..83a58b4b 100644 --- a/tests/Visual/Parallel.php +++ b/tests/Visual/Parallel.php @@ -26,13 +26,13 @@ test('parallel', function () use ($run): void { $file = file_get_contents(__FILE__); $file = preg_replace( '/\$expected = \'.*?\';/', - "\$expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1531 passed (3334 assertions)';", + "\$expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1540 passed (3343 assertions)';", $file, ); file_put_contents(__FILE__, $file); } - $expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1531 passed (3334 assertions)'; + $expected = '1 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1540 passed (3343 assertions)'; expect($output) ->toContain("Tests: {$expected}")