Skip to content

Commit afdcb8f

Browse files
authored
fix(workspace): correctly resolve workspace globs and file paths (#6316)
1 parent abd85e3 commit afdcb8f

File tree

19 files changed

+390
-164
lines changed

19 files changed

+390
-164
lines changed

packages/vitest/src/constants.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export const CONFIG_NAMES = ['vitest.config', 'vite.config']
1616

1717
const WORKSPACES_NAMES = ['vitest.workspace', 'vitest.projects']
1818

19-
const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']
19+
export const CONFIG_EXTENSIONS = ['.ts', '.mts', '.cts', '.js', '.mjs', '.cjs']
2020

2121
export const configFiles = CONFIG_NAMES.flatMap(name =>
2222
CONFIG_EXTENSIONS.map(ext => name + ext),

packages/vitest/src/node/core.ts

Lines changed: 17 additions & 153 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,7 @@
11
import { existsSync, promises as fs } from 'node:fs'
22
import type { Writable } from 'node:stream'
3-
import { isMainThread } from 'node:worker_threads'
43
import type { ViteDevServer } from 'vite'
5-
import { mergeConfig } from 'vite'
6-
import { basename, dirname, join, normalize, relative, resolve } from 'pathe'
7-
import fg from 'fast-glob'
4+
import { dirname, join, normalize, relative, resolve } from 'pathe'
85
import mm from 'micromatch'
96
import { ViteNodeRunner } from 'vite-node/client'
107
import { SnapshotManager } from '@vitest/snapshot/manager'
@@ -14,7 +11,7 @@ import type { defineWorkspace } from 'vitest/config'
1411
import { version } from '../../package.json' with { type: 'json' }
1512
import { getTasks, hasFailed, noop, slash, toArray, wildcardPatternToRegExp } from '../utils'
1613
import { getCoverageProvider } from '../integrations/coverage'
17-
import { CONFIG_NAMES, configFiles, workspacesFiles as workspaceFiles } from '../constants'
14+
import { workspacesFiles as workspaceFiles } from '../constants'
1815
import { rootDir } from '../paths'
1916
import { WebSocketReporter } from '../api/setup'
2017
import type { SerializedCoverageConfig } from '../runtime/config'
@@ -27,13 +24,14 @@ import { StateManager } from './state'
2724
import { resolveConfig } from './config/resolveConfig'
2825
import { Logger } from './logger'
2926
import { VitestCache } from './cache'
30-
import { WorkspaceProject, initializeProject } from './workspace'
27+
import { WorkspaceProject } from './workspace'
3128
import { VitestPackageInstaller } from './packageInstaller'
3229
import { BlobReporter, readBlobs } from './reporters/blob'
3330
import { FilesNotFoundError, GitNotFoundError } from './errors'
34-
import type { ResolvedConfig, UserConfig, UserWorkspaceConfig, VitestRunMode } from './types/config'
31+
import type { ResolvedConfig, UserConfig, VitestRunMode } from './types/config'
3532
import type { Reporter } from './types/reporter'
3633
import type { CoverageProvider } from './types/coverage'
34+
import { resolveWorkspace } from './workspace/resolveWorkspace'
3735

3836
const WATCHER_DEBOUNCE = 100
3937

@@ -192,7 +190,10 @@ export class Vitest {
192190
this.getCoreWorkspaceProject().provide(key, value)
193191
}
194192

195-
private async createCoreProject() {
193+
/**
194+
* @internal
195+
*/
196+
async _createCoreProject() {
196197
this.coreWorkspaceProject = await WorkspaceProject.createCoreProject(this)
197198
return this.coreWorkspaceProject
198199
}
@@ -241,160 +242,23 @@ export class Vitest {
241242
const workspaceConfigPath = await this.getWorkspaceConfigPath()
242243

243244
if (!workspaceConfigPath) {
244-
return [await this.createCoreProject()]
245+
return [await this._createCoreProject()]
245246
}
246247

247248
const workspaceModule = await this.runner.executeFile(workspaceConfigPath) as {
248249
default: ReturnType<typeof defineWorkspace>
249250
}
250251

251252
if (!workspaceModule.default || !Array.isArray(workspaceModule.default)) {
252-
throw new Error(`Workspace config file ${workspaceConfigPath} must export a default array of project paths.`)
253-
}
254-
255-
const workspaceGlobMatches: string[] = []
256-
const projectsOptions: UserWorkspaceConfig[] = []
257-
258-
for (const project of workspaceModule.default) {
259-
if (typeof project === 'string') {
260-
workspaceGlobMatches.push(project.replace('<rootDir>', this.config.root))
261-
}
262-
else if (typeof project === 'function') {
263-
projectsOptions.push(await project({
264-
command: this.server.config.command,
265-
mode: this.server.config.mode,
266-
isPreview: false,
267-
isSsrBuild: false,
268-
}))
269-
}
270-
else {
271-
projectsOptions.push(await project)
272-
}
273-
}
274-
275-
const globOptions: fg.Options = {
276-
absolute: true,
277-
dot: true,
278-
onlyFiles: false,
279-
markDirectories: true,
280-
cwd: this.config.root,
281-
ignore: ['**/node_modules/**', '**/*.timestamp-*'],
282-
}
283-
284-
const workspacesFs = await fg(workspaceGlobMatches, globOptions)
285-
const resolvedWorkspacesPaths = await Promise.all(workspacesFs.filter((file) => {
286-
if (file.endsWith('/')) {
287-
// if it's a directory, check that we don't already have a workspace with a config inside
288-
const hasWorkspaceWithConfig = workspacesFs.some((file2) => {
289-
return file2 !== file && `${dirname(file2)}/` === file
290-
})
291-
return !hasWorkspaceWithConfig
292-
}
293-
const filename = basename(file)
294-
return CONFIG_NAMES.some(configName => filename.startsWith(configName))
295-
}).map(async (filepath) => {
296-
if (filepath.endsWith('/')) {
297-
const filesInside = await fs.readdir(filepath)
298-
const configFile = configFiles.find(config => filesInside.includes(config))
299-
return configFile ? join(filepath, configFile) : filepath
300-
}
301-
return filepath
302-
}))
303-
304-
const workspacesByFolder = resolvedWorkspacesPaths
305-
.reduce((configByFolder, filepath) => {
306-
const dir = filepath.endsWith('/') ? filepath.slice(0, -1) : dirname(filepath)
307-
configByFolder[dir] ??= []
308-
configByFolder[dir].push(filepath)
309-
return configByFolder
310-
}, {} as Record<string, string[]>)
311-
312-
const filteredWorkspaces = Object.values(workspacesByFolder).map((configFiles) => {
313-
if (configFiles.length === 1) {
314-
return configFiles[0]
315-
}
316-
const vitestConfig = configFiles.find(configFile => basename(configFile).startsWith('vitest.config'))
317-
return vitestConfig || configFiles[0]
318-
})
319-
320-
const overridesOptions = [
321-
'logHeapUsage',
322-
'allowOnly',
323-
'sequence',
324-
'testTimeout',
325-
'pool',
326-
'update',
327-
'globals',
328-
'expandSnapshotDiff',
329-
'disableConsoleIntercept',
330-
'retry',
331-
'testNamePattern',
332-
'passWithNoTests',
333-
'bail',
334-
'isolate',
335-
'printConsoleTrace',
336-
] as const
337-
338-
const cliOverrides = overridesOptions.reduce((acc, name) => {
339-
if (name in cliOptions) {
340-
acc[name] = cliOptions[name] as any
341-
}
342-
return acc
343-
}, {} as UserConfig)
344-
345-
const cwd = process.cwd()
346-
347-
const projects: WorkspaceProject[] = []
348-
349-
try {
350-
// we have to resolve them one by one because CWD should depend on the project
351-
for (const filepath of filteredWorkspaces) {
352-
if (this.server.config.configFile === filepath) {
353-
const project = await this.createCoreProject()
354-
projects.push(project)
355-
continue
356-
}
357-
const dir = filepath.endsWith('/') ? filepath.slice(0, -1) : dirname(filepath)
358-
if (isMainThread) {
359-
process.chdir(dir)
360-
}
361-
projects.push(
362-
await initializeProject(filepath, this, { workspaceConfigPath, test: cliOverrides }),
363-
)
364-
}
365-
}
366-
finally {
367-
if (isMainThread) {
368-
process.chdir(cwd)
369-
}
253+
throw new TypeError(`Workspace config file "${workspaceConfigPath}" must export a default array of project paths.`)
370254
}
371255

372-
const projectPromises: Promise<WorkspaceProject>[] = []
373-
374-
projectsOptions.forEach((options, index) => {
375-
// we can resolve these in parallel because process.cwd() is not changed
376-
projectPromises.push(initializeProject(index, this, mergeConfig(options, { workspaceConfigPath, test: cliOverrides }) as any))
377-
})
378-
379-
if (!projects.length && !projectPromises.length) {
380-
return [await this.createCoreProject()]
381-
}
382-
383-
const resolvedProjects = await Promise.all([
384-
...projects,
385-
...await Promise.all(projectPromises),
386-
])
387-
const names = new Set<string>()
388-
389-
for (const project of resolvedProjects) {
390-
const name = project.getName()
391-
if (names.has(name)) {
392-
throw new Error(`Project name "${name}" is not unique. All projects in a workspace should have unique names.`)
393-
}
394-
names.add(name)
395-
}
396-
397-
return resolvedProjects
256+
return resolveWorkspace(
257+
this,
258+
cliOptions,
259+
workspaceConfigPath,
260+
workspaceModule.default,
261+
)
398262
}
399263

400264
private async initCoverageProvider() {

packages/vitest/src/node/workspace.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,12 +52,6 @@ export async function initializeProject(
5252
) {
5353
const project = new WorkspaceProject(workspacePath, ctx, options)
5454

55-
const configFile = options.extends
56-
? resolve(dirname(options.workspaceConfigPath), options.extends)
57-
: typeof workspacePath === 'number' || workspacePath.endsWith('/')
58-
? false
59-
: workspacePath
60-
6155
const root
6256
= options.root
6357
|| (typeof workspacePath === 'number'
@@ -66,6 +60,12 @@ export async function initializeProject(
6660
? workspacePath
6761
: dirname(workspacePath))
6862

63+
const configFile = options.extends
64+
? resolve(dirname(options.workspaceConfigPath), options.extends)
65+
: typeof workspacePath === 'number' || workspacePath.endsWith('/')
66+
? false
67+
: workspacePath
68+
6969
const config: ViteInlineConfig = {
7070
...options,
7171
root,

0 commit comments

Comments
 (0)