From bf7c6b31340164d9eb5f20ccbf43cf78c060bcd3 Mon Sep 17 00:00:00 2001 From: nuno maduro Date: Tue, 11 Aug 2026 01:47:11 +0100 Subject: [PATCH] fix(tia): casing issues --- bin/pest-tia-vite-deps.mjs | 19 +++++ composer.json | 4 +- src/Plugins/Tia/JsModuleGraph.php | 51 ++++++++++---- tests/.snapshots/success.txt | 14 +++- tests/Unit/Plugins/Tia/JsModuleGraph.php | 86 +++++++++++++++++++++++ tests/Unit/Plugins/Tia/ViteDepsHelper.php | 63 +++++++++++++++++ tests/Visual/Parallel.php | 4 +- 7 files changed, 224 insertions(+), 17 deletions(-) create mode 100644 tests/Unit/Plugins/Tia/JsModuleGraph.php diff --git a/bin/pest-tia-vite-deps.mjs b/bin/pest-tia-vite-deps.mjs index 031a7cdb..f39af395 100644 --- a/bin/pest-tia-vite-deps.mjs +++ b/bin/pest-tia-vite-deps.mjs @@ -190,6 +190,24 @@ async function listPageFiles(pagesDir) { return out } +// `existsSync()` ignores casing on APFS and NTFS, and on a macOS bind mount seen from +// inside a Linux container, so the `resources/js/Pages` candidate resolves in a project +// whose directory is really `resources/js/pages`. Every page path derives from the +// directory accepted below, so a wrong-cased one detaches the pages tree from the paths +// the PHP side reports. `readdir()` is case-exact everywhere. +export async function matchesDiskCasing(projectRoot, rel) { + let current = projectRoot + + for (const segment of rel.split('/')) { + let entries + try { entries = await readdir(current) } catch { return false } + if (!entries.includes(segment)) return false + current = join(current, segment) + } + + return true +} + async function discoverPagesDir() { const override = process.env.TIA_VITE_PAGES_DIR if (override && override.length > 0) { @@ -199,6 +217,7 @@ async function discoverPagesDir() { for (const rel of PAGE_DIR_CANDIDATES) { const abs = resolve(PROJECT_ROOT, rel) if (!existsSync(abs)) continue + if (!(await matchesDiskCasing(PROJECT_ROOT, rel))) continue const files = await listPageFiles(abs) if (files.length > 0) return abs } diff --git a/composer.json b/composer.json index d88d9d48..a23d0bc1 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,7 @@ "nunomaduro/termwind": "^2.4.0", "pestphp/pest-plugin": "^5.0.0", "pestphp/pest-plugin-arch": "^5.0.0", - "pestphp/pest-plugin-mutate": "^5.0.1", + "pestphp/pest-plugin-mutate": "^5.0.2", "pestphp/pest-plugin-profanity": "^5.0.0", "phpunit/phpunit": "^13.3.0", "symfony/process": "^8.1.0" @@ -60,7 +60,7 @@ }, "require-dev": { "pestphp/pest-dev-tools": "^5.0.0", - "pestphp/pest-plugin-browser": "^5.0.0", + "pestphp/pest-plugin-browser": "^5.0.1", "pestphp/pest-plugin-phpstan": "^5.0.2", "pestphp/pest-plugin-rector": "^5.0.3", "pestphp/pest-plugin-type-coverage": "^5.0.2", diff --git a/src/Plugins/Tia/JsModuleGraph.php b/src/Plugins/Tia/JsModuleGraph.php index 633e50ce..eec9ebc1 100644 --- a/src/Plugins/Tia/JsModuleGraph.php +++ b/src/Plugins/Tia/JsModuleGraph.php @@ -85,6 +85,10 @@ final class JsModuleGraph continue; } + if (! self::matchesDiskCasing($projectRoot, $rel)) { + continue; + } + if (self::dirHasPageFile($abs)) { return $abs; } @@ -93,12 +97,30 @@ final class JsModuleGraph return null; } + private static function matchesDiskCasing(string $projectRoot, string $relative): bool + { + $current = rtrim($projectRoot, DIRECTORY_SEPARATOR); + + foreach (explode('/', $relative) as $segment) { + $entries = @scandir($current); + + if ($entries === false || ! in_array($segment, $entries, true)) { + return false; + } + + $current .= DIRECTORY_SEPARATOR.$segment; + } + + return true; + } + private static function dirHasPageFile(string $dir): bool { try { $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY, + \RecursiveIteratorIterator::CATCH_GET_CHILD, ); } catch (\UnexpectedValueException) { return false; @@ -265,20 +287,25 @@ final class JsModuleGraph if ($jsRoot !== null && is_dir($jsRoot)) { $entries = []; - $iterator = new \RecursiveIteratorIterator( - new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS), - \RecursiveIteratorIterator::LEAVES_ONLY, - ); + try { + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::LEAVES_ONLY, + \RecursiveIteratorIterator::CATCH_GET_CHILD, + ); - /** @var \SplFileInfo $file */ - foreach ($iterator as $file) { - if (! $file->isFile()) { - continue; + /** @var \SplFileInfo $file */ + foreach ($iterator as $file) { + if (! $file->isFile()) { + continue; + } + + $entries[] = $file->getPathname() + .':'.$file->getSize() + .':'.$file->getMTime(); } - - $entries[] = $file->getPathname() - .':'.$file->getSize() - .':'.$file->getMTime(); + } catch (\UnexpectedValueException|\RuntimeException) { + return null; } sort($entries); diff --git a/tests/.snapshots/success.txt b/tests/.snapshots/success.txt index 67b4170d..c6bc6212 100644 --- a/tests/.snapshots/success.txt +++ b/tests/.snapshots/success.txt @@ -1906,6 +1906,14 @@ ✓ does not throw when an integer --random-order-seed is passed as a separate argv element ✓ still detects --tia when an integer argument is present + PASS Tests\Unit\Plugins\Tia\JsModuleGraph + ✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "exact" + ✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "wrong leaf" + ✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "wrong parent" + ✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "absent" + ✓ it resolves the pages directory with the casing it has on disk + ✓ it fingerprints a project whose js tree holds a directory it cannot open + PASS Tests\Unit\Plugins\Tia\Lockfiles\PackageLock ✓ it applies only to package-lock.json ✓ it returns null for contents that are not an npm lockfile @@ -2021,6 +2029,10 @@ ✓ it builds the expected alias map from a vite config with ('laravel-plugin-default') ✓ it builds the expected alias map from a vite config with ('no-alias-no-plugin') ✓ it builds the expected alias map from a vite config with ('no-config') + ✓ it accepts a page directory candidate only when it matches the casing on disk with ('exact') + ✓ it accepts a page directory candidate only when it matches the casing on disk with ('wrong leaf') + ✓ it accepts a page directory candidate only when it matches the casing on disk with ('wrong parent') + ✓ it accepts a page directory candidate only when it matches the casing on disk with ('absent') PASS Tests\Unit\Preset ✓ preset invalid name @@ -2209,4 +2221,4 @@ ✓ pass with dataset with ('my-datas-set-value') ✓ within describe → pass with dataset with ('my-datas-set-value') - Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1560 passed (3400 assertions) \ No newline at end of file + Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1570 passed (3410 assertions) \ No newline at end of file diff --git a/tests/Unit/Plugins/Tia/JsModuleGraph.php b/tests/Unit/Plugins/Tia/JsModuleGraph.php new file mode 100644 index 00000000..902e4db2 --- /dev/null +++ b/tests/Unit/Plugins/Tia/JsModuleGraph.php @@ -0,0 +1,86 @@ +invoke(null, ...$arguments); +} + +function tiaJsModuleGraphProject(): string +{ + $root = sys_get_temp_dir().'/pest-tia-js-module-graph-'.bin2hex(random_bytes(6)); + + mkdir($root.'/resources/js/pages', 0755, true); + file_put_contents($root.'/vite.config.ts', "export default {}\n"); + file_put_contents($root.'/resources/js/pages/Dashboard.vue', "\n"); + + return $root; +} + +function tiaJsModuleGraphRemove(string $path): void +{ + if (! is_dir($path)) { + @unlink($path); + + return; + } + + @chmod($path, 0755); + + $entries = @scandir($path); + + foreach ($entries === false ? [] : $entries as $entry) { + if ($entry === '.') { + continue; + } + if ($entry === '..') { + continue; + } + tiaJsModuleGraphRemove($path.'/'.$entry); + } + + @rmdir($path); +} + +beforeEach(function (): void { + $this->projectRoot = tiaJsModuleGraphProject(); +}); + +afterEach(function (): void { + tiaJsModuleGraphRemove($this->projectRoot); +}); + +it('accepts a page directory candidate only when every segment matches the casing on disk', function (string $candidate, bool $expected): void { + expect(tiaJsModuleGraphCall('matchesDiskCasing', $this->projectRoot, $candidate))->toBe($expected); +})->with([ + 'exact' => ['resources/js/pages', true], + 'wrong leaf' => ['resources/js/Pages', false], + 'wrong parent' => ['Resources/js/pages', false], + 'absent' => ['assets/js/pages', false], +]); + +it('resolves the pages directory with the casing it has on disk', function (): void { + $expected = $this->projectRoot + .DIRECTORY_SEPARATOR.'resources' + .DIRECTORY_SEPARATOR.'js' + .DIRECTORY_SEPARATOR.'pages'; + + expect(tiaJsModuleGraphCall('firstExistingPagesDir', $this->projectRoot))->toBe($expected); +}); + +it('fingerprints a project whose js tree holds a directory it cannot open', function (): void { + $locked = $this->projectRoot.'/resources/js/locked'; + + mkdir($locked, 0755, true); + file_put_contents($locked.'/Secret.vue', "\n"); + chmod($locked, 0000); + + if (is_readable($locked)) { + $this->markTestSkipped('the current user reads directories regardless of their mode.'); + } + + expect(tiaJsModuleGraphCall('fingerprint', $this->projectRoot))->toBeString(); +}); diff --git a/tests/Unit/Plugins/Tia/ViteDepsHelper.php b/tests/Unit/Plugins/Tia/ViteDepsHelper.php index e88aee4f..79f60ca2 100644 --- a/tests/Unit/Plugins/Tia/ViteDepsHelper.php +++ b/tests/Unit/Plugins/Tia/ViteDepsHelper.php @@ -357,6 +357,63 @@ function tiaViteAliasResults(): array return $cache = ['roots' => $roots, 'aliases' => $aliases]; } +function tiaViteCasingFixtures(): array +{ + return [ + 'exact' => ['resources/js/pages', true], + 'wrong leaf' => ['resources/js/Pages', false], + 'wrong parent' => ['Resources/js/pages', false], + 'absent' => ['assets/js/pages', false], + ]; +} + +function tiaViteCasingResults(): array +{ + static $cache = null; + if ($cache !== null) { + return $cache; + } + + $root = sys_get_temp_dir().'/pest-tia-vite-casing-'.bin2hex(random_bytes(6)); + mkdir($root.'/resources/js/pages', 0755, true); + file_put_contents($root.'/resources/js/pages/Dashboard.vue', "\n"); + + $helper = str_replace('\\', '/', tiaViteHelperPath()); + $normalized = str_replace('\\', '/', $root); + + $payload = []; + foreach (tiaViteCasingFixtures() as $name => [$candidate]) { + $payload[] = ['name' => $name, 'candidate' => $candidate]; + } + + $inputFile = tempnam(sys_get_temp_dir(), 'tia-vite-casing-'); + file_put_contents($inputFile, json_encode($payload)); + $input = str_replace('\\', '/', $inputFile); + + $script = <<mustRun(); + + $results = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR); + + @unlink($inputFile); + @unlink($root.'/resources/js/pages/Dashboard.vue'); + @rmdir($root.'/resources/js/pages'); + @rmdir($root.'/resources/js'); + @rmdir($root.'/resources'); + @rmdir($root); + + return $cache = $results; +} + beforeEach(function (): void { if ((new ExecutableFinder)->find('node') === null) { $this->markTestSkipped('node is not available.'); @@ -409,3 +466,9 @@ it('builds the expected alias map from a vite config', function (string $name): expect($results['aliases'][$name])->toEqual($expected); })->with(array_keys(tiaViteAliasFixtures())); + +it('accepts a page directory candidate only when it matches the casing on disk', function (string $name): void { + [, $expected] = tiaViteCasingFixtures()[$name]; + + expect(tiaViteCasingResults()[$name])->toBe($expected); +})->with(array_keys(tiaViteCasingFixtures())); diff --git a/tests/Visual/Parallel.php b/tests/Visual/Parallel.php index ccc2db5b..6bb90a0a 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 = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1542 passed (3345 assertions)';", + "\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1552 passed (3355 assertions)';", $file, ); file_put_contents(__FILE__, $file); } - $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1542 passed (3345 assertions)'; + $expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1552 passed (3355 assertions)'; expect($output) ->toContain("Tests: {$expected}")