mirror of
https://github.com/pestphp/pest.git
synced 2026-09-05 06:13:35 +02:00
fix(tia): casing issues
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
Tests: 2 deprecated, 4 warnings, 5 incomplete, 2 notices, 40 todos, 35 skipped, 1570 passed (3410 assertions)
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Pest\Plugins\Tia\JsModuleGraph;
|
||||
|
||||
function tiaJsModuleGraphCall(string $method, mixed ...$arguments): mixed
|
||||
{
|
||||
return new ReflectionMethod(JsModuleGraph::class, $method)->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', "<template>ok</template>\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', "<template>ok</template>\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();
|
||||
});
|
||||
@@ -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', "<template>ok</template>\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 = <<<JS
|
||||
import { matchesDiskCasing } from '{$helper}'
|
||||
import { readFileSync } from 'node:fs'
|
||||
const cases = JSON.parse(readFileSync('{$input}', 'utf8'))
|
||||
const out = {}
|
||||
for (const c of cases) out[c.name] = await matchesDiskCasing('{$normalized}', c.candidate)
|
||||
process.stdout.write(JSON.stringify(out))
|
||||
JS;
|
||||
|
||||
$process = new Process(['node', '--input-type=module', '-e', $script]);
|
||||
$process->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()));
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user