@nx/vite
Version:
336 lines (335 loc) • 14.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = migrateToVitest3;
const devkit_1 = require("@nx/devkit");
const internal_1 = require("@nx/js/internal");
const tsquery_1 = require("@phenomnomnominal/tsquery");
const ast_edits_1 = require("./lib/ast-edits");
const ci_files_1 = require("./lib/ci-files");
const vitest_config_files_1 = require("./lib/vitest-config-files");
let ts;
/**
* Hybrid migration paired with `ai-instructions-for-vitest-3.md`. Applies the
* deterministic, AST-tractable Vitest 1.x/2.x → 3.x changes mechanically and
* returns an `agentContext` describing any shape it could not handle, so the
* paired prompt's agent can finish those by hand.
*/
async function migrateToVitest3(tree) {
ts ??= (0, internal_1.ensureTypescript)();
const unhandled = [];
(0, vitest_config_files_1.visitVitestConfigFiles)(tree, (filePath) => processVitestConfig(tree, filePath, unhandled));
(0, devkit_1.visitNotIgnoredFiles)(tree, '', (filePath) => {
if (!(0, vitest_config_files_1.isJsOrTsFile)(filePath))
return;
processSnapshotEnvironmentImport(tree, filePath, unhandled);
});
(0, devkit_1.visitNotIgnoredFiles)(tree, '', (filePath) => {
if (filePath.endsWith('package.json')) {
processPackageJson(tree, filePath);
}
else if (filePath.endsWith('project.json')) {
processProjectJson(tree, filePath);
}
});
scanCiFiles(tree, unhandled);
await (0, devkit_1.formatFiles)(tree);
// Hybrid migration return shape: `agentContext` (when populated) is
// forwarded to the paired prompt's agent. When no agent runs, the field is
// dropped silently per the contract — safe on master and pre-agentic flows.
if (unhandled.length > 0)
return { agentContext: unhandled };
return;
}
function processVitestConfig(tree, filePath, unhandled) {
const contents = tree.read(filePath, 'utf-8');
// Cheap prechecks: skip parsing if none of the v3 targets appear.
const hasC8Provider = /['"]c8['"]/.test(contents);
const hasNoneProvider = /['"]none['"]/.test(contents);
const hasIndexScripts = contents.includes('indexScripts');
if (!hasC8Provider && !hasNoneProvider && !hasIndexScripts)
return;
const sourceFile = (0, tsquery_1.ast)(contents);
const edits = [];
const c8EditCountBefore = edits.length;
if (hasC8Provider) {
collectCoverageProviderC8Edits(sourceFile, contents, edits);
}
if (hasNoneProvider) {
collectBrowserProviderNoneEdits(sourceFile, contents, edits);
}
const indexEditCountBefore = edits.length;
if (hasIndexScripts) {
collectIndexScriptsRenameEdits(sourceFile, edits);
}
// Fallback signals: precheck hit a vitest-specific token but the AST didn't
// surface a shape we know how to rewrite (variable indirection, template
// literal, spread, ternary, etc.). Forward the file path so the agent can
// investigate.
if (hasC8Provider && edits.length === c8EditCountBefore) {
unhandled.push(`${filePath}: found a \`'c8'\` string but no direct \`coverage.provider: 'c8'\` assignment to rewrite. ` +
`If this file's coverage provider resolves to c8 via a variable, ternary, or spread, switch it to \`'v8'\`.`);
}
if (hasIndexScripts && edits.length === indexEditCountBefore) {
unhandled.push(`${filePath}: found an \`indexScripts\` reference but no direct \`browser.indexScripts\` assignment to rewrite. ` +
`If this is the legacy browser option, rename it to \`orchestratorScripts\`.`);
}
if (edits.length === 0)
return;
tree.write(filePath, (0, ast_edits_1.applyTextEdits)(contents, edits));
}
/**
* `test.coverage.provider: 'c8'` → `'v8'`. Anchored to the `test.coverage`
* sub-tree so a coincidental `'c8'` elsewhere isn't rewritten.
*/
function collectCoverageProviderC8Edits(sourceFile, contents, edits) {
const literals = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment:has(Identifier[name=provider]) > StringLiteral[value=c8]');
for (const lit of literals) {
if (!isInsideTestSubProperty(lit, 'coverage'))
continue;
edits.push((0, ast_edits_1.replaceStringLiteralValueEdit)(contents, lit, 'v8'));
}
}
/**
* `test.browser.provider: 'none'` → `'preview'`. Anchored similarly to v3-2b.
*/
function collectBrowserProviderNoneEdits(sourceFile, contents, edits) {
const literals = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment:has(Identifier[name=provider]) > StringLiteral[value=none]');
for (const lit of literals) {
if (!isInsideTestSubProperty(lit, 'browser'))
continue;
edits.push((0, ast_edits_1.replaceStringLiteralValueEdit)(contents, lit, 'preview'));
}
}
/**
* `test.browser.indexScripts` → `orchestratorScripts`.
*/
function collectIndexScriptsRenameEdits(sourceFile, edits) {
const identifiers = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=indexScripts]');
for (const id of identifiers) {
if (!isInsideTestSubProperty(id, 'browser'))
continue;
edits.push((0, ast_edits_1.replaceNodeTextEdit)(id, 'orchestratorScripts'));
}
}
/**
* `import { SnapshotEnvironment } from 'vitest'` → `from 'vitest/snapshot'`.
* Only applied when `SnapshotEnvironment` is the SOLE named binding. Mixed
* imports (e.g. `{ SnapshotEnvironment, vi }`) are logged for the agent —
* splitting the import is a semantic decision (does the v3.x `SnapshotEnvironment`
* still live in `vitest`? — no, it moved in v2).
*/
function processSnapshotEnvironmentImport(tree, filePath, unhandled) {
const contents = tree.read(filePath, 'utf-8');
if (!contents.includes('SnapshotEnvironment'))
return;
const sourceFile = (0, tsquery_1.ast)(contents);
const importDecls = (0, tsquery_1.query)(sourceFile, 'ImportDeclaration');
const edits = [];
for (const decl of importDecls) {
if (!ts.isStringLiteral(decl.moduleSpecifier))
continue;
if (decl.moduleSpecifier.text !== 'vitest')
continue;
const named = decl.importClause?.namedBindings;
if (!named || !ts.isNamedImports(named))
continue;
const elements = named.elements;
const hasSnapshotEnv = elements.some((el) => el.name.text === 'SnapshotEnvironment');
if (!hasSnapshotEnv)
continue;
if (elements.length === 1 && !decl.importClause?.name) {
// Sole named binding, no default import → flip the module specifier.
edits.push((0, ast_edits_1.replaceStringLiteralValueEdit)(contents, decl.moduleSpecifier, 'vitest/snapshot'));
}
else {
unhandled.push(`${filePath}: \`SnapshotEnvironment\` is imported from 'vitest' alongside other bindings. ` +
`Split it into a separate \`import { SnapshotEnvironment } from 'vitest/snapshot'\` statement.`);
}
}
if (edits.length > 0) {
tree.write(filePath, (0, ast_edits_1.applyTextEdits)(contents, edits));
}
}
const SEGFAULT_RETRY_RE = /\s*--segfault-retry(?:[= ]\d+)?(?=\s|$)/g;
// `\b` between `k` and `-` is still a word boundary, so the v3.x flag form
// `vitest --typecheck-helper` (hypothetical) would otherwise match. Use a
// lookahead requiring whitespace or end-of-string after the keyword.
const VITEST_TYPECHECK_RE = /\bvitest\s+typecheck(?=\s|$)/g;
function processPackageJson(tree, filePath) {
const contents = tree.read(filePath, 'utf-8');
let parsed;
try {
parsed = JSON.parse(contents);
}
catch {
return;
}
let changed = false;
// V3-1: strip `--segfault-retry` from script values.
// V3-3: `vitest typecheck` → `vitest --typecheck` in script values.
if (parsed.scripts && typeof parsed.scripts === 'object') {
for (const [name, value] of Object.entries(parsed.scripts)) {
if (typeof value !== 'string')
continue;
let updated = value;
if (updated.includes('--segfault-retry')) {
updated = updated.replace(SEGFAULT_RETRY_RE, '').trim();
}
updated = updated.replace(VITEST_TYPECHECK_RE, 'vitest --typecheck');
if (updated !== value) {
parsed.scripts[name] = updated;
changed = true;
}
}
}
// V3-2a: `@vitest/coverage-c8` → `@vitest/coverage-v8` in dep buckets,
// preserving the user's pin verbatim.
for (const bucket of [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
]) {
const deps = parsed[bucket];
if (!deps || typeof deps !== 'object')
continue;
if (!('@vitest/coverage-c8' in deps))
continue;
if ('@vitest/coverage-v8' in deps) {
// Both keys present; leave the v8 pin alone and just remove the c8 entry.
delete deps['@vitest/coverage-c8'];
}
else {
// Rebuild the bucket to preserve key order with c8 swapped for v8.
const rebuilt = {};
for (const [k, v] of Object.entries(deps)) {
if (k === '@vitest/coverage-c8')
rebuilt['@vitest/coverage-v8'] = v;
else
rebuilt[k] = v;
}
parsed[bucket] = rebuilt;
}
changed = true;
}
if (changed) {
const trailingNewline = contents.endsWith('\n') ? '\n' : '';
tree.write(filePath, JSON.stringify(parsed, null, 2) + trailingNewline);
}
}
/**
* Walk all targets in `project.json` and rewrite the same string-level
* concerns we handle in `package.json` scripts: `--segfault-retry` removal
* and `vitest typecheck` → `vitest --typecheck`. Applies to `options.args`
* (string or array), `options.command` (string), and `options.commands`
* (array of strings).
*/
function processProjectJson(tree, filePath) {
const contents = tree.read(filePath, 'utf-8');
let parsed;
try {
parsed = JSON.parse(contents);
}
catch {
return;
}
let changed = false;
const targets = parsed?.targets;
if (!targets || typeof targets !== 'object')
return;
for (const target of Object.values(targets)) {
const options = target?.options;
if (!options || typeof options !== 'object')
continue;
if (typeof options.args === 'string') {
const rewritten = rewriteScriptString(options.args);
if (rewritten !== options.args) {
options.args = rewritten;
changed = true;
}
}
else if (Array.isArray(options.args)) {
const filtered = options.args.filter((a) => typeof a !== 'string' || !/^--segfault-retry(?:=\d+)?$/.test(a.trim()));
if (filtered.length !== options.args.length) {
options.args = filtered;
changed = true;
}
}
if (typeof options.command === 'string') {
const rewritten = rewriteScriptString(options.command);
if (rewritten !== options.command) {
options.command = rewritten;
changed = true;
}
}
if (Array.isArray(options.commands)) {
for (let i = 0; i < options.commands.length; i++) {
const entry = options.commands[i];
if (typeof entry === 'string') {
const rewritten = rewriteScriptString(entry);
if (rewritten !== entry) {
options.commands[i] = rewritten;
changed = true;
}
}
}
}
}
if (changed) {
const trailingNewline = contents.endsWith('\n') ? '\n' : '';
tree.write(filePath, JSON.stringify(parsed, null, 2) + trailingNewline);
}
}
function rewriteScriptString(value) {
let updated = value;
if (updated.includes('--segfault-retry')) {
updated = updated.replace(SEGFAULT_RETRY_RE, '').trim();
}
updated = updated.replace(VITEST_TYPECHECK_RE, 'vitest --typecheck');
return updated;
}
/**
* Scan CI provider configs for the v3 legacy tokens. YAML edits aren't done
* mechanically (structure/comment/anchor risk); the file path + tokens are
* forwarded to the agent.
*/
function scanCiFiles(tree, unhandled) {
(0, ci_files_1.visitCiFiles)(tree, (filePath, contents) => {
const tokens = [];
if (contents.includes('--segfault-retry')) {
tokens.push('`--segfault-retry`');
}
if (VITEST_TYPECHECK_RE.test(contents)) {
tokens.push('`vitest typecheck`');
}
// Reset lastIndex because of /g.
VITEST_TYPECHECK_RE.lastIndex = 0;
if (tokens.length === 0)
return;
unhandled.push(`${filePath}: found legacy token(s) in this CI config: ${tokens.join(', ')}. ` +
`Remove \`--segfault-retry\` (the option was removed in Vitest 2.0) and rewrite \`vitest typecheck\` as \`vitest --typecheck\` (the subcommand was replaced by the flag).`);
});
}
/**
* Returns true if `node` lives inside a property named `propertyName` whose
* own parent is a property named `test` — i.e. anchored to the vitest
* `test.<propertyName>` sub-tree of the config.
*/
function isInsideTestSubProperty(node, propertyName) {
const owningProperty = findEnclosingPropertyAssignment(node, propertyName);
if (!owningProperty)
return false;
return !!findEnclosingPropertyAssignment(owningProperty, 'test');
}
function findEnclosingPropertyAssignment(node, name) {
let current = node.parent;
while (current) {
if (ts.isPropertyAssignment(current) &&
ts.isIdentifier(current.name) &&
current.name.text === name) {
return current;
}
current = current.parent;
}
return undefined;
}