Skip to content

Commit bf7c6b3

Browse files
committed
fix(tia): casing issues
1 parent 617226c commit bf7c6b3

7 files changed

Lines changed: 225 additions & 18 deletions

File tree

bin/pest-tia-vite-deps.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,24 @@ async function listPageFiles(pagesDir) {
190190
return out
191191
}
192192

193+
// `existsSync()` ignores casing on APFS and NTFS, and on a macOS bind mount seen from
194+
// inside a Linux container, so the `resources/js/Pages` candidate resolves in a project
195+
// whose directory is really `resources/js/pages`. Every page path derives from the
196+
// directory accepted below, so a wrong-cased one detaches the pages tree from the paths
197+
// the PHP side reports. `readdir()` is case-exact everywhere.
198+
export async function matchesDiskCasing(projectRoot, rel) {
199+
let current = projectRoot
200+
201+
for (const segment of rel.split('/')) {
202+
let entries
203+
try { entries = await readdir(current) } catch { return false }
204+
if (!entries.includes(segment)) return false
205+
current = join(current, segment)
206+
}
207+
208+
return true
209+
}
210+
193211
async function discoverPagesDir() {
194212
const override = process.env.TIA_VITE_PAGES_DIR
195213
if (override && override.length > 0) {
@@ -199,6 +217,7 @@ async function discoverPagesDir() {
199217
for (const rel of PAGE_DIR_CANDIDATES) {
200218
const abs = resolve(PROJECT_ROOT, rel)
201219
if (!existsSync(abs)) continue
220+
if (!(await matchesDiskCasing(PROJECT_ROOT, rel))) continue
202221
const files = await listPageFiles(abs)
203222
if (files.length > 0) return abs
204223
}

composer.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
"nunomaduro/termwind": "^2.4.0",
2424
"pestphp/pest-plugin": "^5.0.0",
2525
"pestphp/pest-plugin-arch": "^5.0.0",
26-
"pestphp/pest-plugin-mutate": "^5.0.1",
26+
"pestphp/pest-plugin-mutate": "^5.0.2",
2727
"pestphp/pest-plugin-profanity": "^5.0.0",
2828
"phpunit/phpunit": "^13.3.0",
2929
"symfony/process": "^8.1.0"
@@ -60,7 +60,7 @@
6060
},
6161
"require-dev": {
6262
"pestphp/pest-dev-tools": "^5.0.0",
63-
"pestphp/pest-plugin-browser": "^5.0.0",
63+
"pestphp/pest-plugin-browser": "^5.0.1",
6464
"pestphp/pest-plugin-phpstan": "^5.0.2",
6565
"pestphp/pest-plugin-rector": "^5.0.3",
6666
"pestphp/pest-plugin-type-coverage": "^5.0.2",

src/Plugins/Tia/JsModuleGraph.php

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ private static function firstExistingPagesDir(string $projectRoot): ?string
8585
continue;
8686
}
8787

88+
if (! self::matchesDiskCasing($projectRoot, $rel)) {
89+
continue;
90+
}
91+
8892
if (self::dirHasPageFile($abs)) {
8993
return $abs;
9094
}
@@ -93,12 +97,30 @@ private static function firstExistingPagesDir(string $projectRoot): ?string
9397
return null;
9498
}
9599

100+
private static function matchesDiskCasing(string $projectRoot, string $relative): bool
101+
{
102+
$current = rtrim($projectRoot, DIRECTORY_SEPARATOR);
103+
104+
foreach (explode('/', $relative) as $segment) {
105+
$entries = @scandir($current);
106+
107+
if ($entries === false || ! in_array($segment, $entries, true)) {
108+
return false;
109+
}
110+
111+
$current .= DIRECTORY_SEPARATOR.$segment;
112+
}
113+
114+
return true;
115+
}
116+
96117
private static function dirHasPageFile(string $dir): bool
97118
{
98119
try {
99120
$iterator = new \RecursiveIteratorIterator(
100121
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
101122
\RecursiveIteratorIterator::LEAVES_ONLY,
123+
\RecursiveIteratorIterator::CATCH_GET_CHILD,
102124
);
103125
} catch (\UnexpectedValueException) {
104126
return false;
@@ -265,20 +287,25 @@ private static function fingerprint(string $projectRoot): ?string
265287
if ($jsRoot !== null && is_dir($jsRoot)) {
266288
$entries = [];
267289

268-
$iterator = new \RecursiveIteratorIterator(
269-
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
270-
\RecursiveIteratorIterator::LEAVES_ONLY,
271-
);
272-
273-
/** @var \SplFileInfo $file */
274-
foreach ($iterator as $file) {
275-
if (! $file->isFile()) {
276-
continue;
290+
try {
291+
$iterator = new \RecursiveIteratorIterator(
292+
new \RecursiveDirectoryIterator($jsRoot, \FilesystemIterator::SKIP_DOTS),
293+
\RecursiveIteratorIterator::LEAVES_ONLY,
294+
\RecursiveIteratorIterator::CATCH_GET_CHILD,
295+
);
296+
297+
/** @var \SplFileInfo $file */
298+
foreach ($iterator as $file) {
299+
if (! $file->isFile()) {
300+
continue;
301+
}
302+
303+
$entries[] = $file->getPathname()
304+
.':'.$file->getSize()
305+
.':'.$file->getMTime();
277306
}
278-
279-
$entries[] = $file->getPathname()
280-
.':'.$file->getSize()
281-
.':'.$file->getMTime();
307+
} catch (\UnexpectedValueException|\RuntimeException) {
308+
return null;
282309
}
283310

284311
sort($entries);

tests/.snapshots/success.txt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1906,6 +1906,14 @@
19061906
✓ does not throw when an integer --random-order-seed is passed as a separate argv element
19071907
✓ still detects --tia when an integer argument is present
19081908

1909+
PASS Tests\Unit\Plugins\Tia\JsModuleGraph
1910+
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "exact"
1911+
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "wrong leaf"
1912+
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "wrong parent"
1913+
✓ it accepts a page directory candidate only when every segment matches the casing on disk with dataset "absent"
1914+
✓ it resolves the pages directory with the casing it has on disk
1915+
✓ it fingerprints a project whose js tree holds a directory it cannot open
1916+
19091917
PASS Tests\Unit\Plugins\Tia\Lockfiles\PackageLock
19101918
✓ it applies only to package-lock.json
19111919
✓ it returns null for contents that are not an npm lockfile
@@ -2021,6 +2029,10 @@
20212029
✓ it builds the expected alias map from a vite config with ('laravel-plugin-default')
20222030
✓ it builds the expected alias map from a vite config with ('no-alias-no-plugin')
20232031
✓ it builds the expected alias map from a vite config with ('no-config')
2032+
✓ it accepts a page directory candidate only when it matches the casing on disk with ('exact')
2033+
✓ it accepts a page directory candidate only when it matches the casing on disk with ('wrong leaf')
2034+
✓ it accepts a page directory candidate only when it matches the casing on disk with ('wrong parent')
2035+
✓ it accepts a page directory candidate only when it matches the casing on disk with ('absent')
20242036

20252037
PASS Tests\Unit\Preset
20262038
✓ preset invalid name
@@ -2209,4 +2221,4 @@
22092221
✓ pass with dataset with ('my-datas-set-value')
22102222
✓ within describe → pass with dataset with ('my-datas-set-value')
22112223

2212-
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1560 passed (3400 assertions)
2224+
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1570 passed (3410 assertions)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Pest\Plugins\Tia\JsModuleGraph;
6+
7+
function tiaJsModuleGraphCall(string $method, mixed ...$arguments): mixed
8+
{
9+
return new ReflectionMethod(JsModuleGraph::class, $method)->invoke(null, ...$arguments);
10+
}
11+
12+
function tiaJsModuleGraphProject(): string
13+
{
14+
$root = sys_get_temp_dir().'/pest-tia-js-module-graph-'.bin2hex(random_bytes(6));
15+
16+
mkdir($root.'/resources/js/pages', 0755, true);
17+
file_put_contents($root.'/vite.config.ts', "export default {}\n");
18+
file_put_contents($root.'/resources/js/pages/Dashboard.vue', "<template>ok</template>\n");
19+
20+
return $root;
21+
}
22+
23+
function tiaJsModuleGraphRemove(string $path): void
24+
{
25+
if (! is_dir($path)) {
26+
@unlink($path);
27+
28+
return;
29+
}
30+
31+
@chmod($path, 0755);
32+
33+
$entries = @scandir($path);
34+
35+
foreach ($entries === false ? [] : $entries as $entry) {
36+
if ($entry === '.') {
37+
continue;
38+
}
39+
if ($entry === '..') {
40+
continue;
41+
}
42+
tiaJsModuleGraphRemove($path.'/'.$entry);
43+
}
44+
45+
@rmdir($path);
46+
}
47+
48+
beforeEach(function (): void {
49+
$this->projectRoot = tiaJsModuleGraphProject();
50+
});
51+
52+
afterEach(function (): void {
53+
tiaJsModuleGraphRemove($this->projectRoot);
54+
});
55+
56+
it('accepts a page directory candidate only when every segment matches the casing on disk', function (string $candidate, bool $expected): void {
57+
expect(tiaJsModuleGraphCall('matchesDiskCasing', $this->projectRoot, $candidate))->toBe($expected);
58+
})->with([
59+
'exact' => ['resources/js/pages', true],
60+
'wrong leaf' => ['resources/js/Pages', false],
61+
'wrong parent' => ['Resources/js/pages', false],
62+
'absent' => ['assets/js/pages', false],
63+
]);
64+
65+
it('resolves the pages directory with the casing it has on disk', function (): void {
66+
$expected = $this->projectRoot
67+
.DIRECTORY_SEPARATOR.'resources'
68+
.DIRECTORY_SEPARATOR.'js'
69+
.DIRECTORY_SEPARATOR.'pages';
70+
71+
expect(tiaJsModuleGraphCall('firstExistingPagesDir', $this->projectRoot))->toBe($expected);
72+
});
73+
74+
it('fingerprints a project whose js tree holds a directory it cannot open', function (): void {
75+
$locked = $this->projectRoot.'/resources/js/locked';
76+
77+
mkdir($locked, 0755, true);
78+
file_put_contents($locked.'/Secret.vue', "<template>ok</template>\n");
79+
chmod($locked, 0000);
80+
81+
if (is_readable($locked)) {
82+
$this->markTestSkipped('the current user reads directories regardless of their mode.');
83+
}
84+
85+
expect(tiaJsModuleGraphCall('fingerprint', $this->projectRoot))->toBeString();
86+
});

tests/Unit/Plugins/Tia/ViteDepsHelper.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,63 @@ function tiaViteAliasResults(): array
357357
return $cache = ['roots' => $roots, 'aliases' => $aliases];
358358
}
359359
360+
function tiaViteCasingFixtures(): array
361+
{
362+
return [
363+
'exact' => ['resources/js/pages', true],
364+
'wrong leaf' => ['resources/js/Pages', false],
365+
'wrong parent' => ['Resources/js/pages', false],
366+
'absent' => ['assets/js/pages', false],
367+
];
368+
}
369+
370+
function tiaViteCasingResults(): array
371+
{
372+
static $cache = null;
373+
if ($cache !== null) {
374+
return $cache;
375+
}
376+
377+
$root = sys_get_temp_dir().'/pest-tia-vite-casing-'.bin2hex(random_bytes(6));
378+
mkdir($root.'/resources/js/pages', 0755, true);
379+
file_put_contents($root.'/resources/js/pages/Dashboard.vue', "<template>ok</template>\n");
380+
381+
$helper = str_replace('\\', '/', tiaViteHelperPath());
382+
$normalized = str_replace('\\', '/', $root);
383+
384+
$payload = [];
385+
foreach (tiaViteCasingFixtures() as $name => [$candidate]) {
386+
$payload[] = ['name' => $name, 'candidate' => $candidate];
387+
}
388+
389+
$inputFile = tempnam(sys_get_temp_dir(), 'tia-vite-casing-');
390+
file_put_contents($inputFile, json_encode($payload));
391+
$input = str_replace('\\', '/', $inputFile);
392+
393+
$script = <<<JS
394+
import { matchesDiskCasing } from '{$helper}'
395+
import { readFileSync } from 'node:fs'
396+
const cases = JSON.parse(readFileSync('{$input}', 'utf8'))
397+
const out = {}
398+
for (const c of cases) out[c.name] = await matchesDiskCasing('{$normalized}', c.candidate)
399+
process.stdout.write(JSON.stringify(out))
400+
JS;
401+
402+
$process = new Process(['node', '--input-type=module', '-e', $script]);
403+
$process->mustRun();
404+
405+
$results = json_decode($process->getOutput(), true, flags: JSON_THROW_ON_ERROR);
406+
407+
@unlink($inputFile);
408+
@unlink($root.'/resources/js/pages/Dashboard.vue');
409+
@rmdir($root.'/resources/js/pages');
410+
@rmdir($root.'/resources/js');
411+
@rmdir($root.'/resources');
412+
@rmdir($root);
413+
414+
return $cache = $results;
415+
}
416+
360417
beforeEach(function (): void {
361418
if ((new ExecutableFinder)->find('node') === null) {
362419
$this->markTestSkipped('node is not available.');
@@ -409,3 +466,9 @@ function tiaViteAliasResults(): array
409466
410467
expect($results['aliases'][$name])->toEqual($expected);
411468
})->with(array_keys(tiaViteAliasFixtures()));
469+
470+
it('accepts a page directory candidate only when it matches the casing on disk', function (string $name): void {
471+
[, $expected] = tiaViteCasingFixtures()[$name];
472+
473+
expect(tiaViteCasingResults()[$name])->toBe($expected);
474+
})->with(array_keys(tiaViteCasingFixtures()));

tests/Visual/Parallel.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@
2626
$file = file_get_contents(__FILE__);
2727
$file = preg_replace(
2828
'/\$expected = \'.*?\';/',
29-
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1542 passed (3345 assertions)';",
29+
"\$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1552 passed (3355 assertions)';",
3030
$file,
3131
);
3232
file_put_contents(__FILE__, $file);
3333
}
3434

35-
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1542 passed (3345 assertions)';
35+
$expected = '2 deprecated, 4 warnings, 5 incomplete, 3 notices, 40 todos, 27 skipped, 1552 passed (3355 assertions)';
3636

3737
expect($output)
3838
->toContain("Tests: {$expected}")

0 commit comments

Comments
 (0)