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', "ok\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', "ok\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', "ok\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}")