@nx/vitest 23.1.0 → 23.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,7 @@ const devkit_1 = require("@nx/devkit");
7
7
  const options_utils_1 = require("../../../utils/options-utils");
8
8
  const path_1 = require("path");
9
9
  const executor_utils_1 = require("../../../utils/executor-utils");
10
+ const internal_1 = require("@nx/devkit/internal");
10
11
  async function getOptions(options, context, projectRoot) {
11
12
  // Allows ESM to be required in CJS modules. Vite will be published as ESM in the future.
12
13
  const { loadConfigFromFile } = await (0, executor_utils_1.loadViteDynamicImport)();
@@ -49,10 +50,18 @@ async function getOptions(options, context, projectRoot) {
49
50
  '--': _dashdash, color: _color, w: _w,
50
51
  // Pass through any additional Vitest options
51
52
  ...passThroughOptions } = normalizedExtraArgs;
53
+ // Vitest keeps the UI/browser server alive only in watch mode and has no
54
+ // --ui -> watch link, so a run-once `nx test --ui` tears the UI down right
55
+ // after the run. When --ui is requested from an interactive, non-CI terminal,
56
+ // default watch on so the UI stays open; bare runs and CI stay run-once so
57
+ // `nx run-many`/`affected` don't hang. An explicit CLI --watch/--no-watch or a
58
+ // config `test.watch` still takes precedence.
59
+ const uiRequested = passThroughOptions.ui === true;
60
+ const watchForUi = uiRequested && !!process.stdin.isTTY && !(0, internal_1.isCI)();
52
61
  return {
53
- // Explicitly set watch mode to false if not provided otherwise vitest
54
- // will enable watch mode by default for non CI environments
55
- watch: watch ?? false,
62
+ watch: watch ??
63
+ resolved?.config?.['test']?.watch ??
64
+ watchForUi,
56
65
  // Pass through any additional Vitest options
57
66
  ...passThroughOptions,
58
67
  // This should not be needed as it's going to be set in vite.config.ts
@@ -16,6 +16,7 @@ const version_utils_1 = require("../../utils/version-utils");
16
16
  const versions_1 = require("../../utils/versions");
17
17
  const assert_supported_vitest_version_1 = require("../../utils/assert-supported-vitest-version");
18
18
  const semver_1 = require("semver");
19
+ let ts;
19
20
  /**
20
21
  * Determines whether to use vitest.config.mts instead of vite.config.mts.
21
22
  * Returns true for new non-framework projects that don't already have a vite.config.
@@ -176,15 +177,86 @@ getTestBed().initTestEnvironment(
176
177
  const installDependenciesTask = (0, devkit_1.addDependenciesToPackageJson)(tree, {}, devDependencies, undefined, true);
177
178
  tasks.push(installDependenciesTask);
178
179
  }
179
- // Setup workspace config file (https://vitest.dev/guide/workspace.html)
180
- if (!isRootProject &&
181
- !tree.exists(`vitest.workspace.ts`) &&
182
- !tree.exists(`vitest.workspace.js`) &&
183
- !tree.exists(`vitest.workspace.json`) &&
184
- !tree.exists(`vitest.projects.ts`) &&
185
- !tree.exists(`vitest.projects.js`) &&
186
- !tree.exists(`vitest.projects.json`)) {
187
- tree.write('vitest.workspace.ts', `export default ['**/vite.config.{mjs,js,ts,mts}', '**/vitest.config.{mjs,js,ts,mts}'];`);
180
+ // Setup the root config aggregating the project configs. Vitest 4 removed
181
+ // workspace files in favor of inlining the projects into a root vitest.config
182
+ // via `test.projects` (https://vitest.dev/guide/migration.html#workspace-is-replaced-with-projects).
183
+ // Emit that shape for vitest 4+ and when the installed version can't be
184
+ // detected (new installs resolve to v4); vitest 3 keeps the workspace file.
185
+ if (!isRootProject) {
186
+ const projectGlobs = `'**/vite.config.{mjs,js,ts,mts}', '**/vitest.config.{mjs,js,ts,mts}'`;
187
+ const vitestMajorVersion = (0, versions_1.getInstalledVitestMajorVersion)(tree);
188
+ if (vitestMajorVersion === null || vitestMajorVersion >= 4) {
189
+ const hasWorkspaceFile = ['ts', 'js', 'json'].some((ext) => tree.exists(`vitest.workspace.${ext}`) ||
190
+ tree.exists(`vitest.projects.${ext}`));
191
+ const rootVitestConfig = findRootConfig(tree, 'vitest.config');
192
+ const rootViteConfig = findRootConfig(tree, 'vite.config');
193
+ if (hasWorkspaceFile) {
194
+ // A workspace/projects file already defines the project set and the
195
+ // vitest 4 migration converts it; leave it untouched.
196
+ }
197
+ else if (rootVitestConfig) {
198
+ // A root vitest.config.* wins vitest's config resolution, so we can
199
+ // neither add a competing aggregator nor safely rewrite it. Warn when it
200
+ // doesn't aggregate, or when its shape can't be read statically, since
201
+ // the new project would otherwise be silently absent from
202
+ // workspace-level vitest runs.
203
+ const declaresProjects = rootConfigDeclaresProjects(tree, rootVitestConfig);
204
+ if (declaresProjects === 'missing') {
205
+ devkit_1.logger.warn(`Found a root "${rootVitestConfig}" without a \`test.projects\` entry. ` +
206
+ `The "${schema.project}" project won't be part of workspace-level ` +
207
+ `vitest runs until you add its config file to \`test.projects\` there.`);
208
+ }
209
+ else if (declaresProjects === 'unknown') {
210
+ devkit_1.logger.warn(`Found a root "${rootVitestConfig}" whose test setup couldn't be ` +
211
+ `analyzed. If the "${schema.project}" project isn't picked up by ` +
212
+ `workspace-level vitest runs, add its config file to ` +
213
+ `\`test.projects\` there.`);
214
+ }
215
+ }
216
+ else if (rootViteConfig) {
217
+ // A root vite.config.* is vitest's config today. Writing a
218
+ // vitest.config.* would win resolution and shadow it, dropping the vite
219
+ // settings (aliases, plugins) from vitest runs, and projects don't
220
+ // inherit those from a root aggregator either. Leave it in place.
221
+ const declaresProjects = rootConfigDeclaresProjects(tree, rootViteConfig);
222
+ if (declaresProjects === 'missing') {
223
+ devkit_1.logger.warn(`Found a root "${rootViteConfig}" without a \`test.projects\` entry. ` +
224
+ `The "${schema.project}" project runs through that root config, so ` +
225
+ `its own vitest configuration (e.g. \`environment\`, \`setupFiles\`) ` +
226
+ `won't apply. Add its config file to a \`test.projects\` entry there ` +
227
+ `to run it with its own configuration.`);
228
+ }
229
+ else if (declaresProjects === 'unknown') {
230
+ devkit_1.logger.warn(`Found a root "${rootViteConfig}" whose test setup couldn't be ` +
231
+ `analyzed. If the "${schema.project}" project isn't picked up by ` +
232
+ `workspace-level vitest runs, add its config file to ` +
233
+ `\`test.projects\` there.`);
234
+ }
235
+ }
236
+ else {
237
+ // No root config exists, so emit the aggregator. Its projects glob
238
+ // matches every vite/vitest config, including this file itself and any
239
+ // root vite.config added later; exclude both so neither is resolved as
240
+ // an extra project that, carrying no `include`, re-runs every spec via
241
+ // the default glob.
242
+ tree.write('vitest.config.ts', `import { defineConfig } from 'vitest/config';
243
+
244
+ export default defineConfig({
245
+ test: {
246
+ projects: [${projectGlobs}, '!vitest.config.{mjs,js,ts,mts}', '!vite.config.{mjs,js,ts,mts}'],
247
+ },
248
+ });
249
+ `);
250
+ }
251
+ }
252
+ else if (!tree.exists(`vitest.workspace.ts`) &&
253
+ !tree.exists(`vitest.workspace.js`) &&
254
+ !tree.exists(`vitest.workspace.json`) &&
255
+ !tree.exists(`vitest.projects.ts`) &&
256
+ !tree.exists(`vitest.projects.js`) &&
257
+ !tree.exists(`vitest.projects.json`)) {
258
+ tree.write('vitest.workspace.ts', `export default [${projectGlobs}];`);
259
+ }
188
260
  }
189
261
  if (!schema.skipFormat) {
190
262
  await (0, devkit_1.formatFiles)(tree);
@@ -369,4 +441,84 @@ function findTestDefault(td, target) {
369
441
  }
370
442
  return value;
371
443
  }
444
+ function findRootConfig(tree, name) {
445
+ for (const ext of ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs']) {
446
+ const candidate = `${name}.${ext}`;
447
+ if (tree.exists(candidate)) {
448
+ return candidate;
449
+ }
450
+ }
451
+ return undefined;
452
+ }
453
+ /**
454
+ * Classifies a root config's default export by whether it already aggregates
455
+ * projects via `test.projects` (or the vitest 3 `test.workspace`):
456
+ * - `'declares'`: it aggregates projects.
457
+ * - `'missing'`: it's a readable object config with no such aggregation.
458
+ * - `'unknown'`: the shape can't be read statically (dynamic/function configs,
459
+ * spreads), where the safe move is to leave the config untouched rather than
460
+ * shadow it.
461
+ */
462
+ function rootConfigDeclaresProjects(tree, configPath) {
463
+ ts ??= (0, internal_2.ensureTypescript)();
464
+ const { tsquery } = require('@phenomnomnominal/tsquery');
465
+ let sourceFile;
466
+ try {
467
+ sourceFile = tsquery.ast(tree.read(configPath, 'utf-8'));
468
+ }
469
+ catch {
470
+ return 'unknown';
471
+ }
472
+ const exportAssignment = sourceFile.statements.find((s) => ts.isExportAssignment(s) && !s.isExportEquals);
473
+ if (!exportAssignment) {
474
+ return 'unknown';
475
+ }
476
+ let expression = unwrapExpression(exportAssignment.expression);
477
+ // Unwrap a single-argument config wrapper such as `defineConfig(...)`.
478
+ if (ts.isCallExpression(expression) &&
479
+ ts.isIdentifier(expression.expression) &&
480
+ expression.arguments.length === 1) {
481
+ expression = unwrapExpression(expression.arguments[0]);
482
+ }
483
+ if (!ts.isObjectLiteralExpression(expression)) {
484
+ return 'unknown';
485
+ }
486
+ // A spread could hide a `test` block we can't see.
487
+ if (expression.properties.some((p) => ts.isSpreadAssignment(p))) {
488
+ return 'unknown';
489
+ }
490
+ const testProperty = findObjectProperty(expression, 'test');
491
+ if (!testProperty) {
492
+ return 'missing';
493
+ }
494
+ if (!ts.isObjectLiteralExpression(testProperty.initializer)) {
495
+ return 'unknown';
496
+ }
497
+ const testObject = testProperty.initializer;
498
+ if (testObject.properties.some((p) => ts.isSpreadAssignment(p))) {
499
+ return 'unknown';
500
+ }
501
+ return findObjectProperty(testObject, 'projects') ||
502
+ findObjectProperty(testObject, 'workspace')
503
+ ? 'declares'
504
+ : 'missing';
505
+ }
506
+ function unwrapExpression(expression) {
507
+ while (ts.isAsExpression(expression) ||
508
+ ts.isSatisfiesExpression(expression) ||
509
+ ts.isParenthesizedExpression(expression)) {
510
+ expression = expression.expression;
511
+ }
512
+ return expression;
513
+ }
514
+ function findObjectProperty(objectLiteral, name) {
515
+ for (const property of objectLiteral.properties) {
516
+ if (ts.isPropertyAssignment(property) &&
517
+ (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
518
+ property.name.text === name) {
519
+ return property;
520
+ }
521
+ }
522
+ return undefined;
523
+ }
372
524
  exports.default = configurationGenerator;
@@ -15,6 +15,27 @@ export interface VitestPluginOptions {
15
15
  * - 'run': Tests run once and exit
16
16
  */
17
17
  testMode?: 'watch' | 'run';
18
+ /**
19
+ * How atomized test files (the `ciTargetName` targets) are discovered.
20
+ * - 'glob' (default): enumerate specs with a glob that mirrors Vitest's own
21
+ * resolution instead of booting Vitest per project. Booting Vitest starts a
22
+ * Vite dev server and runs the config's plugin hooks, so the glob is faster
23
+ * during graph creation. The glob reads the Nx workspace file index, so
24
+ * files ignored by `.gitignore`/`.nxignore` are never enumerated even when
25
+ * Vitest itself would run them.
26
+ * - 'vitest': always enumerate through Vitest.
27
+ *
28
+ * Configs a glob cannot reproduce faithfully still boot Vitest automatically
29
+ * even under 'glob': `test.projects`/`test.workspace` (inline or an
30
+ * auto-loaded `vitest.workspace.*`/`vitest.projects.*` sibling file), plugins
31
+ * with a `configureVitest` hook, `test.changed`/`test.related`, enabled
32
+ * browser `instances` that set their own include/exclude/includeSource/dir,
33
+ * and `include`, `exclude`, `includeSource`, or `typecheck` include/exclude
34
+ * patterns the workspace glob reads differently (absolute paths, a trailing
35
+ * `/`, or an `!(...)` extglob, each optionally negated).
36
+ * @default 'glob'
37
+ */
38
+ discoverTestFiles?: 'glob' | 'vitest';
18
39
  }
19
40
  /**
20
41
  * @deprecated The 'createDependencies' function is now a no-op. This functionality is included in 'createNodesV2'.
@@ -6,10 +6,8 @@ const devkit_1 = require("@nx/devkit");
6
6
  const js_1 = require("@nx/js");
7
7
  const internal_2 = require("@nx/js/internal");
8
8
  const node_fs_1 = require("node:fs");
9
+ const promises_1 = require("node:fs/promises");
9
10
  const node_path_1 = require("node:path");
10
- const file_hasher_1 = require("nx/src/hasher/file-hasher");
11
- const cache_directory_1 = require("nx/src/utils/cache-directory");
12
- const plugins_1 = require("nx/src/utils/plugins");
13
11
  const executor_utils_1 = require("../utils/executor-utils");
14
12
  /**
15
13
  * @deprecated The 'createDependencies' function is now a no-op. This functionality is included in 'createNodesV2'.
@@ -23,9 +21,9 @@ exports.createNodes = [
23
21
  vitestConfigGlob,
24
22
  async (configFilePaths, options, context) => {
25
23
  const pmc = (0, devkit_1.getPackageManagerCommand)((0, devkit_1.detectPackageManager)(context.workspaceRoot));
26
- const optionsHash = (0, file_hasher_1.hashObject)(options);
24
+ const optionsHash = (0, internal_1.hashObject)(options);
27
25
  const normalizedOptions = normalizeOptions(options);
28
- const cachePath = (0, node_path_1.join)(cache_directory_1.workspaceDataDirectory, `vitest-${optionsHash}.hash`);
26
+ const cachePath = (0, node_path_1.join)(internal_1.workspaceDataDirectory, `vitest-${optionsHash}.hash`);
29
27
  const targetsCache = new internal_1.PluginCache(cachePath);
30
28
  const { roots: projectRoots, configFiles: validConfigFiles } = configFilePaths.reduce((acc, configFile) => {
31
29
  const potentialRoot = (0, node_path_1.dirname)(configFile);
@@ -53,9 +51,18 @@ exports.createNodes = [
53
51
  // for different config files.
54
52
  const hash = hashes[idx] + configFile;
55
53
  if (!targetsCache.has(hash)) {
56
- targetsCache.set(hash, await buildVitestTargets(configFile, projectRoot, normalizedOptions, context, pmc, tsconfigChainsByProjectRoot.get(projectRoot) ?? []));
54
+ const result = await buildVitestTargets(configFile, projectRoot, normalizedOptions, context, pmc, tsconfigChainsByProjectRoot.get(projectRoot) ?? []);
55
+ // Cache the result even when it's null (a root orchestrator config)
56
+ // so the config isn't re-resolved on every project-graph build.
57
+ targetsCache.set(hash, result);
57
58
  }
58
- const { projectType, metadata, targets } = targetsCache.get(hash);
59
+ const cached = targetsCache.get(hash);
60
+ // `buildVitestTargets` returns null for a root orchestrator config
61
+ // that must not become a project; register no node for it.
62
+ if (!cached) {
63
+ return { projects: {} };
64
+ }
65
+ const { projectType, metadata, targets } = cached;
59
66
  const project = {
60
67
  root: projectRoot,
61
68
  targets,
@@ -115,14 +122,14 @@ async function buildVitestTargets(configFilePath, projectRoot, options, context,
115
122
  configFile: absoluteConfigFilePath,
116
123
  mode: 'development',
117
124
  }, 'build');
118
- // If this is a root workspace config file with projects property, don't infer targets.
119
- // The root config is just an orchestrator - the actual tests live in the individual project configs.
125
+ // A root config that aggregates project configs via `test.projects` is just an
126
+ // orchestrator; the actual tests live in the individual project configs. Skip it
127
+ // entirely so it does not register a project rooted at the workspace root (which
128
+ // would, for example, make `nx format` treat the whole workspace as one project).
120
129
  const isWorkspaceRoot = projectRoot === '.';
121
- // TODO(jack): Remove this cast when @nx/vitest switches to moduleResolution:
122
- // "nodenext". Vite 8's rolldown types break vitest's test augmentation.
123
- const hasProjectsProperty = Array.isArray(viteBuildConfig?.test?.projects);
130
+ const hasProjectsProperty = Array.isArray(viteBuildConfig.test?.projects);
124
131
  if (isWorkspaceRoot && hasProjectsProperty) {
125
- return { targets: {}, metadata: {}, projectType: 'library' };
132
+ return null;
126
133
  }
127
134
  let metadata = {};
128
135
  const { testOutputs, hasTest } = getOutputs(viteBuildConfig, projectRoot, context.workspaceRoot);
@@ -130,11 +137,10 @@ async function buildVitestTargets(configFilePath, projectRoot, options, context,
130
137
  const targets = {};
131
138
  // if file is vitest.config or vite.config has definition for test, create targets for test and/or atomized tests
132
139
  if (configFilePath.includes('vitest.config') || hasTest) {
133
- const isTypecheckEnabled = !!viteBuildConfig?.test?.typecheck
134
- ?.enabled;
140
+ const isTypecheckEnabled = !!viteBuildConfig.test?.typecheck?.enabled;
135
141
  targets[options.testTargetName] = await testTarget(namedInputs, testOutputs, projectRoot, options.testMode, pmc, isTypecheckEnabled, tsconfigInputs);
136
142
  if (options.ciTargetName) {
137
- const groupName = options.ciGroupName ?? (0, plugins_1.deriveGroupNameFromTarget)(options.ciTargetName);
143
+ const groupName = options.ciGroupName ?? (0, internal_1.deriveGroupNameFromTarget)(options.ciTargetName);
138
144
  const targetGroup = [];
139
145
  const dependsOn = [];
140
146
  metadata = {
@@ -142,7 +148,24 @@ async function buildVitestTargets(configFilePath, projectRoot, options, context,
142
148
  [groupName]: targetGroup,
143
149
  },
144
150
  };
145
- const projectRootRelativeTestPaths = await getTestPathsRelativeToProjectRoot(projectRoot, context.workspaceRoot);
151
+ // Not normalizing in normalizeOptions since it also affects the options
152
+ // computed for convert-to-inferred.
153
+ const useGlobDiscovery = (options.discoverTestFiles ?? 'glob') !== 'vitest';
154
+ // Both discovery paths read the serve-resolved config: Vitest runs tests
155
+ // through a Vite server (the `serve` command), so `apply: 'serve'`
156
+ // plugins and command-sensitive `test` options (include/exclude and
157
+ // `dir`) are absent from the build resolution used above for outputs.
158
+ // Resolve under `mode: 'test'` to match Vitest, which defaults the Vite
159
+ // mode to 'test'; a config that branches on `command`/`mode` would
160
+ // otherwise enumerate a different spec set here than at test time.
161
+ const viteServeConfig = await resolveConfig({
162
+ configFile: absoluteConfigFilePath,
163
+ mode: 'test',
164
+ }, 'serve');
165
+ const projectRootRelativeTestPaths = await getTestPathsRelativeToProjectRoot(projectRoot, context.workspaceRoot,
166
+ // Only the glob path reads this config; the opt-out path forces the
167
+ // runtime by receiving no config, and takes `test.dir` separately.
168
+ useGlobDiscovery ? viteServeConfig : undefined, viteServeConfig.test?.dir);
146
169
  for (const relativePath of projectRootRelativeTestPaths) {
147
170
  if (relativePath.includes('../')) {
148
171
  throw new Error('@nx/vitest attempted to run tests outside of the project root. This is not supported and should not happen. Please open an issue at https://github.com/nrwl/nx/issues/new/choose with the following information:\n\n' +
@@ -392,12 +415,205 @@ function checkIfConfigFileShouldBeProject(projectRoot, context) {
392
415
  }
393
416
  return true;
394
417
  }
395
- async function getTestPathsRelativeToProjectRoot(projectRoot, workspaceRoot) {
418
+ async function getTestPathsRelativeToProjectRoot(projectRoot, workspaceRoot, viteConfig,
419
+ // Serve-resolved `test.dir` (the command Vitest runs under). Only used on the
420
+ // opt-out path below, which receives no `viteConfig` to read it from.
421
+ optOutTestDir) {
396
422
  const fullProjectRoot = (0, node_path_1.join)(workspaceRoot, projectRoot);
423
+ // `viteConfig` is resolved only when glob discovery is requested; its absence
424
+ // means the runtime path was selected (`discoverTestFiles: 'vitest'`).
425
+ if (viteConfig) {
426
+ const test = viteConfig.test ?? {};
427
+ if (!configRequiresVitestRuntime(test, viteConfig, fullProjectRoot)) {
428
+ return globTestPathsRelativeToProjectRoot(test, workspaceRoot, projectRoot, fullProjectRoot);
429
+ }
430
+ return getTestPathsViaVitestRuntime(fullProjectRoot, projectRoot, test.dir);
431
+ }
432
+ return getTestPathsViaVitestRuntime(fullProjectRoot, projectRoot, optOutTestDir);
433
+ }
434
+ /**
435
+ * The directory Vitest enumerates from: `test.dir` when set, else the project
436
+ * root. Vitest resolves a relative `test.dir` against the working directory,
437
+ * which for both the `test` and atomized targets is the project root.
438
+ */
439
+ function resolveTestDir(fullProjectRoot, testDir) {
440
+ return testDir ? (0, node_path_1.resolve)(fullProjectRoot, testDir) : fullProjectRoot;
441
+ }
442
+ /**
443
+ * Enumerates a project's test files by mirroring Vitest's own resolution with
444
+ * a glob. Vitest globs `test.include` and `test.includeSource` (both skipped
445
+ * when typecheck is enabled with `only`, the latter also kept only when the
446
+ * file contains an in-source test) plus `test.typecheck.include` (when
447
+ * typecheck is enabled), each minus the relevant `exclude`, from `test.dir`
448
+ * when set and the project root otherwise. Defaults come from the installed
449
+ * Vitest so they track the user's version. Globbing goes through the Nx
450
+ * workspace context (the daemon-cached file index), so files ignored by
451
+ * `.gitignore`/`.nxignore` are never candidates. Callers must first confirm the
452
+ * config is reproducible with a glob via `configRequiresVitestRuntime`.
453
+ */
454
+ async function globTestPathsRelativeToProjectRoot(test, workspaceRoot, projectRoot, fullProjectRoot) {
455
+ const { configDefaults } = await (0, executor_utils_1.loadVitestConfigDynamicImport)();
456
+ const exclude = test.exclude ?? configDefaults.exclude;
457
+ const typecheck = test.typecheck;
458
+ const typecheckOnly = !!(typecheck?.enabled && typecheck?.only);
459
+ // The workspace context matches workspace-relative paths, while the config's
460
+ // patterns are relative to the directory Vitest enumerates from; anchor them
461
+ // to it.
462
+ const scanRoot = test.dir
463
+ ? (0, devkit_1.normalizePath)((0, node_path_1.relative)(workspaceRoot, resolveTestDir(fullProjectRoot, test.dir)))
464
+ : projectRoot;
465
+ // The workspace context only reads `!` at index 0, so a negated pattern has
466
+ // to be re-prefixed rather than anchored as-is.
467
+ const anchor = (pattern) => isNegatedPattern(pattern)
468
+ ? `!${(0, devkit_1.joinPathFragments)(scanRoot, pattern.slice(1))}`
469
+ : (0, devkit_1.joinPathFragments)(scanRoot, pattern);
470
+ const globProjectFiles = (include, ignore) => {
471
+ // The workspace context treats an all-negated (or empty) include set as
472
+ // "match everything" (it inverts to the exclude set), while Vitest
473
+ // enumerates nothing. Match Vitest: without a positive entry there is
474
+ // nothing to enumerate.
475
+ if (!include.some((pattern) => !isNegatedPattern(pattern))) {
476
+ return Promise.resolve([]);
477
+ }
478
+ return (0, internal_1.globWithWorkspaceContext)(workspaceRoot, include.map(anchor),
479
+ // Vitest discards a negated `exclude` entry; forwarding it would turn the
480
+ // exclude set into an allowlist.
481
+ ignore.filter((pattern) => !isNegatedPattern(pattern)).map(anchor));
482
+ };
483
+ // Regular and type tests are independent walks; run them together.
484
+ const globJobs = [];
485
+ // Typecheck enabled with `only` makes Vitest run only type tests, so skip
486
+ // regular tests.
487
+ if (!typecheckOnly) {
488
+ const include = test.include ?? configDefaults.include;
489
+ globJobs.push(globProjectFiles(include, exclude));
490
+ }
491
+ if (typecheck?.enabled) {
492
+ const include = typecheck.include ?? configDefaults.typecheck.include;
493
+ const ignore = typecheck.exclude ?? configDefaults.typecheck.exclude;
494
+ globJobs.push(globProjectFiles(include, ignore));
495
+ }
496
+ const matches = new Set();
497
+ for (const files of await Promise.all(globJobs)) {
498
+ for (const file of files)
499
+ matches.add(file);
500
+ }
501
+ // In-source tests: only files that actually contain a test are included.
502
+ // Typecheck enabled with `only` makes Vitest run only type tests, so skip
503
+ // these too.
504
+ if (!typecheckOnly && test.includeSource?.length) {
505
+ const sourceFiles = await globProjectFiles(test.includeSource, exclude);
506
+ // The candidate set can be the whole `src` tree, so read in bounded
507
+ // batches; an unbounded Promise.all over every file risks EMFILE.
508
+ const readConcurrency = 25;
509
+ for (let i = 0; i < sourceFiles.length; i += readConcurrency) {
510
+ const inSourceMatches = await Promise.all(sourceFiles.slice(i, i + readConcurrency).map(async (file) => {
511
+ // Vitest tolerates unreadable in-source candidates and skips them;
512
+ // match that so a permission error or TOCTOU race can't abort graph
513
+ // creation.
514
+ try {
515
+ const content = await (0, promises_1.readFile)((0, node_path_1.join)(workspaceRoot, file), 'utf-8');
516
+ return content.includes('import.meta.vitest') ? file : null;
517
+ }
518
+ catch {
519
+ return null;
520
+ }
521
+ }));
522
+ for (const file of inSourceMatches) {
523
+ if (file)
524
+ matches.add(file);
525
+ }
526
+ }
527
+ }
528
+ // The workspace context returns workspace-relative paths, so re-relativize
529
+ // them to the project root. An `include` pattern (`../lib2/**`) or an
530
+ // out-of-project `test.dir` can land outside the project root, so drop what
531
+ // does, matching the runtime path.
532
+ return [...matches]
533
+ .map((file) => (0, devkit_1.normalizePath)((0, node_path_1.relative)(projectRoot, file)))
534
+ .filter((file) => !file.startsWith('../'))
535
+ .sort();
536
+ }
537
+ // Vitest reads a leading `!` as a negation, except when it opens an extglob
538
+ // (`!(...)`), which the runtime gate routes away before discovery reaches here.
539
+ function isNegatedPattern(pattern) {
540
+ return pattern.startsWith('!') && !pattern.startsWith('!(');
541
+ }
542
+ /**
543
+ * Whether the workspace context resolves a pattern differently than Vitest.
544
+ * Anchoring rewrites an absolute pattern into a project-relative one that
545
+ * enumerates the wrong location; a trailing `/` becomes a recursive directory
546
+ * match where Vitest (globbing with `expandDirectories: false`) matches
547
+ * nothing; and `!(...)` converts to an include plus literal exclusions that
548
+ * reproduces Vitest's extglob only for some shapes. A leading `!` is stripped
549
+ * first so a negated form of any of these still routes to the runtime.
550
+ */
551
+ function patternRequiresVitestRuntime(pattern) {
552
+ const bare = isNegatedPattern(pattern) ? pattern.slice(1) : pattern;
553
+ return (0, node_path_1.isAbsolute)(bare) || bare.endsWith('/') || bare.includes('!(');
554
+ }
555
+ // Sibling files Vitest 3 auto-loads to define sub-projects even when the config
556
+ // object declares none. Removed in Vitest 4 in favor of inline `test.projects`.
557
+ const vitestWorkspaceFiles = ['vitest.workspace', 'vitest.projects'].flatMap((name) => ['ts', 'mts', 'cts', 'js', 'mjs', 'cjs', 'json'].map((ext) => `${name}.${ext}`));
558
+ /**
559
+ * Whether a config's test discovery cannot be faithfully reproduced with a
560
+ * glob, so enumeration must go through Vitest itself.
561
+ */
562
+ function configRequiresVitestRuntime(
563
+ // `workspace` (removed in Vitest 4) and the CLI-only `changed`/`related`
564
+ // filters are not part of the config-file `InlineConfig` type.
565
+ test, viteConfig, projectDir) {
566
+ // Multi-project configs resolve include/exclude per sub-project.
567
+ if (Array.isArray(test?.projects) || test?.workspace)
568
+ return true;
569
+ // Vitest 3 auto-loads a `vitest.workspace.*`/`vitest.projects.*` sibling file
570
+ // to define sub-projects even when the config object declares none; the glob
571
+ // resolves only the single config, so it cannot reproduce that.
572
+ if (vitestWorkspaceFiles.some((file) => (0, node_fs_1.existsSync)((0, node_path_1.join)(projectDir, file)))) {
573
+ return true;
574
+ }
575
+ // Vitest filters specs by VCS/graph state, which a glob cannot know.
576
+ if (test?.changed || test?.related)
577
+ return true;
578
+ // Vitest's own defaults carry none of these shapes, so only a config that
579
+ // spells one out reaches the runtime for this reason.
580
+ const configuredPatterns = [
581
+ test?.include,
582
+ test?.exclude,
583
+ test?.includeSource,
584
+ test?.typecheck?.include,
585
+ test?.typecheck?.exclude,
586
+ ]
587
+ .filter(Array.isArray)
588
+ .flat();
589
+ if (configuredPatterns.some(patternRequiresVitestRuntime))
590
+ return true;
591
+ // Browser mode: an instance can override include/exclude/includeSource, and
592
+ // `dir` (the base directory Vitest scans), so a top-level glob enumerates a
593
+ // different spec set than the instance would. Vitest ignores `instances`
594
+ // while browser mode is off, and the atomized target runs `vitest run <file>`
595
+ // without a browser flag, so the resolved `enabled` matches the run.
596
+ const browserInstances = test?.browser?.instances ?? [];
597
+ if (test?.browser?.enabled &&
598
+ browserInstances.some((instance) => instance &&
599
+ (instance.include?.length ||
600
+ instance.exclude?.length ||
601
+ instance.includeSource?.length ||
602
+ instance.dir))) {
603
+ return true;
604
+ }
605
+ // A plugin can inject or reshape projects through this Vitest-only hook.
606
+ const plugins = viteConfig?.plugins ?? [];
607
+ return plugins.some((plugin) => plugin && typeof plugin === 'object' && 'configureVitest' in plugin);
608
+ }
609
+ async function getTestPathsViaVitestRuntime(fullProjectRoot, projectRoot, testDir) {
397
610
  const { createVitest } = await import('vitest/node');
398
611
  const vitest = await createVitest('test', {
399
612
  root: fullProjectRoot,
400
- dir: fullProjectRoot,
613
+ // `dir` defaults to `root`, and a relative `test.dir` would resolve against
614
+ // the working directory, which during graph creation is the workspace root
615
+ // rather than the project root.
616
+ dir: resolveTestDir(fullProjectRoot, testDir),
401
617
  filesOnly: true,
402
618
  watch: false,
403
619
  });
@@ -1,2 +1,3 @@
1
- export declare function loadViteDynamicImport(): Promise<any>;
1
+ export declare function loadViteDynamicImport(): Promise<typeof import('vite')>;
2
2
  export declare function loadVitestDynamicImport(): Promise<typeof import('vitest/node')>;
3
+ export declare function loadVitestConfigDynamicImport(): Promise<typeof import('vitest/config')>;
@@ -2,12 +2,13 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.loadViteDynamicImport = loadViteDynamicImport;
4
4
  exports.loadVitestDynamicImport = loadVitestDynamicImport;
5
- // TODO(jack): Remove this cast when @nx/vitest switches to moduleResolution:
6
- // "nodenext". Vite 8 ships ESM-only type declarations (.d.mts) not resolvable
7
- // under moduleResolution: "node".
5
+ exports.loadVitestConfigDynamicImport = loadVitestConfigDynamicImport;
8
6
  function loadViteDynamicImport() {
9
7
  return Function('return import("vite")')();
10
8
  }
11
9
  function loadVitestDynamicImport() {
12
10
  return Function('return import("vitest/node")')();
13
11
  }
12
+ function loadVitestConfigDynamicImport() {
13
+ return Function('return import("vitest/config")')();
14
+ }
@@ -1,3 +1,3 @@
1
- import type { Tree } from 'nx/src/generators/tree';
1
+ import { type Tree } from '@nx/devkit';
2
2
  export declare function getInstalledViteVersion(tree: Tree): string;
3
3
  export declare function getInstalledViteMajorVersion(tree: Tree): 5 | 6 | 7 | 8 | undefined;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nx/vitest",
3
3
  "description": "The Nx Plugin for Vitest to enable fast unit testing with Vitest.",
4
- "version": "23.1.0",
4
+ "version": "23.1.2",
5
5
  "type": "commonjs",
6
6
  "files": [
7
7
  "dist",
@@ -73,13 +73,13 @@
73
73
  "tslib": "^2.3.0",
74
74
  "semver": "^7.6.3",
75
75
  "@phenomnomnominal/tsquery": "~6.2.0",
76
- "@nx/devkit": "23.1.0",
77
- "@nx/js": "23.1.0"
76
+ "@nx/devkit": "23.1.2",
77
+ "@nx/js": "23.1.2"
78
78
  },
79
79
  "peerDependencies": {
80
80
  "vitest": "^3.0.0 || ^4.0.0",
81
81
  "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0",
82
- "@nx/eslint": "23.1.0"
82
+ "@nx/eslint": "23.1.2"
83
83
  },
84
84
  "peerDependenciesMeta": {
85
85
  "@nx/eslint": {
@@ -93,6 +93,6 @@
93
93
  }
94
94
  },
95
95
  "devDependencies": {
96
- "nx": "23.1.0"
96
+ "nx": "23.1.2"
97
97
  }
98
98
  }