mirror of
https://github.com/pestphp/pest.git
synced 2026-06-05 02:52:12 +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
|
* Maps a project-relative path to its Inertia component name if it
|
||||||
* lives under `resources/js/Pages/` with a recognised framework
|
* lives under the project's pages directory with a recognised
|
||||||
* extension. Returns null otherwise so callers can cheaply ignore
|
* framework extension. Returns null otherwise so callers can
|
||||||
* non-page files. Matches Inertia's resolver convention: strip the
|
* cheaply ignore non-page files. Matches Inertia's resolver
|
||||||
* `resources/js/Pages/` prefix, strip the extension, preserve the
|
* convention: strip the pages prefix, strip the extension, preserve
|
||||||
* remaining slashes (`Users/Show.vue` → `Users/Show`).
|
* 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
|
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)) {
|
$tail = substr($rel, strlen($prefix));
|
||||||
return null;
|
$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));
|
return null;
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -33,7 +33,15 @@ final class InertiaEdges
|
|||||||
{
|
{
|
||||||
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
|
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
|
* 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
|
// Initial-load HTML path. Inertia ships two shapes here and
|
||||||
// `data-page` attribute on the root `<div id="app">`. We only
|
// we honour both:
|
||||||
// pay the regex cost when the body actually contains the
|
//
|
||||||
// attribute, so non-Inertia HTML responses are effectively a
|
// 1. SSR-safe script tag — `<script data-page="app"
|
||||||
// no-op.
|
// 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);
|
$content = self::readContent($response);
|
||||||
|
|
||||||
if ($content === null || ! str_contains($content, 'data-page=')) {
|
if ($content === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (preg_match('/\sdata-page="([^"]+)"/', $content, $match) !== 1) {
|
// Lookahead pair handles arbitrary attribute order on the
|
||||||
return null;
|
// `<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)
|
if (is_array($decoded)
|
||||||
&& isset($decoded['component'])
|
&& 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 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';
|
private const string JS_DIR = 'resources/js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Walks `resources/js/Pages` and, for each page, collects its
|
* Walks the project's pages directory (`resources/js/Pages` or its
|
||||||
* transitive file imports. Returns the inverted graph so callers
|
* lowercase Laravel-React-starter-kit equivalent `resources/js/pages`)
|
||||||
* can look up "what pages depend on this shared file".
|
* 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>>
|
* @return array<string, list<string>>
|
||||||
*/
|
*/
|
||||||
public static function parse(string $projectRoot): array
|
public static function parse(string $projectRoot): array
|
||||||
{
|
{
|
||||||
$jsRoot = $projectRoot.DIRECTORY_SEPARATOR.self::JS_DIR;
|
$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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -76,7 +76,22 @@ final class JsModuleGraph
|
|||||||
*/
|
*/
|
||||||
public static function isApplicable(string $projectRoot): bool
|
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;
|
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->setTimeout(self::NODE_TIMEOUT_SECONDS);
|
||||||
$process->run();
|
$process->run();
|
||||||
|
|
||||||
|
|||||||
@ -118,6 +118,11 @@ final class TableTracker
|
|||||||
return;
|
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.'/Browser'
|
||||||
: $testPath;
|
: $testPath;
|
||||||
|
|
||||||
return [
|
// Inertia page components (React / Vue / Svelte). Scoped to
|
||||||
// Inertia page components (React / Vue / Svelte). Scoped to
|
// `$browserDir` only — a Vue/React edit cannot change the
|
||||||
// `$browserDir` only — a Vue/React edit cannot change the
|
// output of a server-side Inertia test (those assert on the
|
||||||
// output of a server-side Inertia test (those assert on the
|
// component *name* returned by `Inertia::render()`, not its
|
||||||
// component *name* returned by `Inertia::render()`, not its
|
// client-side implementation). Broad invalidation is only
|
||||||
// client-side implementation). Broad invalidation is only
|
// meaningful for tests that actually render the DOM. Precise
|
||||||
// meaningful for tests that actually render the DOM. Precise
|
// per-component edges come from `InertiaEdges` at record
|
||||||
// per-component edges come from `InertiaEdges` at record
|
// time and replace this fallback when available.
|
||||||
// time and replace this fallback when available.
|
//
|
||||||
'resources/js/Pages/**/*.vue' => [$browserDir],
|
// Both `Pages/` (classic Inertia-Vue) and `pages/` (Laravel
|
||||||
'resources/js/Pages/**/*.tsx' => [$browserDir],
|
// React starter kit, and other lowercase-by-default setups)
|
||||||
'resources/js/Pages/**/*.jsx' => [$browserDir],
|
// are emitted — paths from git are case-sensitive on Linux,
|
||||||
'resources/js/Pages/**/*.svelte' => [$browserDir],
|
// so a single casing would silently miss the other convention.
|
||||||
'resources/js/Pages/**/*.ts' => [$browserDir],
|
$patterns = [];
|
||||||
'resources/js/Pages/**/*.js' => [$browserDir],
|
|
||||||
|
|
||||||
// Shared layouts / components consumed by pages.
|
foreach (['Pages', 'pages'] as $pages) {
|
||||||
'resources/js/Layouts/**/*.vue' => [$browserDir],
|
foreach (['vue', 'tsx', 'jsx', 'svelte', 'ts', 'js'] as $ext) {
|
||||||
'resources/js/Layouts/**/*.tsx' => [$browserDir],
|
$patterns["resources/js/{$pages}/**/*.{$ext}"] = [$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],
|
|
||||||
|
|
||||||
// SSR entry point.
|
foreach (['Layouts', 'layouts', 'Components', 'components'] as $shared) {
|
||||||
'resources/js/ssr.js' => [$browserDir],
|
foreach (['vue', 'tsx', 'ts', 'js'] as $ext) {
|
||||||
'resources/js/ssr.ts' => [$browserDir],
|
$patterns["resources/js/{$shared}/**/*.{$ext}"] = [$browserDir];
|
||||||
'resources/js/app.js' => [$browserDir],
|
}
|
||||||
'resources/js/app.ts' => [$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