mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 10:52:14 +02:00
wip
This commit is contained in:
@ -829,36 +829,45 @@ final class Graph
|
||||
|
||||
/**
|
||||
* Maps a project-relative path to its Inertia component name if it
|
||||
* lives under `resources/js/Pages/` with a recognised framework
|
||||
* extension. Returns null otherwise so callers can cheaply ignore
|
||||
* non-page files. Matches Inertia's resolver convention: strip the
|
||||
* `resources/js/Pages/` prefix, strip the extension, preserve the
|
||||
* remaining slashes (`Users/Show.vue` → `Users/Show`).
|
||||
* lives under the project's pages directory with a recognised
|
||||
* framework extension. Returns null otherwise so callers can
|
||||
* cheaply ignore non-page files. Matches Inertia's resolver
|
||||
* convention: strip the pages prefix, strip the extension, preserve
|
||||
* the remaining slashes (`Users/Show.vue` → `Users/Show`).
|
||||
*
|
||||
* Both `resources/js/Pages/` (the classic Inertia-Vue convention)
|
||||
* and `resources/js/pages/` (the Laravel React starter kit, and
|
||||
* other lowercase-by-default setups) are accepted — paths from
|
||||
* git are case-sensitive on Linux, so we must match the exact
|
||||
* casing used by the project rather than picking one and forcing
|
||||
* the other to fall through to the broad watch pattern.
|
||||
*/
|
||||
private function componentForInertiaPage(string $rel): ?string
|
||||
{
|
||||
$prefix = 'resources/js/Pages/';
|
||||
foreach (['resources/js/Pages/', 'resources/js/pages/'] as $prefix) {
|
||||
if (! str_starts_with($rel, $prefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! str_starts_with($rel, $prefix)) {
|
||||
return null;
|
||||
$tail = substr($rel, strlen($prefix));
|
||||
$dot = strrpos($tail, '.');
|
||||
|
||||
if ($dot === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$extension = substr($tail, $dot + 1);
|
||||
|
||||
if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = substr($tail, 0, $dot);
|
||||
|
||||
return $name === '' ? null : $name;
|
||||
}
|
||||
|
||||
$tail = substr($rel, strlen($prefix));
|
||||
$dot = strrpos($tail, '.');
|
||||
|
||||
if ($dot === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$extension = substr($tail, $dot + 1);
|
||||
|
||||
if (! in_array($extension, ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'], true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$name = substr($tail, 0, $dot);
|
||||
|
||||
return $name === '' ? null : $name;
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -33,7 +33,15 @@ final class InertiaEdges
|
||||
{
|
||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
||||
|
||||
private const string REQUEST_HANDLED_EVENT = '\\Illuminate\\Foundation\\Http\\Events\\RequestHandled';
|
||||
/**
|
||||
* Event class name used as the listener key. Stored *without* a
|
||||
* leading backslash because Laravel's `Dispatcher` keys
|
||||
* `$listeners[$eventName]` by the literal string passed to
|
||||
* `listen()`, and looks up incoming events by their PHP-class
|
||||
* name (`get_class($event)`), which never has a leading
|
||||
* backslash. A `\Illuminate\…` key would silently never match.
|
||||
*/
|
||||
private const string REQUEST_HANDLED_EVENT = 'Illuminate\\Foundation\\Http\\Events\\RequestHandled';
|
||||
|
||||
/**
|
||||
* App-scoped marker that makes `arm()` idempotent across per-test
|
||||
@ -129,22 +137,66 @@ final class InertiaEdges
|
||||
}
|
||||
}
|
||||
|
||||
// Initial-load HTML path: Inertia embeds the page payload in a
|
||||
// `data-page` attribute on the root `<div id="app">`. We only
|
||||
// pay the regex cost when the body actually contains the
|
||||
// attribute, so non-Inertia HTML responses are effectively a
|
||||
// no-op.
|
||||
// Initial-load HTML path. Inertia ships two shapes here and
|
||||
// we honour both:
|
||||
//
|
||||
// 1. SSR-safe script tag — `<script data-page="app"
|
||||
// type="application/json">{…JSON…}</script>`. The
|
||||
// Laravel React starter kit (and modern Inertia-React)
|
||||
// use this so the JSON survives server-rendered
|
||||
// hydration without HTML-encoding the payload into an
|
||||
// attribute. The `data-page="app"` *attribute value* is
|
||||
// the literal string `"app"` — only the tag *body*
|
||||
// carries the page JSON.
|
||||
// 2. Classic — `<div id="app" data-page="{…JSON…}">…`. Older
|
||||
// Inertia-Vue and Inertia-React still emit this. Here
|
||||
// `data-page` IS the JSON, HTML-entity-encoded.
|
||||
//
|
||||
// Try the script-tag shape first; if the response uses it,
|
||||
// the classic regex would also see a `data-page="app"` token
|
||||
// and try to JSON-decode the literal string `"app"`.
|
||||
$content = self::readContent($response);
|
||||
|
||||
if ($content === null || ! str_contains($content, 'data-page=')) {
|
||||
if ($content === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/\sdata-page="([^"]+)"/', $content, $match) !== 1) {
|
||||
return null;
|
||||
// Lookahead pair handles arbitrary attribute order on the
|
||||
// `<script>` tag.
|
||||
if (str_contains($content, 'type="application/json"')
|
||||
&& preg_match('#<script\b(?=[^>]*\bdata-page="app")(?=[^>]*\btype="application/json")[^>]*>(.+?)</script>#s', $content, $match) === 1) {
|
||||
$component = self::componentFromJson(html_entity_decode($match[1]));
|
||||
|
||||
if ($component !== null) {
|
||||
return $component;
|
||||
}
|
||||
}
|
||||
|
||||
$decoded = json_decode(html_entity_decode($match[1]), true);
|
||||
// Classic: only accept a value that looks like a JSON object
|
||||
// (`{…}`). Avoids matching the script-tag form's
|
||||
// `data-page="app"` attribute when both shapes coexist.
|
||||
if (str_contains($content, 'data-page=')
|
||||
&& preg_match('/\sdata-page="(\{[^"]+\})"/', $content, $match) === 1) {
|
||||
$component = self::componentFromJson(html_entity_decode($match[1]));
|
||||
|
||||
if ($component !== null) {
|
||||
return $component;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an Inertia page JSON blob and returns the `component`
|
||||
* field if it's a non-empty string. Used by both the script-tag
|
||||
* and the `data-page`-attribute paths so the success criteria are
|
||||
* identical.
|
||||
*/
|
||||
private static function componentFromJson(string $json): ?string
|
||||
{
|
||||
/** @var mixed $decoded */
|
||||
$decoded = json_decode($json, true);
|
||||
|
||||
if (is_array($decoded)
|
||||
&& isset($decoded['component'])
|
||||
|
||||
@ -36,23 +36,32 @@ final class JsImportParser
|
||||
|
||||
private const array RESOLVABLE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js', 'mjs', 'mts'];
|
||||
|
||||
private const string PAGES_DIR = 'resources/js/Pages';
|
||||
|
||||
private const string JS_DIR = 'resources/js';
|
||||
|
||||
/**
|
||||
* Walks `resources/js/Pages` and, for each page, collects its
|
||||
* transitive file imports. Returns the inverted graph so callers
|
||||
* can look up "what pages depend on this shared file".
|
||||
* Walks the project's pages directory (`resources/js/Pages` or its
|
||||
* lowercase Laravel-React-starter-kit equivalent `resources/js/pages`)
|
||||
* and, for each page, collects its transitive file imports. Returns
|
||||
* the inverted graph so callers can look up "what pages depend on
|
||||
* this shared file".
|
||||
*
|
||||
* @return array<string, list<string>>
|
||||
*/
|
||||
public static function parse(string $projectRoot): array
|
||||
{
|
||||
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.self::JS_DIR;
|
||||
$pagesRoot = $projectRoot.DIRECTORY_SEPARATOR.self::PAGES_DIR;
|
||||
$pagesRoot = null;
|
||||
|
||||
if (! is_dir($pagesRoot)) {
|
||||
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
|
||||
$abs = $projectRoot.DIRECTORY_SEPARATOR.$candidate;
|
||||
if (is_dir($abs)) {
|
||||
$pagesRoot = $abs;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($pagesRoot === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@ -76,7 +76,22 @@ final class JsModuleGraph
|
||||
*/
|
||||
public static function isApplicable(string $projectRoot): bool
|
||||
{
|
||||
return self::hasViteConfig($projectRoot) && is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.'Pages');
|
||||
if (! self::hasViteConfig($projectRoot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Both the classic Inertia-Vue (`Pages/`) and the Laravel React
|
||||
// starter kit (`pages/`) conventions are accepted — projects
|
||||
// running on a case-sensitive filesystem (Linux CI) get
|
||||
// exactly one of the two, and we shouldn't refuse to walk the
|
||||
// graph based on which one it picks.
|
||||
foreach (['Pages', 'pages'] as $dir) {
|
||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.$dir)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -104,7 +119,21 @@ final class JsModuleGraph
|
||||
return null;
|
||||
}
|
||||
|
||||
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
|
||||
// Tell the Node helper which casing this project uses for its
|
||||
// pages directory. The helper defaults to `resources/js/Pages`;
|
||||
// the Laravel React starter ships lowercase `resources/js/pages`,
|
||||
// and on a case-sensitive filesystem the helper would otherwise
|
||||
// walk a non-existent directory and emit an empty module graph.
|
||||
$env = [];
|
||||
foreach (['resources/js/Pages', 'resources/js/pages'] as $candidate) {
|
||||
if (is_dir($projectRoot.DIRECTORY_SEPARATOR.$candidate)) {
|
||||
$env['TIA_VITE_PAGES_DIR'] = $candidate;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot, $env);
|
||||
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
|
||||
$process->run();
|
||||
|
||||
|
||||
@ -118,6 +118,11 @@ final class TableTracker
|
||||
return;
|
||||
}
|
||||
|
||||
$events->listen('\\Illuminate\\Database\\Events\\QueryExecuted', $listener);
|
||||
// Event class key intentionally has no leading backslash —
|
||||
// `Dispatcher::listen()` stores by the literal string and the
|
||||
// lookup at dispatch time uses `get_class($event)` (no
|
||||
// leading backslash), so a `\Illuminate\…` key would never
|
||||
// match the fired event.
|
||||
$events->listen('Illuminate\\Database\\Events\\QueryExecuted', $listener);
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,37 +30,39 @@ final readonly class Inertia implements WatchDefault
|
||||
? $testPath.'/Browser'
|
||||
: $testPath;
|
||||
|
||||
return [
|
||||
// Inertia page components (React / Vue / Svelte). Scoped to
|
||||
// `$browserDir` only — a Vue/React edit cannot change the
|
||||
// output of a server-side Inertia test (those assert on the
|
||||
// component *name* returned by `Inertia::render()`, not its
|
||||
// client-side implementation). Broad invalidation is only
|
||||
// meaningful for tests that actually render the DOM. Precise
|
||||
// per-component edges come from `InertiaEdges` at record
|
||||
// time and replace this fallback when available.
|
||||
'resources/js/Pages/**/*.vue' => [$browserDir],
|
||||
'resources/js/Pages/**/*.tsx' => [$browserDir],
|
||||
'resources/js/Pages/**/*.jsx' => [$browserDir],
|
||||
'resources/js/Pages/**/*.svelte' => [$browserDir],
|
||||
'resources/js/Pages/**/*.ts' => [$browserDir],
|
||||
'resources/js/Pages/**/*.js' => [$browserDir],
|
||||
// Inertia page components (React / Vue / Svelte). Scoped to
|
||||
// `$browserDir` only — a Vue/React edit cannot change the
|
||||
// output of a server-side Inertia test (those assert on the
|
||||
// component *name* returned by `Inertia::render()`, not its
|
||||
// client-side implementation). Broad invalidation is only
|
||||
// meaningful for tests that actually render the DOM. Precise
|
||||
// per-component edges come from `InertiaEdges` at record
|
||||
// time and replace this fallback when available.
|
||||
//
|
||||
// Both `Pages/` (classic Inertia-Vue) and `pages/` (Laravel
|
||||
// React starter kit, and other lowercase-by-default setups)
|
||||
// are emitted — paths from git are case-sensitive on Linux,
|
||||
// so a single casing would silently miss the other convention.
|
||||
$patterns = [];
|
||||
|
||||
// Shared layouts / components consumed by pages.
|
||||
'resources/js/Layouts/**/*.vue' => [$browserDir],
|
||||
'resources/js/Layouts/**/*.tsx' => [$browserDir],
|
||||
'resources/js/Layouts/**/*.ts' => [$browserDir],
|
||||
'resources/js/Layouts/**/*.js' => [$browserDir],
|
||||
'resources/js/Components/**/*.vue' => [$browserDir],
|
||||
'resources/js/Components/**/*.tsx' => [$browserDir],
|
||||
'resources/js/Components/**/*.ts' => [$browserDir],
|
||||
'resources/js/Components/**/*.js' => [$browserDir],
|
||||
foreach (['Pages', 'pages'] as $pages) {
|
||||
foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) {
|
||||
$patterns["resources/js/{$pages}/**/*.{$ext}"] = [$browserDir];
|
||||
}
|
||||
}
|
||||
|
||||
// SSR entry point.
|
||||
'resources/js/ssr.js' => [$browserDir],
|
||||
'resources/js/ssr.ts' => [$browserDir],
|
||||
'resources/js/app.js' => [$browserDir],
|
||||
'resources/js/app.ts' => [$browserDir],
|
||||
];
|
||||
foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) {
|
||||
foreach (['vue', 'tsx', 'ts', 'js'] as $ext) {
|
||||
$patterns["resources/js/{$shared}/**/*.{$ext}"] = [$browserDir];
|
||||
}
|
||||
}
|
||||
|
||||
// SSR entry point.
|
||||
$patterns['resources/js/ssr.js'] = [$browserDir];
|
||||
$patterns['resources/js/ssr.ts'] = [$browserDir];
|
||||
$patterns['resources/js/app.js'] = [$browserDir];
|
||||
$patterns['resources/js/app.ts'] = [$browserDir];
|
||||
|
||||
return $patterns;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user