This commit is contained in:
nuno maduro
2026-04-23 12:29:24 -07:00
parent caabebf2a1
commit 3d3c5d41ac
10 changed files with 1176 additions and 9 deletions

181
bin/pest-tia-vite-deps.mjs Normal file
View File

@ -0,0 +1,181 @@
#!/usr/bin/env node
/**
* TIA Vite dependency resolver.
*
* Spins up a throwaway headless Vite dev server using the project's
* `vite.config.*`, walks every `resources/js/Pages/**` entry to warm
* up the module graph, then serializes the graph as a reverse map:
*
* { "<abs source path>": ["<page component name>", ...], ... }
*
* The resulting JSON is written to stdout. Stderr is silent on
* success so Pest can parse stdout without stripping.
*
* Why this exists: at TIA record time we need to know which Inertia
* page components depend on each shared source file (Button.vue,
* Layouts/*.vue, etc.) so a later edit to one of those files can
* invalidate only the tests that rendered an affected page. Vite
* already knows this via its module graph — we borrow it.
*
* Called from `Pest\Plugins\Tia\JsModuleGraph::build()` as:
*
* node bin/pest-tia-vite-deps.mjs <absoluteProjectRoot>
*
* Environment:
* TIA_VITE_PAGES_DIR override the `resources/js/Pages` default.
* TIA_VITE_TIMEOUT_MS override the 20s internal watchdog.
*/
import { readdir } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { createRequire } from 'node:module'
import { resolve, relative, extname, posix, sep, join } from 'node:path'
import { pathToFileURL } from 'node:url'
const PAGE_EXTENSIONS = new Set(['.vue', '.tsx', '.jsx', '.svelte'])
const PROJECT_ROOT = resolve(process.argv[2] ?? process.cwd())
const PAGES_REL = (process.env.TIA_VITE_PAGES_DIR ?? 'resources/js/Pages').replace(/\\/g, '/')
const TIMEOUT_MS = Number.parseInt(process.env.TIA_VITE_TIMEOUT_MS ?? '20000', 10)
// Resolve Vite from the project's own `node_modules`, not from this
// helper's location (which lives under `vendor/pestphp/pest/bin/` and
// has no `node_modules`). `createRequire` anchored at the project
// root walks up from there, matching the resolution behaviour any
// project-local script would see.
async function loadVite() {
const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json'))
const vitePath = projectRequire.resolve('vite')
return await import(pathToFileURL(vitePath).href)
}
const { createServer } = await loadVite()
async function listPageFiles(pagesDir) {
if (!existsSync(pagesDir)) return []
const out = []
const walk = async (dir) => {
let entries
try { entries = await readdir(dir, { withFileTypes: true }) } catch { return }
for (const entry of entries) {
const full = resolve(dir, entry.name)
if (entry.isDirectory()) { await walk(full); continue }
if (PAGE_EXTENSIONS.has(extname(entry.name))) out.push(full)
}
}
await walk(pagesDir)
return out
}
function componentNameFor(pageAbs, pagesDir) {
const rel = relative(pagesDir, pageAbs).split(sep).join('/')
const ext = extname(rel)
return rel.slice(0, rel.length - ext.length)
}
async function main() {
const pagesDir = resolve(PROJECT_ROOT, PAGES_REL)
const pages = await listPageFiles(pagesDir)
if (pages.length === 0) {
process.stdout.write('{}')
return
}
// Boot Vite in middleware mode (no port binding, no HMR server).
// We only need the module graph; transformRequest per page warms
// it without running a bundle.
const server = await createServer({
configFile: undefined, // auto-detect vite.config.*
root: PROJECT_ROOT,
logLevel: 'silent',
clearScreen: false,
server: {
middlewareMode: true,
hmr: false,
watch: null,
},
appType: 'custom',
optimizeDeps: { disabled: true },
})
// Watchdog — don't let a pathological config hang the record run.
const killer = setTimeout(() => {
server.close().catch(() => {}).finally(() => process.exit(2))
}, TIMEOUT_MS)
// Reverse map: depSourcePath → Set<component name>.
const reverse = new Map()
const pageComponentCache = new Map()
for (const page of pages) {
pageComponentCache.set(page, componentNameFor(page, pagesDir))
}
try {
for (const pagePath of pages) {
const pageComponent = pageComponentCache.get(pagePath)
const pageUrl = '/' + posix.relative(
PROJECT_ROOT.split(sep).join('/'),
pagePath.split(sep).join('/'),
)
try {
await server.transformRequest(pageUrl, { ssr: false })
} catch {
// Transform errors (missing deps, syntax issues) shouldn't
// poison the whole graph — skip this page and continue.
continue
}
const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false)
if (!pageModule) continue
// BFS over importedModules, scoped to files inside the project.
const visited = new Set()
const queue = [pageModule]
while (queue.length) {
const mod = queue.shift()
for (const imported of mod.importedModules) {
const id = imported.file ?? imported.id
if (!id || visited.has(id)) continue
visited.add(id)
// Skip files outside the project root (node_modules, etc.)
// and virtual modules (`\0`-prefixed ids from plugins).
if (id.startsWith('\0')) continue
if (!id.startsWith(PROJECT_ROOT)) continue
const rel = relative(PROJECT_ROOT, id).split(sep).join('/')
const bucket = reverse.get(rel) ?? new Set()
bucket.add(pageComponent)
reverse.set(rel, bucket)
queue.push(imported)
}
}
}
} finally {
clearTimeout(killer)
await server.close()
}
const payload = Object.create(null)
const keys = [...reverse.keys()].sort()
for (const key of keys) {
payload[key] = [...reverse.get(key)].sort()
}
process.stdout.write(JSON.stringify(payload))
}
try {
// Node 20 dynamic-import path — some environments are pickier than others.
void pathToFileURL // retained to silence tree-shakers referencing the import
await main()
} catch (err) {
process.stderr.write(String(err?.stack ?? err ?? 'unknown error'))
process.exit(1)
}

View File

@ -9,6 +9,7 @@ use Pest\Exceptions\DatasetArgumentsMismatch;
use Pest\Panic;
use Pest\Plugins\Tia;
use Pest\Plugins\Tia\BladeEdges;
use Pest\Plugins\Tia\InertiaEdges;
use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\TableTracker;
use Pest\Preset;
@ -328,6 +329,7 @@ trait Testable
if ($recorder instanceof Recorder) {
BladeEdges::arm($recorder);
TableTracker::arm($recorder);
InertiaEdges::arm($recorder);
}
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];

View File

@ -14,6 +14,7 @@ use Pest\Plugins\Tia\Contracts\State;
use Pest\Plugins\Tia\CoverageCollector;
use Pest\Plugins\Tia\Fingerprint;
use Pest\Plugins\Tia\Graph;
use Pest\Plugins\Tia\JsModuleGraph;
use Pest\Plugins\Tia\Recorder;
use Pest\Plugins\Tia\ResultCollector;
use Pest\Plugins\Tia\WatchPatterns;
@ -413,9 +414,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
$perTestTables = $recorder->perTestTables();
$perTestInertia = $recorder->perTestInertiaComponents();
if (Parallel::isWorker()) {
$this->flushWorkerPartial($perTest, $perTestTables);
$this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia);
$recorder->reset();
$this->coverageCollector->reset();
@ -439,6 +441,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
);
$graph->replaceEdges($perTest);
$graph->replaceTestTables($perTestTables);
$graph->replaceTestInertiaComponents($perTestInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
$graph->pruneMissingTests();
// Fold in the results collected during this same record run. The
@ -527,6 +531,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$mergedFiles = [];
$mergedTables = [];
$mergedInertia = [];
foreach ($partialKeys as $key) {
$data = $this->readPartial($key);
@ -555,6 +560,16 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
}
foreach ($data['inertia'] as $testFile => $components) {
if (! isset($mergedInertia[$testFile])) {
$mergedInertia[$testFile] = [];
}
foreach ($components as $component) {
$mergedInertia[$testFile][$component] = true;
}
}
$this->state->delete($key);
}
@ -570,6 +585,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$finalisedTables[$testFile] = array_keys($tableSet);
}
$finalisedInertia = [];
foreach ($mergedInertia as $testFile => $componentSet) {
$finalisedInertia[$testFile] = array_keys($componentSet);
}
// Empty-edges guard: if every worker returned no edges it almost
// always means the coverage driver wasn't loaded in the workers
// (common footgun with custom PHP ini scan dirs, Herd profiles,
@ -588,6 +609,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$graph->replaceEdges($finalised);
$graph->replaceTestTables($finalisedTables);
$graph->replaceTestInertiaComponents($finalisedInertia);
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
$graph->pruneMissingTests();
if (! $this->saveGraph($graph)) {
@ -979,12 +1002,14 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
/**
* @param array<string, array<int, string>> $perTestFiles
* @param array<string, array<int, string>> $perTestTables
* @param array<string, array<int, string>> $perTestInertiaComponents
*/
private function flushWorkerPartial(array $perTestFiles, array $perTestTables): void
private function flushWorkerPartial(array $perTestFiles, array $perTestTables, array $perTestInertiaComponents): void
{
$json = json_encode([
'files' => $perTestFiles,
'tables' => $perTestTables,
'inertia' => $perTestInertiaComponents,
], JSON_UNESCAPED_SLASHES);
if ($json === false) {
@ -1122,7 +1147,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
}
/**
* @return array{files: array<string, array<int, string>>, tables: array<string, array<int, string>>}|null
* @return array{files: array<string, array<int, string>>, tables: array<string, array<int, string>>, inertia: array<string, array<int, string>>}|null
*/
private function readPartial(string $key): ?array
{
@ -1140,10 +1165,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
$filesSource = is_array($data['files'] ?? null) ? $data['files'] : [];
$tablesSource = is_array($data['tables'] ?? null) ? $data['tables'] : [];
$inertiaSource = is_array($data['inertia'] ?? null) ? $data['inertia'] : [];
return [
'files' => $this->cleanPartialSection($filesSource),
'tables' => $this->cleanPartialSection($tablesSource),
'inertia' => $this->cleanPartialSection($inertiaSource),
];
}

View File

@ -40,7 +40,19 @@ final readonly class Fingerprint
// coverage, which would leave every DB test invalidated by
// any migration change — force a rebuild so the new edges
// are populated.
private const int SCHEMA_VERSION = 6;
// v7: Graph gained per-test Inertia page-component edges
// (`$testInertiaComponents`) for surgical page-file
// invalidation. Worker partial now includes an `inertia`
// section. Old graphs have no component edges; without a
// rebuild Vue/React page edits would fall through to the
// broad watch pattern even when precise matching could have
// worked.
// v8: Graph gained `$jsFileToComponents` — reverse dependency
// map computed at record time from Vite's module graph (or
// the PHP fallback) so shared components / layouts /
// composables invalidate the specific pages they're used
// by, not every browser test.
private const int SCHEMA_VERSION = 8;
/**
* @return array{

View File

@ -58,6 +58,33 @@ final class Graph
*/
private array $testTables = [];
/**
* Inertia page component edges: test file (relative) → list of
* component names the test server-side rendered (whatever was
* passed to `Inertia::render($component, …)`). Populated from
* `Recorder::perTestInertiaComponents()`; consumed at replay time
* so an edit to `resources/js/Pages/Users/Show.vue` only invalidates
* tests that rendered `Users/Show`. Same string-keyed shape as
* `$testTables` for the same diff-readable reasons.
*
* @var array<string, array<int, string>>
*/
private array $testInertiaComponents = [];
/**
* Inverted JS dependency map: project-relative source path under
* `resources/js/**` → list of Inertia page components that
* transitively import it. Populated at record time by
* `JsModuleGraph::build()` (Vite module graph via Node helper,
* with a PHP fallback). Replay uses this to route a
* `Components/Button.vue` edit directly to the pages that depend
* on it, intersecting against `$testInertiaComponents` for
* surgical invalidation.
*
* @var array<string, array<int, string>>
*/
private array $jsFileToComponents = [];
/**
* Environment fingerprint captured at record time.
*
@ -199,6 +226,93 @@ final class Graph
}
}
// Inertia page-component routing. When a Vue/React/Svelte page
// under `resources/js/Pages/` changes, map it to the component
// name Inertia would use (the path relative to `Pages/`, with
// the extension stripped) and intersect with the captured
// component edges. Only invalidates tests that actually
// rendered the page. Pages with no captured edges (never
// rendered during record, brand-new on this branch) fall
// through to the watch-pattern fallback via
// `$unknownPageComponents` — safe over-run.
$changedComponents = [];
$unknownPageComponents = [];
foreach ($nonMigrationPaths as $rel) {
$component = $this->componentForInertiaPage($rel);
if ($component === null) {
continue;
}
if ($this->anyTestUses($this->testInertiaComponents, $component)) {
$changedComponents[$component] = true;
} else {
$unknownPageComponents[] = $rel;
}
}
// Pages whose component already resolved precisely via the
// direct Inertia edges path must not leak back through any
// broader mechanism (either the JS-dep lookup below, or the
// watch pattern further down).
$preciselyHandledPages = [];
foreach ($nonMigrationPaths as $rel) {
$component = $this->componentForInertiaPage($rel);
if ($component !== null && isset($changedComponents[$component])) {
$preciselyHandledPages[$rel] = true;
}
}
// Shared JS files (Components, Layouts, composables, etc.)
// aren't Inertia pages but pages depend on them transitively.
// `$jsFileToComponents` was computed at record time by walking
// Vite's module graph, so a change to
// `resources/js/Components/Button.vue` resolves directly to
// the set of page components that import it. Union those into
// `$changedComponents`. Files that aren't in the JS dep map
// fall through to the watch pattern below — same safety-net
// path the Inertia block above uses for unresolved pages.
$sharedFilesResolved = [];
foreach ($nonMigrationPaths as $rel) {
if (isset($preciselyHandledPages[$rel])) {
continue;
}
if (! isset($this->jsFileToComponents[$rel])) {
continue;
}
$touchedAny = false;
foreach ($this->jsFileToComponents[$rel] as $pageComponent) {
if ($this->anyTestUses($this->testInertiaComponents, $pageComponent)) {
$changedComponents[$pageComponent] = true;
$touchedAny = true;
}
}
if ($touchedAny) {
$sharedFilesResolved[$rel] = true;
}
}
if ($changedComponents !== []) {
foreach ($this->testInertiaComponents as $testFile => $components) {
if (isset($affectedSet[$testFile])) {
continue;
}
foreach ($components as $component) {
if (isset($changedComponents[$component])) {
$affectedSet[$testFile] = true;
break;
}
}
}
}
// 1. Coverage-edge lookup (PHP → PHP). Migrations are already
// handled above; skipping them here prevents their always-on
// coverage edges from invalidating the whole DB suite.
@ -241,8 +355,19 @@ final class Graph
// (exotic syntax, raw SQL) are funneled back in here too so
// broad invalidation still kicks in for edge cases we can't
// parse.
// Exclude paths that were already routed precisely through
// either the Inertia page-component path or the shared-JS
// dependency path. Broadcasting them again via the watch
// pattern would re-add every test the pattern maps to,
// defeating the surgical match.
$unknownToGraph = $unparseableMigrations;
foreach ($nonMigrationPaths as $rel) {
if (isset($preciselyHandledPages[$rel])) {
continue;
}
if (isset($sharedFilesResolved[$rel])) {
continue;
}
if (! isset($this->fileIds[$rel])) {
$unknownToGraph[] = $rel;
}
@ -521,6 +646,76 @@ final class Graph
}
}
/**
* Replaces Inertia component edges for the given test files. Names
* preserve case (they're identifiers like `Users/Show`, not
* user-supplied strings) but duplicates are collapsed. Same
* partial-update policy as `replaceTestTables`.
*
* @param array<string, array<int, string>> $testToComponents
*/
public function replaceTestInertiaComponents(array $testToComponents): void
{
foreach ($testToComponents as $testFile => $components) {
$testRel = $this->relative($testFile);
if ($testRel === null) {
continue;
}
$normalised = [];
foreach ($components as $component) {
if ($component !== '') {
$normalised[$component] = true;
}
}
$names = array_keys($normalised);
sort($names);
$this->testInertiaComponents[$testRel] = $names;
}
}
/**
* Replaces the whole JS dep map. Called at record time with the
* output of `JsModuleGraph::build()`. Unlike the test-level
* replacements above this is a wholesale overwrite — the
* resolver produces the full graph on every run.
*
* @param array<string, array<int, string>> $fileToComponents
*/
public function replaceJsFileToComponents(array $fileToComponents): void
{
$out = [];
foreach ($fileToComponents as $path => $components) {
if ($path === '') {
continue;
}
$names = [];
foreach ($components as $component) {
if ($component !== '') {
$names[$component] = true;
}
}
if ($names === []) {
continue;
}
$keys = array_keys($names);
sort($keys);
$out[$path] = $keys;
}
ksort($out);
$this->jsFileToComponents = $out;
}
/**
* Projects under Laravel conventionally keep migrations at
* `database/migrations/`. We recognise the directory as a prefix
@ -559,6 +754,58 @@ final class Graph
return TableExtractor::fromMigrationSource($content);
}
/**
* 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`).
*/
private function componentForInertiaPage(string $rel): ?string
{
$prefix = 'resources/js/Pages/';
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'], true)) {
return null;
}
$name = substr($tail, 0, $dot);
return $name === '' ? null : $name;
}
/**
* Whether any test's component set contains `$component`. Used to
* decide between precise edge matching and watch-pattern fallback
* for a changed Inertia page file.
*
* @param array<string, array<int, string>> $edges
*/
private function anyTestUses(array $edges, string $component): bool
{
foreach ($edges as $components) {
if (in_array($component, $components, true)) {
return true;
}
}
return false;
}
/**
* Drops edges whose test file no longer exists on disk. Prevents the graph
* from keeping stale entries for deleted / renamed tests that would later
@ -574,6 +821,12 @@ final class Graph
}
}
foreach (array_keys($this->testInertiaComponents) as $testRel) {
if (! is_file($root.$testRel)) {
unset($this->testInertiaComponents[$testRel]);
}
}
foreach (array_keys($this->testTables) as $testRel) {
if (! is_file($root.$testRel)) {
unset($this->testTables[$testRel]);
@ -624,6 +877,53 @@ final class Graph
}
}
if (isset($data['test_inertia_components']) && is_array($data['test_inertia_components'])) {
foreach ($data['test_inertia_components'] as $testRel => $components) {
if (! is_string($testRel)) {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
$graph->testInertiaComponents[$testRel] = $names;
}
}
}
if (isset($data['js_file_to_components']) && is_array($data['js_file_to_components'])) {
foreach ($data['js_file_to_components'] as $path => $components) {
if (! is_string($path)) {
continue;
}
if ($path === '') {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
$graph->jsFileToComponents[$path] = $names;
}
}
}
return $graph;
}
@ -642,6 +942,8 @@ final class Graph
'edges' => $this->edges,
'baselines' => $this->baselines,
'test_tables' => $this->testTables,
'test_inertia_components' => $this->testInertiaComponents,
'js_file_to_components' => $this->jsFileToComponents,
];
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);

View File

@ -0,0 +1,170 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Inertia-aware collaborator: during record mode, attributes every
* Inertia component the test server-side renders to the currently-
* running test file.
*
* Why this exists: a change to `resources/js/Pages/Users/Show.vue`
* should only invalidate tests that actually rendered `Users/Show`.
* The Laravel `WatchDefaults\Inertia` glob is a broad fallback — fine
* for brand-new pages, but noisy once the graph has real data. With
* this armed, each test's recorded edge set grows to include the
* component names it returned through `Inertia::render()`, and
* subsequent replay intersects page-file changes against that set.
*
* Mechanism: listen for `Illuminate\Foundation\Http\Events\RequestHandled`
* on Laravel's event dispatcher. Inertia responses are identifiable by
* either an `X-Inertia` header (XHR / JSON shape) or a `data-page`
* attribute on the root `<div id="app">` (full HTML shape). Both carry
* the component name in a structured payload we can parse cheaply.
*
* Same dep-free handshake as `BladeEdges` / `TableTracker`: string
* class lookup + method-capability probes so Pest's `require` stays
* Laravel-free.
*
* @internal
*/
final class InertiaEdges
{
private const string CONTAINER_CLASS = '\\Illuminate\\Container\\Container';
private const string REQUEST_HANDLED_EVENT = '\\Illuminate\\Foundation\\Http\\Events\\RequestHandled';
/**
* App-scoped marker that makes `arm()` idempotent across per-test
* `setUp()` calls. Laravel reuses the same app across tests in
* most configurations — without this guard we'd stack one
* listener per test.
*/
private const string MARKER = 'pest.tia.inertia-edges-armed';
public static function arm(Recorder $recorder): void
{
if (! $recorder->isActive()) {
return;
}
$containerClass = self::CONTAINER_CLASS;
if (! class_exists($containerClass)) {
return;
}
/** @var object $app */
$app = $containerClass::getInstance();
if (! method_exists($app, 'bound') || ! method_exists($app, 'make') || ! method_exists($app, 'instance')) {
return;
}
if ($app->bound(self::MARKER)) {
return;
}
if (! $app->bound('events')) {
return;
}
$app->instance(self::MARKER, true);
/** @var object $events */
$events = $app->make('events');
if (! method_exists($events, 'listen')) {
return;
}
$events->listen(self::REQUEST_HANDLED_EVENT, static function (object $event) use ($recorder): void {
if (! property_exists($event, 'response')) {
return;
}
/** @var mixed $response */
$response = $event->response;
if (! is_object($response)) {
return;
}
$component = self::extractComponent($response);
if ($component !== null) {
$recorder->linkInertiaComponent($component);
}
});
}
/**
* Pulls the Inertia component name out of a Laravel response,
* handling both XHR (`X-Inertia` + JSON body) and full HTML
* (`<div id="app" data-page="…">`) shapes. Returns null for any
* non-Inertia response so the caller can ignore it cheaply.
*/
private static function extractComponent(object $response): ?string
{
// XHR path: Inertia sets an `X-Inertia: true` header and the
// body is JSON with a `component` key.
if (property_exists($response, 'headers') && is_object($response->headers)) {
$headers = $response->headers;
if (method_exists($headers, 'has') && $headers->has('X-Inertia')) {
$content = self::readContent($response);
if ($content !== null) {
/** @var mixed $decoded */
$decoded = json_decode($content, true);
if (is_array($decoded)
&& isset($decoded['component'])
&& is_string($decoded['component'])
&& $decoded['component'] !== '') {
return $decoded['component'];
}
}
}
}
// 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.
$content = self::readContent($response);
if ($content === null || ! str_contains($content, 'data-page=')) {
return null;
}
if (preg_match('/\sdata-page="([^"]+)"/', $content, $match) !== 1) {
return null;
}
$decoded = json_decode(html_entity_decode($match[1]), true);
if (is_array($decoded)
&& isset($decoded['component'])
&& is_string($decoded['component'])
&& $decoded['component'] !== '') {
return $decoded['component'];
}
return null;
}
private static function readContent(object $response): ?string
{
if (! method_exists($response, 'getContent')) {
return null;
}
/** @var mixed $content */
$content = $response->getContent();
return is_string($content) ? $content : null;
}
}

View File

@ -0,0 +1,270 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
/**
* Fallback parser for ES module imports under `resources/js/`.
*
* Used only when the Node helper (`bin/pest-tia-vite-deps.mjs`) is
* unavailable — typically when Node isn't on `PATH` or the user's
* `vite.config.*` can't be loaded. Pure PHP, so it degrades
* gracefully on locked-down environments but cannot match the
* full-fidelity Vite resolver.
*
* Known limits (intentional — preserving correctness over precision):
* - Only `@/` and `~/` aliases recognised (both resolve to
* `resources/js/`, the community default). Custom aliases from
* `vite.config.ts` are ignored; anything we can't resolve is
* simply skipped and falls through to the watch-pattern safety
* net.
* - Dynamic imports with variable expressions
* (`import(`./${name}`.vue)`) can't be resolved; the literal
* prefix is ignored and the caller over-runs. Safe.
* - Vue SFC `<script>` blocks parsed whole; imports inside
* `<template>` blocks (rare but legal) are not scanned.
*
* Output shape mirrors the Node helper: project-relative source path
* → sorted list of component names of pages that depend on it.
*
* @internal
*/
final class JsImportParser
{
private const array PAGE_EXTENSIONS = ['vue', 'tsx', 'jsx', 'svelte'];
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".
*
* @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;
if (! is_dir($pagesRoot)) {
return [];
}
$reverse = [];
foreach (self::collectPages($pagesRoot) as $pageAbs) {
$component = self::componentName($pagesRoot, $pageAbs);
if ($component === null) {
continue;
}
$visited = [];
self::collectTransitive($pageAbs, $projectRoot, $jsRoot, $visited);
foreach (array_keys($visited) as $depAbs) {
if ($depAbs === $pageAbs) {
continue;
}
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($depAbs, strlen($projectRoot) + 1));
$reverse[$rel][$component] = true;
}
}
$out = [];
foreach ($reverse as $path => $components) {
$names = array_keys($components);
sort($names);
$out[$path] = $names;
}
ksort($out);
return $out;
}
/**
* @return list<string>
*/
private static function collectPages(string $pagesRoot): array
{
$out = [];
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($pagesRoot, \FilesystemIterator::SKIP_DOTS),
);
foreach ($iterator as $fileInfo) {
if (! $fileInfo->isFile()) {
continue;
}
$ext = strtolower((string) $fileInfo->getExtension());
if (in_array($ext, self::PAGE_EXTENSIONS, true)) {
$out[] = $fileInfo->getPathname();
}
}
return $out;
}
private static function componentName(string $pagesRoot, string $pageAbs): ?string
{
$rel = str_replace(DIRECTORY_SEPARATOR, '/', substr($pageAbs, strlen($pagesRoot) + 1));
$dot = strrpos($rel, '.');
if ($dot === false) {
return null;
}
$name = substr($rel, 0, $dot);
return $name === '' ? null : $name;
}
/**
* @param array<string, true> $visited
*/
private static function collectTransitive(string $fileAbs, string $projectRoot, string $jsRoot, array &$visited): void
{
if (isset($visited[$fileAbs])) {
return;
}
$visited[$fileAbs] = true;
$source = self::loadSource($fileAbs);
if ($source === null) {
return;
}
foreach (self::extractImports($source) as $spec) {
$resolved = self::resolveImport($spec, $fileAbs, $jsRoot);
if ($resolved === null) {
continue;
}
if (! is_file($resolved)) {
continue;
}
self::collectTransitive($resolved, $projectRoot, $jsRoot, $visited);
}
}
/**
* Loads the importable region of a file. For Vue SFCs, only the
* `<script>` block is relevant for imports; ignoring the rest
* avoids false-positive matches inside `<template>` attributes.
*/
private static function loadSource(string $fileAbs): ?string
{
$content = @file_get_contents($fileAbs);
if ($content === false) {
return null;
}
if (str_ends_with(strtolower($fileAbs), '.vue')) {
$scripts = [];
if (preg_match_all('/<script[^>]*>(.*?)<\/script>/si', $content, $m) !== false) {
foreach ($m[1] as $block) {
$scripts[] = $block;
}
}
return implode("\n", $scripts);
}
return $content;
}
/**
* Picks out every `import … from '…'` / `import '…'` / `import('…')`
* target. We strip line comments first so a commented-out import
* doesn't bloat the dep set.
*
* @return list<string>
*/
private static function extractImports(string $source): array
{
$stripped = preg_replace('#//[^\n]*#', '', $source) ?? $source;
$stripped = preg_replace('#/\*.*?\*/#s', '', $stripped) ?? $stripped;
$specs = [];
if (preg_match_all('/\bimport\s+(?:[^\'"()]*?\s+from\s+)?[\'"]([^\'"]+)[\'"]/', $stripped, $matches) !== false) {
foreach ($matches[1] as $spec) {
$specs[] = $spec;
}
}
if (preg_match_all('/\bimport\(\s*[\'"]([^\'"]+)[\'"]\s*\)/', $stripped, $matches) !== false) {
foreach ($matches[1] as $spec) {
$specs[] = $spec;
}
}
return $specs;
}
private static function resolveImport(string $spec, string $importerAbs, string $jsRoot): ?string
{
if ($spec === '' || $spec[0] === '.' || $spec[0] === '/') {
return self::resolveRelative($spec, $importerAbs);
}
if (str_starts_with($spec, '@/') || str_starts_with($spec, '~/')) {
$tail = substr($spec, 2);
return self::withExtension($jsRoot.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $tail));
}
// Anything else is either a node_modules package or an
// unrecognised alias — skip. The watch-pattern fallback
// handles the safety-net case for non-matched paths.
return null;
}
private static function resolveRelative(string $spec, string $importerAbs): ?string
{
if ($spec === '' || $spec[0] === '/') {
return null;
}
$base = dirname($importerAbs);
$path = $base.DIRECTORY_SEPARATOR.str_replace('/', DIRECTORY_SEPARATOR, $spec);
return self::withExtension($path);
}
/**
* Imports may omit the extension or point at a directory (index.vue,
* index.ts). Probe the common targets in order.
*/
private static function withExtension(string $path): ?string
{
if (is_file($path)) {
return realpath($path) ?: $path;
}
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
$candidate = $path.'.'.$ext;
if (is_file($candidate)) {
return realpath($candidate) ?: $candidate;
}
}
foreach (self::RESOLVABLE_EXTENSIONS as $ext) {
$candidate = $path.DIRECTORY_SEPARATOR.'index.'.$ext;
if (is_file($candidate)) {
return realpath($candidate) ?: $candidate;
}
}
return null;
}
}

View File

@ -0,0 +1,142 @@
<?php
declare(strict_types=1);
namespace Pest\Plugins\Tia;
use Symfony\Component\Process\ExecutableFinder;
use Symfony\Component\Process\Process;
/**
* Builds a reverse dependency map for the project's JS sources under
* `resources/js/**` — for every source file, the list of Inertia page
* components that transitively import it.
*
* Tries two resolvers in order:
*
* 1. **Node helper** (`bin/pest-tia-vite-deps.mjs`). Spins up a
* headless Vite server in middleware mode, walks Vite's own
* module graph for each page entry, and outputs JSON. Uses the
* project's real `vite.config.*`, so aliases, plugins, and SFC
* transformers produce the same graph Vite itself would use.
*
* 2. **PHP fallback** (`JsImportParser`). Regex-scans ES imports
* and resolves `@/` / `~/` aliases manually. Strictly less
* precise — anything it can't resolve is skipped, leaving the
* caller to fall back to the broad watch pattern. Only kicks in
* when the Node helper is unusable (no Node on PATH, no Vite
* installed, vite.config fails to load).
*
* Callers invoke this at record time; results are persisted into the
* graph so replay never re-runs the resolver. On stale-map detection
* the callers decide whether to rebuild.
*
* @internal
*/
final class JsModuleGraph
{
private const int NODE_TIMEOUT_SECONDS = 25;
/**
* @return array<string, list<string>> project-relative source path → sorted list of page component names
*/
public static function build(string $projectRoot): array
{
$viaNode = self::tryNodeHelper($projectRoot);
if ($viaNode !== null) {
return $viaNode;
}
return JsImportParser::parse($projectRoot);
}
/**
* True when the project looks like a Vite + Node project we can
* ask for a module graph. Gate for callers that want to skip the
* resolver entirely on non-Vite apps.
*/
public static function isApplicable(string $projectRoot): bool
{
return self::hasViteConfig($projectRoot) && is_dir($projectRoot.DIRECTORY_SEPARATOR.'resources'.DIRECTORY_SEPARATOR.'js'.DIRECTORY_SEPARATOR.'Pages');
}
/**
* @return array<string, list<string>>|null
*/
private static function tryNodeHelper(string $projectRoot): ?array
{
if (! self::hasViteConfig($projectRoot)) {
return null;
}
if (! is_dir($projectRoot.DIRECTORY_SEPARATOR.'node_modules'.DIRECTORY_SEPARATOR.'vite')) {
return null;
}
$nodeBinary = (new ExecutableFinder)->find('node');
if ($nodeBinary === null) {
return null;
}
$helperPath = dirname(__DIR__, 3).DIRECTORY_SEPARATOR.'bin'.DIRECTORY_SEPARATOR.'pest-tia-vite-deps.mjs';
if (! is_file($helperPath)) {
return null;
}
$process = new Process([$nodeBinary, $helperPath, $projectRoot], $projectRoot);
$process->setTimeout(self::NODE_TIMEOUT_SECONDS);
$process->run();
if (! $process->isSuccessful()) {
return null;
}
/** @var mixed $decoded */
$decoded = json_decode($process->getOutput(), true);
if (! is_array($decoded)) {
return null;
}
$out = [];
foreach ($decoded as $path => $components) {
if (! is_string($path)) {
continue;
}
if (! is_array($components)) {
continue;
}
$names = [];
foreach ($components as $component) {
if (is_string($component) && $component !== '') {
$names[] = $component;
}
}
if ($names !== []) {
sort($names);
$out[$path] = $names;
}
}
ksort($out);
return $out;
}
private static function hasViteConfig(string $projectRoot): bool
{
foreach (['vite.config.ts', 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.mts'] as $name) {
if (is_file($projectRoot.DIRECTORY_SEPARATOR.$name)) {
return true;
}
}
return false;
}
}

View File

@ -39,6 +39,17 @@ final class Recorder
*/
private array $perTestTables = [];
/**
* Aggregated map: absolute test file → set<Inertia component name>.
* Populated by `InertiaEdges` from Inertia responses observed at
* request-handled time; consumed at record finalize to populate
* the graph's per-test component edges that drive Vue / React
* page-file impact analysis.
*
* @var array<string, array<string, true>>
*/
private array $perTestInertiaComponents = [];
/**
* Cached class → test file resolution.
*
@ -205,6 +216,32 @@ final class Recorder
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
}
/**
* Records that the currently-running test server-side-rendered the
* named Inertia component. The name is whatever
* `Inertia::render($component, …)` was called with — typically a
* slash-separated path like `Users/Show` that maps to
* `resources/js/Pages/Users/Show.vue`. No-op outside a test window
* so the underlying listener can stay armed without leaking
* state between tests.
*/
public function linkInertiaComponent(string $component): void
{
if (! $this->active) {
return;
}
if ($this->currentTestFile === null) {
return;
}
if ($component === '') {
return;
}
$this->perTestInertiaComponents[$this->currentTestFile][$component] = true;
}
/**
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
*/
@ -235,6 +272,22 @@ final class Recorder
return $out;
}
/**
* @return array<string, array<int, string>> absolute test file → sorted list of Inertia component names.
*/
public function perTestInertiaComponents(): array
{
$out = [];
foreach ($this->perTestInertiaComponents as $testFile => $components) {
$names = array_keys($components);
sort($names);
$out[$testFile] = $names;
}
return $out;
}
private function resolveTestFile(string $className, string $fallbackFile): ?string
{
if (array_key_exists($className, $this->classFileCache)) {
@ -301,6 +354,7 @@ final class Recorder
$this->currentTestFile = null;
$this->perTestFiles = [];
$this->perTestTables = [];
$this->perTestInertiaComponents = [];
$this->classFileCache = [];
$this->active = false;
}

View File

@ -31,11 +31,18 @@ final readonly class Inertia implements WatchDefault
: $testPath;
return [
// Inertia page components (React / Vue / Svelte).
'resources/js/Pages/**/*.vue' => [$testPath, $browserDir],
'resources/js/Pages/**/*.tsx' => [$testPath, $browserDir],
'resources/js/Pages/**/*.jsx' => [$testPath, $browserDir],
'resources/js/Pages/**/*.svelte' => [$testPath, $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.
'resources/js/Pages/**/*.vue' => [$browserDir],
'resources/js/Pages/**/*.tsx' => [$browserDir],
'resources/js/Pages/**/*.jsx' => [$browserDir],
'resources/js/Pages/**/*.svelte' => [$browserDir],
// Shared layouts / components consumed by pages.
'resources/js/Layouts/**/*.vue' => [$browserDir],