@nx/vite
Version:
734 lines (733 loc) • 34.4 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = migrateToVitest4;
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("./lib/ast-edits");
const ci_files_1 = require("./lib/ci-files");
const generate_local_vitest_configs_1 = require("./lib/generate-local-vitest-configs");
const inline_vitest_workspace_1 = require("./lib/inline-vitest-workspace");
const vitest_config_files_1 = require("./lib/vitest-config-files");
let ts;
/**
* Hybrid migration paired with `ai-instructions-for-vitest-4.md`. Applies the
* deterministic, AST-tractable Vitest 3.x → 4.0 changes mechanically and
* forwards an `agentContext` describing any shape it could not handle so the
* paired prompt's agent can finish the rest.
*
* The "apply" set is intentionally narrow (renames, deletions of dead options,
* single-property string rewrites). Anything requiring cross-cutting surgery
* (e.g. pool option flattening, `deps.* → server.deps.*` merge, browser
* provider function-form rewrite, workspace file inlining) is detected and
* logged for the agent.
*/
async function migrateToVitest4(tree) {
ts ??= (0, internal_1.ensureTypescript)();
const unhandled = [];
(0, vitest_config_files_1.visitVitestConfigFiles)(tree, (filePath) => processVitestConfig(tree, filePath, unhandled));
// Workspace files are removed entirely in v4. Static ones are inlined into
// the root config here; dynamic shapes are forwarded to the agent.
const { foundWorkspaceFiles } = (0, inline_vitest_workspace_1.inlineVitestWorkspaceFiles)(tree, unhandled);
// The new root projects config breaks `vitest` runs from package
// directories that have no config of their own; give those packages one.
const generatedConfigs = foundWorkspaceFiles
? (0, generate_local_vitest_configs_1.generateLocalVitestConfigs)(tree)
: [];
(0, devkit_1.visitNotIgnoredFiles)(tree, '', (filePath) => {
if (!(0, vitest_config_files_1.isJsOrTsFile)(filePath))
return;
processImportsAndCustomCode(tree, filePath, unhandled);
});
(0, devkit_1.visitNotIgnoredFiles)(tree, '', (filePath) => {
if (filePath.endsWith('package.json')) {
processPackageJson(tree, filePath, unhandled);
}
else if (filePath.endsWith('project.json')) {
processProjectJson(tree, filePath, unhandled);
}
else if (isEnvFile(filePath)) {
processEnvFile(tree, filePath, unhandled);
}
});
scanCiFiles(tree, unhandled);
await (0, devkit_1.formatFiles)(tree);
// Always-shown reminder for state we cannot reach from the workspace tree.
const nextSteps = [
`If your CI provider stores Vitest env vars in its dashboard (GitHub Actions repo/org secrets, GitLab CI/CD variables, Vercel/Netlify env vars, etc.), rename \`VITEST_MAX_THREADS\` and \`VITEST_MAX_FORKS\` to \`VITEST_MAX_WORKERS\`, and \`VITE_NODE_DEPS_MODULE_DIRECTORIES\` to \`VITEST_MODULE_DIRECTORIES\`. The pre-pass only handles in-repo files.`,
];
if (generatedConfigs.length > 0) {
nextSteps.push(`Generated a minimal vitest config for packages that run vitest via a package.json script but had no local config (${generatedConfigs.join(', ')}). Without one, Vitest 4 climbs to the root \`test.projects\` config and fails to start from those directories. Review them and add any test options the packages need. ` +
`Note that these packages may also be discovered as projects by root-level \`vitest\` runs when the root \`test.projects\` globs match their new config files.`);
}
const result = {
nextSteps,
};
if (unhandled.length > 0)
result.agentContext = unhandled;
return result;
}
const ENV_FILE_MATCHERS = [picomatch('**/.env'), picomatch('**/.env.*')];
function isEnvFile(filePath) {
const base = filePath.split('/').pop() ?? '';
// Skip `.envrc` (direnv) — different syntax surface.
if (base === '.envrc')
return false;
return ENV_FILE_MATCHERS.some((m) => m(filePath));
}
const COVERAGE_DEAD_OPTIONS = new Set([
'all',
'extensions',
'ignoreEmptyLines',
'experimentalAstAwareRemapping',
]);
const POOL_FLATTEN_OPTIONS = new Set(['execArgv', 'isolate']);
const REMOVED_REPORTER_CALLBACKS = new Set([
'onCollected',
'onSpecsCollected',
'onPathsCollected',
'onTaskUpdate',
'onFinished',
]);
function processVitestConfig(tree, filePath, unhandled) {
const contents = tree.read(filePath, 'utf-8');
// Cheap precheck: skip files that don't mention any v4 target.
if (!mightContainV4Targets(contents))
return;
const sourceFile = (0, tsquery_1.ast)(contents);
const edits = [];
collectCoverageRemovalEdits(sourceFile, contents, edits);
collectWorkspaceRenameEdits(sourceFile, edits);
collectDepsOptimizerWebEdits(sourceFile, edits);
collectUseAtomicsRemovalEdits(sourceFile, contents, edits);
collectMinWorkersRemovalEdits(sourceFile, contents, edits);
collectReporterRenameEdits(sourceFile, contents, edits);
collectPoolOptionDetectLogs(sourceFile, filePath, unhandled);
collectDepsServerMoveLogs(sourceFile, filePath, unhandled);
collectMatchGlobsLogs(sourceFile, filePath, unhandled);
collectBrowserStringProviderLogs(sourceFile, filePath, unhandled);
collectTesterScriptsLogs(sourceFile, filePath, unhandled);
collectCustomReporterCallbackLogs(sourceFile, filePath, unhandled);
if (edits.length > 0) {
tree.write(filePath, (0, ast_edits_1.applyTextEdits)(contents, edits));
}
}
function mightContainV4Targets(contents) {
// Hit-test: keywords we care about. Cheap string scan to avoid parsing
// every config file in the workspace.
return /\b(workspace|projects|coverage|useAtomics|minWorkers|reporters|provider|deps|poolOptions|singleFork|singleThread|maxThreads|maxForks|environmentMatchGlobs|poolMatchGlobs|testerScripts|onCollected|onSpecsCollected|onPathsCollected|onTaskUpdate|onFinished|vmThreads)\b/.test(contents);
}
/**
* Coverage option removals (V4-1): `all`, `extensions`, `ignoreEmptyLines`,
* `experimentalAstAwareRemapping` are all removed in v4. Anchored to
* `test.coverage`.
*/
function collectCoverageRemovalEdits(sourceFile, contents, edits) {
for (const optionName of COVERAGE_DEAD_OPTIONS) {
const identifiers = (0, tsquery_1.query)(sourceFile, `PropertyAssignment > Identifier[name=${optionName}]`);
for (const id of identifiers) {
if (!isInsideTestSubProperty(id, 'coverage'))
continue;
const owningProperty = id.parent;
edits.push((0, ast_edits_1.removeNodeWithTrailingCommaEdit)(contents, owningProperty));
}
}
}
/**
* `test.workspace` → `test.projects` (V4-2). The external `vitest.workspace.*`
* file form is handled separately at the top level: static files are inlined,
* the rest are logged for the agent.
*/
function collectWorkspaceRenameEdits(sourceFile, edits) {
const identifiers = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=workspace]');
for (const id of identifiers) {
if (!isImmediateChildOfTest(id))
continue;
edits.push((0, ast_edits_1.replaceNodeTextEdit)(id, 'projects'));
}
}
/**
* `deps.optimizer.web` → `deps.optimizer.client` (V4-4). Anchored to
* `test.deps.optimizer` so the `web` rename only applies to that nesting.
*/
function collectDepsOptimizerWebEdits(sourceFile, edits) {
const identifiers = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=web]');
for (const id of identifiers) {
if (!isInsideChain(id, ['test', 'deps', 'optimizer']))
continue;
edits.push((0, ast_edits_1.replaceNodeTextEdit)(id, 'client'));
}
}
/**
* Remove `poolOptions.threads.useAtomics` (V4-5).
*/
function collectUseAtomicsRemovalEdits(sourceFile, contents, edits) {
const identifiers = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=useAtomics]');
for (const id of identifiers) {
if (!isInsideChain(id, ['test', 'poolOptions', 'threads']))
continue;
const owningProperty = id.parent;
edits.push((0, ast_edits_1.removeNodeWithTrailingCommaEdit)(contents, owningProperty));
}
}
/**
* Remove top-level `test.minWorkers` (V4-6). Behaves as `0` in non-watch mode
* in v4, so dropping the assignment is safe.
*/
function collectMinWorkersRemovalEdits(sourceFile, contents, edits) {
const identifiers = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=minWorkers]');
for (const id of identifiers) {
if (!isImmediateChildOfTest(id))
continue;
const owningProperty = id.parent;
edits.push((0, ast_edits_1.removeNodeWithTrailingCommaEdit)(contents, owningProperty));
}
}
/**
* Reporter renames inside `test.reporters` (V4-7).
* `'verbose'` → `'tree'`
* `'basic'` → `['default', { summary: false }]`
*/
function collectReporterRenameEdits(sourceFile, contents, edits) {
const reportersProperties = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment:has(Identifier[name=reporters])').filter((p) => ts.isIdentifier(p.name) &&
p.name.text === 'reporters' &&
isImmediateChildOfTest(p.name));
for (const property of reportersProperties) {
const init = property.initializer;
if (!ts.isArrayLiteralExpression(init))
continue;
for (const element of init.elements) {
if (!ts.isStringLiteral(element))
continue;
if (element.text === 'verbose') {
edits.push((0, ast_edits_1.replaceStringLiteralValueEdit)(contents, element, 'tree'));
}
else if (element.text === 'basic') {
edits.push((0, ast_edits_1.replaceNodeTextEdit)(element, `['default', { summary: false }]`));
}
}
}
}
/**
* Detect (but do not modify) pool-related options whose v4 semantics require
* cross-cutting surgery: `singleThread`/`singleFork`, `maxThreads`/`maxForks`,
* `poolOptions.{forks,threads}.{execArgv,isolate}`,
* `poolOptions.vmThreads.memoryLimit`.
*/
function collectPoolOptionDetectLogs(sourceFile, filePath, unhandled) {
const singleHits = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=/^single(Thread|Fork)$/]');
for (const id of singleHits) {
const literalValue = readBooleanLiteralValue(id.parent);
if (literalValue === true) {
unhandled.push(`${filePath}: \`${id.text}: true\` is removed in Vitest 4. ` +
`Replace it with \`maxWorkers: 1, isolate: false\` at the top of the \`test\` block, then remove \`${id.text}\`.`);
}
else if (literalValue === false) {
unhandled.push(`${filePath}: \`${id.text}: false\` is removed in Vitest 4 (it was already the default). ` +
`Delete the property.`);
}
else {
unhandled.push(`${filePath}: \`${id.text}\` is removed in Vitest 4 and the value here is not a boolean literal. ` +
`If it evaluates to \`true\`, replace with \`maxWorkers: 1, isolate: false\` at the top of \`test\` and remove \`${id.text}\`. ` +
`If it evaluates to \`false\`, delete it.`);
}
}
const maxHits = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=/^max(Threads|Forks)$/]');
for (const id of maxHits) {
if (!isImmediateChildOfTest(id))
continue;
const literalValue = readNumericLiteralValue(id.parent);
const currentValueDescr = literalValue !== undefined ? ` (current value: ${literalValue})` : '';
unhandled.push(`${filePath}: \`test.${id.text}\`${currentValueDescr} is removed in Vitest 4. ` +
`Replace with the pool-agnostic \`maxWorkers\` option. ` +
`If both \`maxThreads\` and \`maxForks\` are set with different values, pick the one matching the pool the project actually uses.`);
}
for (const optionName of POOL_FLATTEN_OPTIONS) {
const ids = (0, tsquery_1.query)(sourceFile, `PropertyAssignment > Identifier[name=${optionName}]`);
for (const id of ids) {
const insideForks = isInsideChain(id, ['test', 'poolOptions', 'forks']);
const insideThreads = isInsideChain(id, [
'test',
'poolOptions',
'threads',
]);
if (!insideForks && !insideThreads)
continue;
const pool = insideForks ? 'forks' : 'threads';
unhandled.push(`${filePath}: \`test.poolOptions.${pool}.${optionName}\` was flattened in Vitest 4. ` +
`Move it to the top-level \`test.${optionName}\` (one value for all pools).`);
}
}
const memoryLimitIds = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=memoryLimit]');
for (const id of memoryLimitIds) {
if (!isInsideChain(id, ['test', 'poolOptions', 'vmThreads']))
continue;
unhandled.push(`${filePath}: \`test.poolOptions.vmThreads.memoryLimit\` was renamed and lifted in Vitest 4. ` +
`Move it to top-level \`test.vmMemoryLimit\`.`);
}
}
function readBooleanLiteralValue(property) {
const init = property.initializer;
if (init.kind === ts.SyntaxKind.TrueKeyword)
return true;
if (init.kind === ts.SyntaxKind.FalseKeyword)
return false;
return undefined;
}
function readNumericLiteralValue(property) {
const init = property.initializer;
if (ts.isNumericLiteral(init))
return Number(init.text);
return undefined;
}
/**
* Detect `test.deps.{external,inline,fallbackCJS}`. Moving them under
* `test.server.deps` requires conflict-aware merging that we don't attempt
* mechanically.
*/
function collectDepsServerMoveLogs(sourceFile, filePath, unhandled) {
for (const name of ['external', 'inline', 'fallbackCJS']) {
const ids = (0, tsquery_1.query)(sourceFile, `PropertyAssignment > Identifier[name=${name}]`);
for (const id of ids) {
if (!isInsideChain(id, ['test', 'deps']))
continue;
// Don't flag `test.deps.optimizer.<...>` — only direct children of deps.
if (!isImmediateChildOfProperty(id, 'deps'))
continue;
unhandled.push(`${filePath}: \`test.deps.${name}\` moved to \`test.server.deps.${name}\` in Vitest 4. ` +
`Move the property, merging with any existing \`server.deps\` block.`);
}
}
}
/**
* `test.poolMatchGlobs` / `test.environmentMatchGlobs` are removed in v4;
* their replacement is per-project conditions inside `test.projects`. Always
* a manual rewrite.
*/
function collectMatchGlobsLogs(sourceFile, filePath, unhandled) {
for (const name of ['poolMatchGlobs', 'environmentMatchGlobs']) {
const ids = (0, tsquery_1.query)(sourceFile, `PropertyAssignment > Identifier[name=${name}]`);
for (const id of ids) {
if (!isImmediateChildOfTest(id))
continue;
unhandled.push(`${filePath}: \`test.${name}\` is removed in Vitest 4. ` +
`Express the same per-glob configuration via \`test.projects\` entries with conditions.`);
}
}
}
/**
* `browser.provider` as a string is no longer valid in v4 — it must be the
* return value of a provider function imported from a per-provider package.
*/
function collectBrowserStringProviderLogs(sourceFile, filePath, unhandled) {
const providers = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment:has(Identifier[name=provider]) > StringLiteral');
for (const lit of providers) {
if (!isInsideTestSubProperty(lit, 'browser'))
continue;
unhandled.push(`${filePath}: \`browser.provider\` is no longer a string in Vitest 4 (current value: ${lit.getText()}). ` +
`Install the matching \`@vitest/browser-<provider>\` package and use the function form, ` +
`e.g. \`provider: playwright(...)\`.`);
}
}
/**
* `browser.testerScripts` (array of scripts) was replaced by
* `browser.testerHtmlPath` (single HTML file) — a semantic change.
*/
function collectTesterScriptsLogs(sourceFile, filePath, unhandled) {
const ids = (0, tsquery_1.query)(sourceFile, 'PropertyAssignment > Identifier[name=testerScripts]');
for (const id of ids) {
if (!isInsideTestSubProperty(id, 'browser'))
continue;
unhandled.push(`${filePath}: \`browser.testerScripts\` was removed in Vitest 4 in favor of \`browser.testerHtmlPath\`. ` +
`Combine the script contents into a single HTML file and point \`testerHtmlPath\` at it.`);
}
}
/**
* Custom reporter callbacks removed in v4: `onCollected`, `onSpecsCollected`,
* `onPathsCollected`, `onTaskUpdate`, `onFinished`. They can appear as
* `PropertyAssignment` (object literal: `onCollected: () => ...`),
* `MethodDeclaration` (shorthand: `onCollected() {}`), or class method on a
* reporter class — the search broadens past `PropertyAssignment` to cover
* all three.
*/
function collectCustomReporterCallbackLogs(sourceFile, filePath, unhandled) {
for (const name of REMOVED_REPORTER_CALLBACKS) {
const ids = (0, tsquery_1.query)(sourceFile, `Identifier[name=${name}]`);
const matchedAsName = ids.some(isPropertyOrMethodName);
if (matchedAsName) {
unhandled.push(`${filePath}: custom reporter uses the removed-in-v4 \`${name}\` callback. ` +
`Migrate to the v4 \`onTestModuleCollected\` / \`onTestCaseResult\` / \`onTestRunEnd\` API.`);
}
}
}
function isPropertyOrMethodName(id) {
const parent = id.parent;
if (!parent)
return false;
if (!(ts.isPropertyAssignment(parent) ||
ts.isShorthandPropertyAssignment(parent) ||
ts.isMethodDeclaration(parent) ||
ts.isGetAccessorDeclaration(parent) ||
ts.isSetAccessorDeclaration(parent) ||
ts.isMethodSignature(parent) ||
ts.isPropertySignature(parent))) {
return false;
}
// The identifier must be the NAME of the property/method, not an
// initializer-side reference.
return parent.name === id;
}
/**
* Handles import-side concerns:
* - `@vitest/browser/context` → `vitest/browser` (V4-3a, mechanical when
* the subpath is exactly `/context`).
* - `@vitest/browser/utils` (logged — semantic restructure).
* - `defineWorkspace` from `vitest/config` (logged — file removed in v4).
* - `jest-snapshot` imports inside custom matcher files (logged).
*/
function processImportsAndCustomCode(tree, filePath, unhandled) {
const contents = tree.read(filePath, 'utf-8');
const hasBrowserPackage = contents.includes('@vitest/browser');
const hasDefineWorkspace = contents.includes('defineWorkspace');
const hasJestSnapshot = contents.includes('jest-snapshot');
if (!hasBrowserPackage && !hasDefineWorkspace && !hasJestSnapshot)
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;
const spec = decl.moduleSpecifier.text;
if (spec === '@vitest/browser/context') {
edits.push((0, ast_edits_1.replaceStringLiteralValueEdit)(contents, decl.moduleSpecifier, 'vitest/browser'));
}
else if (spec === '@vitest/browser/utils') {
unhandled.push(`${filePath}: imports from '@vitest/browser/utils' moved into 'vitest/browser' under a named \`utils\` export in Vitest 4. ` +
`Rewrite to \`import { utils } from 'vitest/browser'\` and rebind the used helpers.`);
}
else if (spec === '@vitest/browser') {
unhandled.push(`${filePath}: bare \`@vitest/browser\` imports — the package's public surface moved into \`vitest/browser\` (and the per-provider packages) in Vitest 4. ` +
`Rewrite the import to \`vitest/browser\` and rebind any helpers that moved to the per-provider package.`);
}
else if (spec === 'jest-snapshot') {
unhandled.push(`${filePath}: custom matcher imports from 'jest-snapshot'. ` +
`In Vitest 4, import the snapshot helpers from 'vitest' via the \`Snapshots\` namespace.`);
}
// `defineWorkspace` is named-imported from vitest/config — flag any usage.
const named = decl.importClause?.namedBindings;
if (named && ts.isNamedImports(named)) {
const usesDefineWorkspace = named.elements.some((el) => el.name.text === 'defineWorkspace');
if (usesDefineWorkspace) {
unhandled.push(`${filePath}: \`defineWorkspace\` is removed in Vitest 4 (the external \`vitest.workspace.*\` form is gone). ` +
`Replace with \`defineConfig\` and inline the project list under \`test.projects\` in the root vitest config.`);
}
}
}
if (edits.length > 0) {
tree.write(filePath, (0, ast_edits_1.applyTextEdits)(contents, edits));
}
}
const ENV_RENAMES = [
[/\bVITEST_MAX_THREADS\b/g, 'VITEST_MAX_WORKERS'],
[/\bVITEST_MAX_FORKS\b/g, 'VITEST_MAX_WORKERS'],
[/\bVITE_NODE_DEPS_MODULE_DIRECTORIES\b/g, 'VITEST_MODULE_DIRECTORIES'],
];
function processPackageJson(tree, filePath, unhandled) {
const contents = tree.read(filePath, 'utf-8');
let parsed;
try {
parsed = JSON.parse(contents);
}
catch {
return;
}
let changed = false;
if (parsed.scripts && typeof parsed.scripts === 'object') {
for (const [name, value] of Object.entries(parsed.scripts)) {
if (typeof value !== 'string')
continue;
let updated = value;
for (const [re, replacement] of ENV_RENAMES) {
updated = updated.replace(re, replacement);
}
if (updated !== value) {
parsed.scripts[name] = updated;
changed = true;
}
}
}
// `@vitest/browser` surface moved into the main `vitest` package and the
// per-provider packages. Removing the dep without installing the new
// provider package would break browser runs, so log instead of editing.
for (const bucket of [
'dependencies',
'devDependencies',
'peerDependencies',
'optionalDependencies',
]) {
const deps = parsed[bucket];
if (!deps || typeof deps !== 'object')
continue;
if ('@vitest/browser' in deps) {
unhandled.push(`${filePath}: \`@vitest/browser\` is no longer needed at the top level in Vitest 4 — ` +
`install the matching per-provider package (\`@vitest/browser-playwright\`, \`@vitest/browser-webdriverio\`, ` +
`or \`@vitest/browser-preview\`) and remove the \`@vitest/browser\` entry.`);
break; // one note per file is enough
}
}
if (changed) {
const trailingNewline = contents.endsWith('\n') ? '\n' : '';
tree.write(filePath, JSON.stringify(parsed, null, 2) + trailingNewline);
}
}
/**
* Rewrite `.env` / `.env.*` files: rename the legacy Vitest env vars to the
* v4-aware names. `VITEST_MAX_THREADS`/`VITEST_MAX_FORKS` only get renamed
* when exactly one of them is present in the file — both map to the same
* pool-agnostic `VITEST_MAX_WORKERS`, so a blind rename would produce two
* conflicting assignments (the latter would silently win per dotenv
* semantics). When both are present the rename is skipped and the conflict
* is forwarded to the agent.
*/
function processEnvFile(tree, filePath, unhandled) {
const contents = tree.read(filePath, 'utf-8');
if (!/(?:VITEST_MAX_THREADS|VITEST_MAX_FORKS|VITE_NODE_DEPS_MODULE_DIRECTORIES)\b/.test(contents)) {
return;
}
const hasThreads = /^(?:\s*(?:export\s+)?)VITEST_MAX_THREADS\b/m.test(contents);
const hasForks = /^(?:\s*(?:export\s+)?)VITEST_MAX_FORKS\b/m.test(contents);
let updated = contents;
if (hasThreads && hasForks) {
unhandled.push(`${filePath}: both \`VITEST_MAX_THREADS\` and \`VITEST_MAX_FORKS\` are set — both collapse to the single pool-agnostic \`VITEST_MAX_WORKERS\` in Vitest 4. ` +
`Decide which value should win based on the pool the project uses (\`test.pool\` in the vitest config) and rename only that line.`);
}
else if (hasThreads) {
updated = updated.replace(/^((?:\s*(?:export\s+)?))VITEST_MAX_THREADS\b/gm, '$1VITEST_MAX_WORKERS');
}
else if (hasForks) {
updated = updated.replace(/^((?:\s*(?:export\s+)?))VITEST_MAX_FORKS\b/gm, '$1VITEST_MAX_WORKERS');
}
updated = updated.replace(/^((?:\s*(?:export\s+)?))VITE_NODE_DEPS_MODULE_DIRECTORIES\b/gm, '$1VITEST_MODULE_DIRECTORIES');
if (updated !== contents) {
tree.write(filePath, updated);
}
}
/**
* Walk all targets in `project.json` and apply v4 env-var renames inline:
* - `options.env` keys
* - `options.{args,command,commands}` string content (VAR=value prefixes)
* Same conflict guard as `.env` files.
*/
function processProjectJson(tree, filePath, unhandled) {
const contents = tree.read(filePath, 'utf-8');
let parsed;
try {
parsed = JSON.parse(contents);
}
catch {
return;
}
const targets = parsed?.targets;
if (!targets || typeof targets !== 'object')
return;
let changed = false;
for (const [targetName, target] of Object.entries(targets)) {
const options = target?.options;
if (!options || typeof options !== 'object')
continue;
// `options.env`: rename keys, with the same threads/forks conflict guard.
if (options.env && typeof options.env === 'object') {
const hasThreads = 'VITEST_MAX_THREADS' in options.env;
const hasForks = 'VITEST_MAX_FORKS' in options.env;
if (hasThreads && hasForks) {
unhandled.push(`${filePath} (target \`${targetName}\`): both \`VITEST_MAX_THREADS\` and \`VITEST_MAX_FORKS\` are set in \`options.env\` — both collapse to \`VITEST_MAX_WORKERS\` in Vitest 4. ` +
`Decide which value should win and rename only that key.`);
}
else if (hasThreads) {
options.env = renameObjectKey(options.env, 'VITEST_MAX_THREADS', 'VITEST_MAX_WORKERS');
changed = true;
}
else if (hasForks) {
options.env = renameObjectKey(options.env, 'VITEST_MAX_FORKS', 'VITEST_MAX_WORKERS');
changed = true;
}
if ('VITE_NODE_DEPS_MODULE_DIRECTORIES' in options.env) {
options.env = renameObjectKey(options.env, 'VITE_NODE_DEPS_MODULE_DIRECTORIES', 'VITEST_MODULE_DIRECTORIES');
changed = true;
}
}
// `options.args` / `options.command` / `options.commands`: in-string renames.
if (typeof options.args === 'string') {
const rewritten = rewriteInlineEnvVars(options.args, filePath, targetName, unhandled);
if (rewritten !== options.args) {
options.args = rewritten;
changed = true;
}
}
else if (Array.isArray(options.args)) {
for (let i = 0; i < options.args.length; i++) {
const entry = options.args[i];
if (typeof entry !== 'string')
continue;
const rewritten = rewriteInlineEnvVars(entry, filePath, targetName, unhandled);
if (rewritten !== entry) {
options.args[i] = rewritten;
changed = true;
}
}
}
if (typeof options.command === 'string') {
const rewritten = rewriteInlineEnvVars(options.command, filePath, targetName, unhandled);
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')
continue;
const rewritten = rewriteInlineEnvVars(entry, filePath, targetName, unhandled);
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 renameObjectKey(obj, oldKey, newKey) {
const rebuilt = {};
for (const [k, v] of Object.entries(obj)) {
rebuilt[k === oldKey ? newKey : k] = v;
}
return rebuilt;
}
const VITEST_MAX_THREADS_INLINE = /\bVITEST_MAX_THREADS\b/g;
const VITEST_MAX_FORKS_INLINE = /\bVITEST_MAX_FORKS\b/g;
const VITE_NODE_DEPS_INLINE = /\bVITE_NODE_DEPS_MODULE_DIRECTORIES\b/g;
function rewriteInlineEnvVars(value, filePath, targetName, unhandled) {
const hasThreads = /\bVITEST_MAX_THREADS\b/.test(value);
const hasForks = /\bVITEST_MAX_FORKS\b/.test(value);
let updated = value;
if (hasThreads && hasForks) {
unhandled.push(`${filePath} (target \`${targetName}\`): \`VITEST_MAX_THREADS\` and \`VITEST_MAX_FORKS\` both appear in the same command — both collapse to \`VITEST_MAX_WORKERS\` in Vitest 4. ` +
`Decide which value should win and rename only that occurrence.`);
}
else if (hasThreads) {
updated = updated.replace(VITEST_MAX_THREADS_INLINE, 'VITEST_MAX_WORKERS');
}
else if (hasForks) {
updated = updated.replace(VITEST_MAX_FORKS_INLINE, 'VITEST_MAX_WORKERS');
}
updated = updated.replace(VITE_NODE_DEPS_INLINE, 'VITEST_MODULE_DIRECTORIES');
return updated;
}
/**
* Scan CI provider configs for v4 legacy env-var tokens and forward the file
* paths to the agent. YAML structure varies by provider; mechanical edits
* risk breaking comments, anchors, and multi-line strings.
*/
function scanCiFiles(tree, unhandled) {
(0, ci_files_1.visitCiFiles)(tree, (filePath, contents) => {
const tokens = [];
if (/\bVITEST_MAX_THREADS\b/.test(contents)) {
tokens.push('`VITEST_MAX_THREADS`');
}
if (/\bVITEST_MAX_FORKS\b/.test(contents)) {
tokens.push('`VITEST_MAX_FORKS`');
}
if (/\bVITE_NODE_DEPS_MODULE_DIRECTORIES\b/.test(contents)) {
tokens.push('`VITE_NODE_DEPS_MODULE_DIRECTORIES`');
}
if (tokens.length === 0)
return;
unhandled.push(`${filePath}: found legacy Vitest env-var token(s) in this CI config: ${tokens.join(', ')}. ` +
`Rename \`VITEST_MAX_THREADS\` / \`VITEST_MAX_FORKS\` → \`VITEST_MAX_WORKERS\` (pick a single value when both are set), and \`VITE_NODE_DEPS_MODULE_DIRECTORIES\` → \`VITEST_MODULE_DIRECTORIES\`.`);
});
}
/**
* Returns true if `node` lives inside a property whose name is
* `propertyName`, whose own parent is a property named `test`.
*/
function isInsideTestSubProperty(node, propertyName) {
const owning = findEnclosingPropertyAssignment(node, propertyName);
if (!owning)
return false;
return !!findEnclosingPropertyAssignment(owning, 'test');
}
/**
* Returns true if `node`'s identifier sits as a direct property of a
* `test:` block (i.e. `test.{name}`, not `test.something.{name}`).
*/
function isImmediateChildOfTest(node) {
// The Identifier's parent is a PropertyAssignment; that PA's parent is the
// ObjectLiteralExpression of `test`; that OLE's parent is the `test:`
// PropertyAssignment itself.
const pa = node.parent;
if (!pa || !ts.isPropertyAssignment(pa))
return false;
const ole = pa.parent;
if (!ole || !ts.isObjectLiteralExpression(ole))
return false;
const testPa = ole.parent;
if (!testPa || !ts.isPropertyAssignment(testPa))
return false;
return ts.isIdentifier(testPa.name) && testPa.name.text === 'test';
}
/**
* Like `isImmediateChildOfTest` but parameterised on the immediate parent's
* property name.
*/
function isImmediateChildOfProperty(node, propertyName) {
const pa = node.parent;
if (!pa || !ts.isPropertyAssignment(pa))
return false;
const ole = pa.parent;
if (!ole || !ts.isObjectLiteralExpression(ole))
return false;
const ownerPa = ole.parent;
if (!ownerPa || !ts.isPropertyAssignment(ownerPa))
return false;
return ts.isIdentifier(ownerPa.name) && ownerPa.name.text === propertyName;
}
/**
* Returns true if `node`'s ancestors include a chain of property names —
* outer-most first. So `['test', 'deps', 'optimizer']` matches a node living
* inside `test.deps.optimizer.<something>` (any sub-property name, e.g.
* `web`).
*/
function isInsideChain(node, chain) {
// Walk inner → outer through enclosing properties, expecting names to
// match `chain` from the last (innermost) to the first (outermost).
let current = node.parent;
let chainIndex = chain.length - 1;
while (current && chainIndex >= 0) {
if (ts.isPropertyAssignment(current) && ts.isIdentifier(current.name)) {
if (current.name.text === chain[chainIndex]) {
chainIndex--;
}
}
current = current.parent;
}
return chainIndex < 0;
}
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;
}