mirror of
https://github.com/pestphp/pest.git
synced 2026-04-24 07:57:29 +02:00
wip
This commit is contained in:
181
bin/pest-tia-vite-deps.mjs
Normal file
181
bin/pest-tia-vite-deps.mjs
Normal 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)
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ use Pest\Exceptions\DatasetArgumentsMismatch;
|
|||||||
use Pest\Panic;
|
use Pest\Panic;
|
||||||
use Pest\Plugins\Tia;
|
use Pest\Plugins\Tia;
|
||||||
use Pest\Plugins\Tia\BladeEdges;
|
use Pest\Plugins\Tia\BladeEdges;
|
||||||
|
use Pest\Plugins\Tia\InertiaEdges;
|
||||||
use Pest\Plugins\Tia\Recorder;
|
use Pest\Plugins\Tia\Recorder;
|
||||||
use Pest\Plugins\Tia\TableTracker;
|
use Pest\Plugins\Tia\TableTracker;
|
||||||
use Pest\Preset;
|
use Pest\Preset;
|
||||||
@ -328,6 +329,7 @@ trait Testable
|
|||||||
if ($recorder instanceof Recorder) {
|
if ($recorder instanceof Recorder) {
|
||||||
BladeEdges::arm($recorder);
|
BladeEdges::arm($recorder);
|
||||||
TableTracker::arm($recorder);
|
TableTracker::arm($recorder);
|
||||||
|
InertiaEdges::arm($recorder);
|
||||||
}
|
}
|
||||||
|
|
||||||
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
$beforeEach = TestSuite::getInstance()->beforeEach->get(self::$__filename)[1];
|
||||||
|
|||||||
@ -14,6 +14,7 @@ use Pest\Plugins\Tia\Contracts\State;
|
|||||||
use Pest\Plugins\Tia\CoverageCollector;
|
use Pest\Plugins\Tia\CoverageCollector;
|
||||||
use Pest\Plugins\Tia\Fingerprint;
|
use Pest\Plugins\Tia\Fingerprint;
|
||||||
use Pest\Plugins\Tia\Graph;
|
use Pest\Plugins\Tia\Graph;
|
||||||
|
use Pest\Plugins\Tia\JsModuleGraph;
|
||||||
use Pest\Plugins\Tia\Recorder;
|
use Pest\Plugins\Tia\Recorder;
|
||||||
use Pest\Plugins\Tia\ResultCollector;
|
use Pest\Plugins\Tia\ResultCollector;
|
||||||
use Pest\Plugins\Tia\WatchPatterns;
|
use Pest\Plugins\Tia\WatchPatterns;
|
||||||
@ -413,9 +414,10 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
}
|
}
|
||||||
|
|
||||||
$perTestTables = $recorder->perTestTables();
|
$perTestTables = $recorder->perTestTables();
|
||||||
|
$perTestInertia = $recorder->perTestInertiaComponents();
|
||||||
|
|
||||||
if (Parallel::isWorker()) {
|
if (Parallel::isWorker()) {
|
||||||
$this->flushWorkerPartial($perTest, $perTestTables);
|
$this->flushWorkerPartial($perTest, $perTestTables, $perTestInertia);
|
||||||
$recorder->reset();
|
$recorder->reset();
|
||||||
$this->coverageCollector->reset();
|
$this->coverageCollector->reset();
|
||||||
|
|
||||||
@ -439,6 +441,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
);
|
);
|
||||||
$graph->replaceEdges($perTest);
|
$graph->replaceEdges($perTest);
|
||||||
$graph->replaceTestTables($perTestTables);
|
$graph->replaceTestTables($perTestTables);
|
||||||
|
$graph->replaceTestInertiaComponents($perTestInertia);
|
||||||
|
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
||||||
$graph->pruneMissingTests();
|
$graph->pruneMissingTests();
|
||||||
|
|
||||||
// Fold in the results collected during this same record run. The
|
// Fold in the results collected during this same record run. The
|
||||||
@ -527,6 +531,7 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
|
|
||||||
$mergedFiles = [];
|
$mergedFiles = [];
|
||||||
$mergedTables = [];
|
$mergedTables = [];
|
||||||
|
$mergedInertia = [];
|
||||||
|
|
||||||
foreach ($partialKeys as $key) {
|
foreach ($partialKeys as $key) {
|
||||||
$data = $this->readPartial($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);
|
$this->state->delete($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -570,6 +585,12 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable
|
|||||||
$finalisedTables[$testFile] = array_keys($tableSet);
|
$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
|
// Empty-edges guard: if every worker returned no edges it almost
|
||||||
// always means the coverage driver wasn't loaded in the workers
|
// always means the coverage driver wasn't loaded in the workers
|
||||||
// (common footgun with custom PHP ini scan dirs, Herd profiles,
|
// (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->replaceEdges($finalised);
|
||||||
$graph->replaceTestTables($finalisedTables);
|
$graph->replaceTestTables($finalisedTables);
|
||||||
|
$graph->replaceTestInertiaComponents($finalisedInertia);
|
||||||
|
$graph->replaceJsFileToComponents(JsModuleGraph::build($projectRoot));
|
||||||
$graph->pruneMissingTests();
|
$graph->pruneMissingTests();
|
||||||
|
|
||||||
if (! $this->saveGraph($graph)) {
|
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>> $perTestFiles
|
||||||
* @param array<string, array<int, string>> $perTestTables
|
* @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([
|
$json = json_encode([
|
||||||
'files' => $perTestFiles,
|
'files' => $perTestFiles,
|
||||||
'tables' => $perTestTables,
|
'tables' => $perTestTables,
|
||||||
|
'inertia' => $perTestInertiaComponents,
|
||||||
], JSON_UNESCAPED_SLASHES);
|
], JSON_UNESCAPED_SLASHES);
|
||||||
|
|
||||||
if ($json === false) {
|
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
|
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'] : [];
|
$filesSource = is_array($data['files'] ?? null) ? $data['files'] : [];
|
||||||
$tablesSource = is_array($data['tables'] ?? null) ? $data['tables'] : [];
|
$tablesSource = is_array($data['tables'] ?? null) ? $data['tables'] : [];
|
||||||
|
$inertiaSource = is_array($data['inertia'] ?? null) ? $data['inertia'] : [];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'files' => $this->cleanPartialSection($filesSource),
|
'files' => $this->cleanPartialSection($filesSource),
|
||||||
'tables' => $this->cleanPartialSection($tablesSource),
|
'tables' => $this->cleanPartialSection($tablesSource),
|
||||||
|
'inertia' => $this->cleanPartialSection($inertiaSource),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -40,7 +40,19 @@ final readonly class Fingerprint
|
|||||||
// coverage, which would leave every DB test invalidated by
|
// coverage, which would leave every DB test invalidated by
|
||||||
// any migration change — force a rebuild so the new edges
|
// any migration change — force a rebuild so the new edges
|
||||||
// are populated.
|
// 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{
|
* @return array{
|
||||||
|
|||||||
@ -58,6 +58,33 @@ final class Graph
|
|||||||
*/
|
*/
|
||||||
private array $testTables = [];
|
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.
|
* 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
|
// 1. Coverage-edge lookup (PHP → PHP). Migrations are already
|
||||||
// handled above; skipping them here prevents their always-on
|
// handled above; skipping them here prevents their always-on
|
||||||
// coverage edges from invalidating the whole DB suite.
|
// 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
|
// (exotic syntax, raw SQL) are funneled back in here too so
|
||||||
// broad invalidation still kicks in for edge cases we can't
|
// broad invalidation still kicks in for edge cases we can't
|
||||||
// parse.
|
// 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;
|
$unknownToGraph = $unparseableMigrations;
|
||||||
foreach ($nonMigrationPaths as $rel) {
|
foreach ($nonMigrationPaths as $rel) {
|
||||||
|
if (isset($preciselyHandledPages[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (isset($sharedFilesResolved[$rel])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (! isset($this->fileIds[$rel])) {
|
if (! isset($this->fileIds[$rel])) {
|
||||||
$unknownToGraph[] = $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
|
* Projects under Laravel conventionally keep migrations at
|
||||||
* `database/migrations/`. We recognise the directory as a prefix
|
* `database/migrations/`. We recognise the directory as a prefix
|
||||||
@ -559,6 +754,58 @@ final class Graph
|
|||||||
return TableExtractor::fromMigrationSource($content);
|
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
|
* Drops edges whose test file no longer exists on disk. Prevents the graph
|
||||||
* from keeping stale entries for deleted / renamed tests that would later
|
* 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) {
|
foreach (array_keys($this->testTables) as $testRel) {
|
||||||
if (! is_file($root.$testRel)) {
|
if (! is_file($root.$testRel)) {
|
||||||
unset($this->testTables[$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;
|
return $graph;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -642,6 +942,8 @@ final class Graph
|
|||||||
'edges' => $this->edges,
|
'edges' => $this->edges,
|
||||||
'baselines' => $this->baselines,
|
'baselines' => $this->baselines,
|
||||||
'test_tables' => $this->testTables,
|
'test_tables' => $this->testTables,
|
||||||
|
'test_inertia_components' => $this->testInertiaComponents,
|
||||||
|
'js_file_to_components' => $this->jsFileToComponents,
|
||||||
];
|
];
|
||||||
|
|
||||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||||
|
|||||||
170
src/Plugins/Tia/InertiaEdges.php
Normal file
170
src/Plugins/Tia/InertiaEdges.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
270
src/Plugins/Tia/JsImportParser.php
Normal file
270
src/Plugins/Tia/JsImportParser.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/Plugins/Tia/JsModuleGraph.php
Normal file
142
src/Plugins/Tia/JsModuleGraph.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,17 @@ final class Recorder
|
|||||||
*/
|
*/
|
||||||
private array $perTestTables = [];
|
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.
|
* Cached class → test file resolution.
|
||||||
*
|
*
|
||||||
@ -205,6 +216,32 @@ final class Recorder
|
|||||||
$this->perTestTables[$this->currentTestFile][strtolower($table)] = true;
|
$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.
|
* @return array<string, array<int, string>> absolute test file → list of absolute source files.
|
||||||
*/
|
*/
|
||||||
@ -235,6 +272,22 @@ final class Recorder
|
|||||||
return $out;
|
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
|
private function resolveTestFile(string $className, string $fallbackFile): ?string
|
||||||
{
|
{
|
||||||
if (array_key_exists($className, $this->classFileCache)) {
|
if (array_key_exists($className, $this->classFileCache)) {
|
||||||
@ -301,6 +354,7 @@ final class Recorder
|
|||||||
$this->currentTestFile = null;
|
$this->currentTestFile = null;
|
||||||
$this->perTestFiles = [];
|
$this->perTestFiles = [];
|
||||||
$this->perTestTables = [];
|
$this->perTestTables = [];
|
||||||
|
$this->perTestInertiaComponents = [];
|
||||||
$this->classFileCache = [];
|
$this->classFileCache = [];
|
||||||
$this->active = false;
|
$this->active = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,11 +31,18 @@ final readonly class Inertia implements WatchDefault
|
|||||||
: $testPath;
|
: $testPath;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// Inertia page components (React / Vue / Svelte).
|
// Inertia page components (React / Vue / Svelte). Scoped to
|
||||||
'resources/js/Pages/**/*.vue' => [$testPath, $browserDir],
|
// `$browserDir` only — a Vue/React edit cannot change the
|
||||||
'resources/js/Pages/**/*.tsx' => [$testPath, $browserDir],
|
// output of a server-side Inertia test (those assert on the
|
||||||
'resources/js/Pages/**/*.jsx' => [$testPath, $browserDir],
|
// component *name* returned by `Inertia::render()`, not its
|
||||||
'resources/js/Pages/**/*.svelte' => [$testPath, $browserDir],
|
// 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.
|
// Shared layouts / components consumed by pages.
|
||||||
'resources/js/Layouts/**/*.vue' => [$browserDir],
|
'resources/js/Layouts/**/*.vue' => [$browserDir],
|
||||||
|
|||||||
Reference in New Issue
Block a user