diff --git a/bin/pest-tia-vite-deps.mjs b/bin/pest-tia-vite-deps.mjs index 4e8805ff..b5c3107b 100644 --- a/bin/pest-tia-vite-deps.mjs +++ b/bin/pest-tia-vite-deps.mjs @@ -1,24 +1,50 @@ #!/usr/bin/env node -import { readdir } from 'node:fs/promises' +import { readdir, readFile } 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 { resolve, relative, extname, sep, join } from 'node:path' import { pathToFileURL } from 'node:url' const PAGE_EXTENSIONS = new Set(['.vue', '.tsx', '.jsx', '.svelte']) +const ASSET_EXT_RE = /\.(css|scss|sass|less|styl|stylus|svg|png|jpe?g|gif|webp|avif|ico|bmp|woff2?|ttf|eot|otf|md|mdx|txt|html|mp4|webm|mp3|wav|ogg|m4a|pdf|wasm|glsl|frag|vert)$/i 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) -const CONCURRENCY = Math.max(1, Number.parseInt(process.env.TIA_VITE_CONCURRENCY ?? '16', 10)) -async function loadVite() { +async function loadRolldown() { const projectRequire = createRequire(join(PROJECT_ROOT, 'package.json')) - const vitePath = projectRequire.resolve('vite') - return await import(pathToFileURL(vitePath).href) + const path = projectRequire.resolve('rolldown') + return await import(pathToFileURL(path).href) } -const { createServer } = await loadVite() +async function readJsonWithComments(path) { + const raw = await readFile(path, 'utf8') + const stripped = raw + .replace(/\/\*[\s\S]*?\*\//g, '') + .replace(/(^|[^:])\/\/[^\n]*/g, '$1') + return JSON.parse(stripped) +} + +async function loadAliasFromTsconfig() { + const alias = {} + for (const name of ['tsconfig.json', 'jsconfig.json']) { + const p = join(PROJECT_ROOT, name) + if (!existsSync(p)) continue + let cfg + try { cfg = await readJsonWithComments(p) } catch { continue } + const baseUrl = resolve(PROJECT_ROOT, cfg?.compilerOptions?.baseUrl ?? '.') + const paths = cfg?.compilerOptions?.paths ?? {} + for (const [key, targets] of Object.entries(paths)) { + if (!key.endsWith('/*')) continue + const t0 = Array.isArray(targets) ? targets[0] : null + if (typeof t0 !== 'string' || !t0.endsWith('/*')) continue + const aliasKey = key.slice(0, -2) + if (alias[aliasKey] !== undefined) continue + alias[aliasKey] = resolve(baseUrl, t0.slice(0, -2)) + } + } + return alias +} async function listPageFiles(pagesDir) { if (!existsSync(pagesDir)) return [] @@ -44,6 +70,14 @@ function componentNameFor(pageAbs, pagesDir) { return rel.slice(0, rel.length - ext.length) } +function isLocalSpecifier(source, aliasKeys) { + if (source.startsWith('.') || source.startsWith('/')) return true + for (const key of aliasKeys) { + if (source === key || source.startsWith(key + '/')) return true + } + return false +} + async function main() { const pagesDir = resolve(PROJECT_ROOT, PAGES_REL) const pages = await listPageFiles(pagesDir) @@ -53,100 +87,104 @@ async function main() { return } - const server = await createServer({ - configFile: undefined, // auto-detect vite.config.* - root: PROJECT_ROOT, - logLevel: 'silent', - clearScreen: false, - server: { - middlewareMode: true, - hmr: false, - watch: null, + const { rolldown } = await loadRolldown() + const alias = await loadAliasFromTsconfig() + const aliasKeys = Object.keys(alias) + + const graph = new Map() + + const collector = { + name: 'pest-tia-collector', + moduleParsed(info) { + const id = info.id + if (!id || id.startsWith('\0')) return + const deps = new Set() + for (const i of info.importedIds) if (i && !i.startsWith('\0')) deps.add(i) + for (const i of info.dynamicallyImportedIds) if (i && !i.startsWith('\0')) deps.add(i) + graph.set(id, deps) }, - appType: 'custom', - optimizeDeps: { disabled: true }, - }) - - const killer = setTimeout(() => { - server.close().catch(() => {}).finally(() => process.exit(2)) - }, TIMEOUT_MS) - - const reverse = new Map() - - const pageComponentCache = new Map() - for (const page of pages) { - pageComponentCache.set(page, componentNameFor(page, pagesDir)) } - const projectRootPosix = PROJECT_ROOT.split(sep).join('/') - const pageEntries = pages.map((pagePath) => ({ - pagePath, - pageComponent: pageComponentCache.get(pagePath), - pageUrl: '/' + posix.relative(projectRootPosix, pagePath.split(sep).join('/')), - })) + const externalBare = { + name: 'pest-tia-external-bare', + resolveId(source) { + if (!source) return null + if (isLocalSpecifier(source, aliasKeys)) return null + return { id: source, external: true } + }, + } + + const assetStub = { + name: 'pest-tia-asset-stub', + load(id) { + if (!id) return null + if (ASSET_EXT_RE.test(id)) { + return { code: 'export default null', moduleSideEffects: false } + } + return null + }, + } + + const input = Object.create(null) + for (let i = 0; i < pages.length; i++) input[`p${i}`] = pages[i] + + const bundle = await rolldown({ + input, + cwd: PROJECT_ROOT, + resolve: { + alias, + extensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.cjs', '.json'], + }, + transform: { jsx: 'preserve' }, + treeshake: false, + plugins: [externalBare, assetStub, collector], + logLevel: 'silent', + onLog: () => {}, + }) try { - let cursor = 0 - const workers = Array.from({ length: Math.min(CONCURRENCY, pageEntries.length) }, async () => { - while (true) { - const i = cursor++ - if (i >= pageEntries.length) return - const { pageUrl } = pageEntries[i] - try { - await server.transformRequest(pageUrl, { ssr: false }) - } catch { - // ignore, handled below when we look up the module - } - } - }) - await Promise.all(workers) + await bundle.generate({ format: 'esm' }) + } finally { + await bundle.close() + } - const transitiveCache = new Map() - const computeTransitive = (mod, stack) => { - const key = mod.file ?? mod.id - if (!key) return null - const cached = transitiveCache.get(key) - if (cached) return cached - if (stack.has(key)) return null // cycle: let the originating frame fold us in + const reverse = new Map() + const transitiveCache = new Map() - stack.add(key) - const acc = new Set() - for (const imported of mod.importedModules) { - const id = imported.file ?? imported.id - if (!id) continue - if (id.startsWith('\0')) continue + const computeTransitive = (id, stack) => { + const cached = transitiveCache.get(id) + if (cached) return cached + if (stack.has(id)) return null - if (id.startsWith(PROJECT_ROOT)) { - const rel = relative(PROJECT_ROOT, id).split(sep).join('/') + stack.add(id) + const acc = new Set() + const deps = graph.get(id) + if (deps) { + for (const dep of deps) { + if (!dep || dep.startsWith('\0')) continue + if (dep.startsWith(PROJECT_ROOT)) { + const rel = relative(PROJECT_ROOT, dep).split(sep).join('/') acc.add(rel) } - - const childKey = id - if (stack.has(childKey)) continue - const child = computeTransitive(imported, stack) + if (stack.has(dep)) continue + const child = computeTransitive(dep, stack) if (child) for (const r of child) acc.add(r) } - stack.delete(key) - transitiveCache.set(key, acc) - return acc } + stack.delete(id) + transitiveCache.set(id, acc) + return acc + } - for (const { pageComponent, pageUrl } of pageEntries) { - const pageModule = await server.moduleGraph.getModuleByUrl(pageUrl, false) - if (!pageModule) continue - - const reachable = computeTransitive(pageModule, new Set()) - if (!reachable) continue - - for (const rel of reachable) { - const bucket = reverse.get(rel) ?? new Set() - bucket.add(pageComponent) - reverse.set(rel, bucket) - } + for (const page of pages) { + const pageComponent = componentNameFor(page, pagesDir) + const reachable = computeTransitive(page, new Set()) + if (!reachable) continue + for (const rel of reachable) { + const bucket = reverse.get(rel) ?? new Set() + bucket.add(pageComponent) + reverse.set(rel, bucket) } - } finally { - clearTimeout(killer) - await server.close() } const payload = Object.create(null) diff --git a/src/Plugins/Tia.php b/src/Plugins/Tia.php index d5e3d2c3..e7b00649 100644 --- a/src/Plugins/Tia.php +++ b/src/Plugins/Tia.php @@ -249,7 +249,8 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable $alwaysEnabled = $watchPatterns->isEnabled() && (! $watchPatterns->isLocally() || Environment::name() === Environment::LOCAL); $enabled = $cliEnabled || $alwaysEnabled; - $this->filteredMode = $this->hasArgument(self::FILTERED_OPTION, $arguments) || $watchPatterns->isFiltered(); + $this->filteredMode = ($this->hasArgument(self::FILTERED_OPTION, $arguments) || $watchPatterns->isFiltered()) + && ! $this->hasExplicitPathArgument($arguments); $freshRequested = $this->hasArgument(self::FRESH_OPTION, $arguments); $this->forceRefetch = $this->hasArgument(self::REFETCH_OPTION, $arguments); @@ -1377,6 +1378,32 @@ final class Tia implements AddsOutput, HandlesArguments, Terminable return $coverage->coverage === true; } + /** + * @param array $arguments + */ + private function hasExplicitPathArgument(array $arguments): bool + { + $projectRoot = TestSuite::getInstance()->rootPath; + + foreach ($arguments as $arg) { + if ($arg === '' || str_starts_with($arg, '-')) { + continue; + } + + if (is_file($arg) || is_dir($arg)) { + return true; + } + + $absolute = rtrim($projectRoot, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR.ltrim($arg, DIRECTORY_SEPARATOR); + + if (is_file($absolute) || is_dir($absolute)) { + return true; + } + } + + return false; + } + /** * @param array $changedFiles */