UNPKG

@nx/vite

Version:

The Nx Plugin for building and testing applications using Vite

362 lines (360 loc) • 15.2 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.inlineVitestWorkspaceFiles = inlineVitestWorkspaceFiles; const devkit_1 = require("@nx/devkit"); const internal_1 = require("@nx/js/internal"); const tsquery_1 = require("@phenomnomnominal/tsquery"); const picomatch = require("picomatch"); const ast_edits_1 = require("./ast-edits"); const vitest_config_files_1 = require("./vitest-config-files"); let ts; // Mirrors vitest's own config lookup (CONFIG_NAMES x CONFIG_EXTENSIONS), // including its vitest-over-vite preference order. const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']; const VITEST_CONFIG_RE = /^vitest\.config\.(js|ts|mjs|mts|cjs|cts)$/; const VITE_CONFIG_RE = /^vite\.config\.(js|ts|mjs|mts|cjs|cts)$/; /** * Inline `vitest.workspace.*` files whose project list is a static array of * string literals into the sibling root `vitest.config.*` under * `test.projects` (creating the config file when none exists), then delete * the workspace file. Vitest 4 removed workspace files entirely. * * Anything not statically tractable (dynamic arrays, inline project objects, * non-object-literal configs, pre-existing `test.projects`) is left in place * and reported through `unhandled` for the agent. */ function inlineVitestWorkspaceFiles(tree, unhandled) { ts ??= (0, internal_1.ensureTypescript)(); const workspaceFiles = []; (0, devkit_1.visitNotIgnoredFiles)(tree, '', (filePath) => { if ((0, vitest_config_files_1.isVitestWorkspaceFile)(filePath)) workspaceFiles.push(filePath); }); for (const filePath of workspaceFiles) { let inlined = false; try { inlined = tryInlineWorkspaceFile(tree, filePath, unhandled); } catch { // A failed inline must never fail the migration; the agent handles it. inlined = false; } if (!inlined) { unhandled.push(`${filePath}: \`vitest.workspace.*\` files are removed in Vitest 4 and this one could not be inlined automatically. ` + `Inline its project list into the root \`vitest.config.*\` under \`test.projects\` and delete this file.`); } } return { foundWorkspaceFiles: workspaceFiles.length > 0 }; } function tryInlineWorkspaceFile(tree, filePath, unhandled) { const globs = extractStaticProjectGlobs(tree.read(filePath, 'utf-8')); if (!globs || globs.length === 0) return false; const dir = posixDirname(filePath); const { negations, advisories } = computeDualConfigNegations(tree, dir, filePath, globs); const entries = [...globs, ...negations]; const vitestConfigPath = findExistingConfig(tree, dir, 'vitest.config'); if (vitestConfigPath) { if (!tryAddProjectsToConfig(tree, vitestConfigPath, entries)) { return false; } } else if (findExistingConfig(tree, dir, 'vite.config')) { // A sibling vite.config may carry its own test block; creating a // vitest.config next to it would shadow it for test runs. Let the agent // decide where the projects belong. return false; } else { createVitestConfig(tree, dir, filePath, entries); } tree.delete(filePath); unhandled.push(...advisories); return true; } /** * Extracts the project globs from a workspace file when its default export is * a plain array of string literals, optionally wrapped in `defineWorkspace`. * Returns `null` for any other shape. */ function extractStaticProjectGlobs(contents) { const sourceFile = (0, tsquery_1.ast)(contents); const exportAssignment = sourceFile.statements.find((s) => ts.isExportAssignment(s) && !s.isExportEquals); if (!exportAssignment || !ts.isExportAssignment(exportAssignment)) { return null; } let expression = unwrapExpression(exportAssignment.expression); if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression) && expression.expression.text === 'defineWorkspace' && expression.arguments.length === 1) { expression = unwrapExpression(expression.arguments[0]); } if (!ts.isArrayLiteralExpression(expression)) return null; const globs = []; for (const element of expression.elements) { const unwrapped = unwrapExpression(element); if (!ts.isStringLiteral(unwrapped)) return null; globs.push(unwrapped.text); } return globs; } function unwrapExpression(expression) { while (ts.isAsExpression(expression) || ts.isSatisfiesExpression(expression) || ts.isParenthesizedExpression(expression)) { expression = expression.expression; } return expression; } function findExistingConfig(tree, dir, name) { for (const ext of CONFIG_EXTENSIONS) { const candidate = (0, devkit_1.joinPathFragments)(dir, `${name}${ext}`); if (tree.exists(candidate)) return candidate; } return undefined; } /** * Inserts `projects: [...]` into an existing config file's `test` block (or a * whole `test` block when missing). Returns false when the config shape is * not statically safe to edit. */ function tryAddProjectsToConfig(tree, configPath, entries) { const contents = tree.read(configPath, 'utf-8'); const configObject = findDefaultExportConfigObject((0, tsquery_1.ast)(contents)); if (!configObject) return false; if (hasSpreadOrUnsupportedProperty(configObject.properties, ['test'])) { return false; } // Duplicate keys are valid JS; editing the first occurrence would be dead // code since the last one wins at runtime. if (countPropertyAssignments(configObject, 'test') > 1) return false; const testProperty = findPropertyAssignment(configObject, 'test'); const projectsText = `projects: ${formatEntriesArray(entries)}`; if (testProperty) { const testObject = testProperty.initializer; if (!ts.isObjectLiteralExpression(testObject)) return false; if (hasSpreadOrUnsupportedProperty(testObject.properties, [ 'projects', 'workspace', ])) { return false; } if (findPropertyAssignment(testObject, 'projects') || findPropertyAssignment(testObject, 'workspace')) { // The config already defines its own project set; merging two project // lists is a semantic decision the agent must make. return false; } const insertPos = testObject.getStart() + 1; tree.write(configPath, (0, ast_edits_1.applyTextEdits)(contents, [ { start: insertPos, end: insertPos, replacement: `${projectsText},` }, ])); } else { const insertPos = configObject.getStart() + 1; tree.write(configPath, (0, ast_edits_1.applyTextEdits)(contents, [ { start: insertPos, end: insertPos, replacement: `test: {${projectsText},},`, }, ])); } return true; } function createVitestConfig(tree, dir, workspaceFilePath, entries) { const workspaceExt = workspaceFilePath.slice(workspaceFilePath.lastIndexOf('.')); // The generated file uses ESM syntax, so anything that isn't unambiguously // ESM-capable (.js depends on package type, .cts/.cjs are CJS) gets .mjs. const ext = ['.ts', '.mts', '.mjs'].includes(workspaceExt) ? workspaceExt : '.mjs'; tree.write((0, devkit_1.joinPathFragments)(dir, `vitest.config${ext}`), `import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { projects: ${formatEntriesArray(entries)}, }, }); `); } function formatEntriesArray(entries) { return `[${entries .map((e) => `'${e.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`) .join(', ')}]`; } /** * Vitest errors at startup when two matched config files resolve to the same * project name, which is the default outcome for directories holding both a * `vite.config.*` and a `vitest.config.*` (both default to the directory's * package name). For those directories, exclude the `vite.config.*` file via * a negative glob, matching vitest's own vitest-over-vite preference when it * resolves a directory entry. Directories whose two configs have statically * distinct names are intentionally separate projects and are left alone, as * is anything not statically determinable. */ function computeDualConfigNegations(tree, rootDir, workspaceFilePath, globs) { const matchers = globs .filter((g) => !g.startsWith('!')) // picomatch does not normalize a leading './', vitest's glob layer does. .map((g) => picomatch(g.replace(/^\.\//, ''), { dot: true })); const matchedByDir = new Map(); (0, devkit_1.visitNotIgnoredFiles)(tree, rootDir === '.' ? '' : rootDir, (filePath) => { const relativePath = relativeToDir(filePath, rootDir); if (!matchers.some((m) => m(relativePath))) return; const base = posixBasename(filePath); const dir = posixDirname(filePath); const pair = matchedByDir.get(dir) ?? {}; if (VITEST_CONFIG_RE.test(base)) pair.vitest = filePath; else if (VITE_CONFIG_RE.test(base)) pair.vite = filePath; matchedByDir.set(dir, pair); }); const negations = []; const advisories = []; for (const [dir, pair] of matchedByDir) { if (!pair.vite || !pair.vitest) continue; const viteName = determineStaticProjectName(tree, pair.vite); const vitestName = determineStaticProjectName(tree, pair.vitest); if (viteName === undefined || vitestName === undefined) { advisories.push(`${workspaceFilePath}: the inlined \`test.projects\` globs match both \`${pair.vite}\` and \`${pair.vitest}\`, and their project names could not be statically compared. ` + `If vitest fails at startup with "Project name ... is not unique", exclude one of them from \`test.projects\` with a negative glob (e.g. \`'!${relativeToDir(pair.vite, rootDir)}'\`).`); continue; } const resolvedViteName = viteName === 'default' ? defaultProjectName(tree, dir) : viteName.name; const resolvedVitestName = vitestName === 'default' ? defaultProjectName(tree, dir) : vitestName.name; if (resolvedViteName !== resolvedVitestName) continue; negations.push(`!${relativeToDir(pair.vite, rootDir)}`); } return { negations: negations.sort(), advisories: advisories.sort() }; } /** * Statically determines a config file's project name. Returns: * - `{ name }` for an explicit string-literal `test.name` * - `'default'` when no `test.name` is set (vitest falls back to the * directory's package.json name or basename) * - `undefined` when not statically determinable */ function determineStaticProjectName(tree, configPath) { const configObject = findDefaultExportConfigObject((0, tsquery_1.ast)(tree.read(configPath, 'utf-8'))); if (!configObject) return undefined; if (hasSpreadOrUnsupportedProperty(configObject.properties, ['test'])) { return undefined; } const testProperty = findPropertyAssignment(configObject, 'test'); if (!testProperty) return 'default'; if (!ts.isObjectLiteralExpression(testProperty.initializer)) { return undefined; } const testObject = testProperty.initializer; if (hasSpreadOrUnsupportedProperty(testObject.properties, ['name'])) { return undefined; } const nameProperty = findPropertyAssignment(testObject, 'name'); if (!nameProperty) return 'default'; if (ts.isStringLiteral(nameProperty.initializer)) { return { name: nameProperty.initializer.text }; } return undefined; } function defaultProjectName(tree, dir) { const packageJsonPath = (0, devkit_1.joinPathFragments)(dir, 'package.json'); if (tree.exists(packageJsonPath)) { try { const name = JSON.parse(tree.read(packageJsonPath, 'utf-8')).name; if (typeof name === 'string' && name) return name; } catch { } } return posixBasename(dir); } function findDefaultExportConfigObject(sourceFile) { const exportAssignment = sourceFile.statements.find((s) => ts.isExportAssignment(s) && !s.isExportEquals); if (!exportAssignment || !ts.isExportAssignment(exportAssignment)) { return undefined; } let expression = unwrapExpression(exportAssignment.expression); if (ts.isCallExpression(expression) && ts.isIdentifier(expression.expression) && expression.expression.text === 'defineConfig' && expression.arguments.length === 1) { expression = unwrapExpression(expression.arguments[0]); } return ts.isObjectLiteralExpression(expression) ? expression : undefined; } function countPropertyAssignments(objectLiteral, name) { let count = 0; for (const property of objectLiteral.properties) { if (ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && property.name.text === name) { count++; } } return count; } function findPropertyAssignment(objectLiteral, name) { for (const property of objectLiteral.properties) { if (ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) && property.name.text === name) { return property; } } return undefined; } /** * True when the object contains a spread (its contents can't be known * statically) or when any of `criticalNames` appears as something other than * a plain property assignment (shorthand, method, computed name), in which * case edits or reads keyed on those names would be unsafe. */ function hasSpreadOrUnsupportedProperty(properties, criticalNames) { for (const property of properties) { if (ts.isSpreadAssignment(property)) return true; if (ts.isPropertyAssignment(property)) { if (ts.isComputedPropertyName(property.name)) return true; continue; } const name = property.name && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined; if (name === undefined || criticalNames.includes(name)) return true; } return false; } function posixDirname(filePath) { const idx = filePath.lastIndexOf('/'); return idx === -1 ? '.' : filePath.slice(0, idx); } function posixBasename(filePath) { const idx = filePath.lastIndexOf('/'); return idx === -1 ? filePath : filePath.slice(idx + 1); } function relativeToDir(filePath, dir) { if (dir === '.' || dir === '') return filePath; return filePath.startsWith(`${dir}/`) ? filePath.slice(dir.length + 1) : filePath; }