diff --git a/docs/api/vi.md b/docs/api/vi.md index a3c61e070e07..0d6c7b0cbb38 100644 --- a/docs/api/vi.md +++ b/docs/api/vi.md @@ -234,7 +234,7 @@ import { vi } from 'vitest' ``` ::: - If there is a `__mocks__` folder alongside a file that you are mocking, and the factory is not provided, Vitest will try to find a file with the same name in the `__mocks__` subfolder and use it as an actual module. If you are mocking a dependency, Vitest will try to find a `__mocks__` folder in the [root](/config/#root) of the project (default is `process.cwd()`). + If there is a `__mocks__` folder alongside a file that you are mocking, and the factory is not provided, Vitest will try to find a file with the same name in the `__mocks__` subfolder and use it as an actual module. If you are mocking a dependency, Vitest will try to find a `__mocks__` folder in the [root](/config/#root) of the project (default is `process.cwd()`). You can tell Vitest where the dependencies are located through the [deps.moduleDirectories](/config/#deps-moduledirectories) config option. For example, you have this file structure: diff --git a/docs/config/index.md b/docs/config/index.md index f45d642f93ce..215b3a28a08d 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -190,6 +190,29 @@ TypeError: default is not a function By default, Vitest assumes you are using a bundler to bypass this and will not fail, but you can disable this behaviour manually, if you code is not processed. +#### deps.moduleDirectories + +- **Type:** `string[]` +- **Default**: `['node_modules']` + +A list of directories that should be treated as module directories. This config option affects the behavior of [`vi.mock`](/api/vi#vi-mock): when no factory is provided and the path of what you are mocking matches one of the `moduleDirectories` values, Vitest will try to resolve the mock by looking for a `__mocks__` folder in the [root](/config/#root) of the project. + +This option will also affect if a file should be treated as a module when externalizing dependencies. By default, Vitest imports external modules with native Node.js bypassing Vite transformation step. + +Setting this option will _override_ the default, if you wish to still search `node_modules` for packages include it along with any other options: + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + deps: { + moduleDirectories: ['node_modules', path.resolve('../../packages')], + } + }, +}) +``` + ### runner - **Type**: `VitestRunnerConstructor` diff --git a/examples/mocks/__mocks__/custom-lib.ts b/examples/mocks/__mocks__/custom-lib.ts new file mode 100644 index 000000000000..deb7cbe32704 --- /dev/null +++ b/examples/mocks/__mocks__/custom-lib.ts @@ -0,0 +1,3 @@ +export default function () { + return 'mocked' +} diff --git a/examples/mocks/projects/custom-lib/index.js b/examples/mocks/projects/custom-lib/index.js new file mode 100644 index 000000000000..9b10654538e6 --- /dev/null +++ b/examples/mocks/projects/custom-lib/index.js @@ -0,0 +1,3 @@ +export default function () { + return 'original' +} diff --git a/examples/mocks/projects/custom-lib/package.json b/examples/mocks/projects/custom-lib/package.json new file mode 100644 index 000000000000..3dbc1ca591c0 --- /dev/null +++ b/examples/mocks/projects/custom-lib/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/examples/mocks/test/custom-module-directory.spec.ts b/examples/mocks/test/custom-module-directory.spec.ts new file mode 100644 index 000000000000..7f914ceb1b11 --- /dev/null +++ b/examples/mocks/test/custom-module-directory.spec.ts @@ -0,0 +1,8 @@ +// @ts-expect-error not typed aliased import +import getState from 'custom-lib' + +vi.mock('custom-lib') + +it('state is mocked', () => { + expect(getState()).toBe('mocked') +}) diff --git a/examples/mocks/vite.config.ts b/examples/mocks/vite.config.ts index 34f684a689f0..76cb1dad1e3e 100644 --- a/examples/mocks/vite.config.ts +++ b/examples/mocks/vite.config.ts @@ -1,5 +1,6 @@ /// +import { resolve } from 'node:path' import { defineConfig } from 'vite' // https://vitejs.dev/config/ @@ -20,12 +21,18 @@ export default defineConfig({ }, }, ], + resolve: { + alias: [ + { find: /^custom-lib$/, replacement: resolve(__dirname, 'projects', 'custom-lib') }, + ], + }, test: { globals: true, environment: 'node', deps: { external: [/src\/external/], interopDefault: true, + moduleDirectories: ['node_modules', 'projects'], }, }, }) diff --git a/packages/vite-node/src/cli.ts b/packages/vite-node/src/cli.ts index 8d24f4489cfd..ef8e607e47f0 100644 --- a/packages/vite-node/src/cli.ts +++ b/packages/vite-node/src/cli.ts @@ -128,6 +128,9 @@ function parseServerOptions(serverOptions: ViteNodeServerOptionsCLI): ViteNodeSe ? new RegExp(dep) : dep }), + moduleDirectories: serverOptions.deps?.moduleDirectories + ? toArray(serverOptions.deps?.moduleDirectories) + : undefined, }, transformMode: { diff --git a/packages/vite-node/src/externalize.ts b/packages/vite-node/src/externalize.ts index 507c24b6d7e8..d0433d8c3d5f 100644 --- a/packages/vite-node/src/externalize.ts +++ b/packages/vite-node/src/externalize.ts @@ -1,5 +1,6 @@ import { existsSync } from 'node:fs' import { isNodeBuiltin, isValidNodeImport } from 'mlly' +import { join } from 'pathe' import type { DepsHandlingOptions } from './types' import { slash } from './utils' @@ -109,35 +110,37 @@ async function _shouldExternalize( if (options?.cacheDir && id.includes(options.cacheDir)) return id - if (matchExternalizePattern(id, options?.inline)) + const moduleDirectories = options?.moduleDirectories || ['/node_modules/'] + + if (matchExternalizePattern(id, moduleDirectories, options?.inline)) return false - if (matchExternalizePattern(id, options?.external)) + if (matchExternalizePattern(id, moduleDirectories, options?.external)) return id - const isNodeModule = id.includes('/node_modules/') - const guessCJS = isNodeModule && options?.fallbackCJS + const isLibraryModule = moduleDirectories.some(dir => id.includes(dir)) + const guessCJS = isLibraryModule && options?.fallbackCJS id = guessCJS ? (guessCJSversion(id) || id) : id - if (matchExternalizePattern(id, defaultInline)) + if (matchExternalizePattern(id, moduleDirectories, defaultInline)) return false - if (matchExternalizePattern(id, depsExternal)) + if (matchExternalizePattern(id, moduleDirectories, depsExternal)) return id const isDist = id.includes('/dist/') - if ((isNodeModule || isDist) && await isValidNodeImport(id)) + if ((isLibraryModule || isDist) && await isValidNodeImport(id)) return id return false } -function matchExternalizePattern(id: string, patterns?: (string | RegExp)[] | true) { +function matchExternalizePattern(id: string, moduleDirectories: string[], patterns?: (string | RegExp)[] | true) { if (patterns == null) return false if (patterns === true) return true for (const ex of patterns) { if (typeof ex === 'string') { - if (id.includes(`/node_modules/${ex}/`)) + if (moduleDirectories.some(dir => id.includes(join(dir, id)))) return true } else { diff --git a/packages/vite-node/src/types.ts b/packages/vite-node/src/types.ts index 251278e282a8..35a94ad498ae 100644 --- a/packages/vite-node/src/types.ts +++ b/packages/vite-node/src/types.ts @@ -9,6 +9,7 @@ export type Awaitable = T | PromiseLike export interface DepsHandlingOptions { external?: (string | RegExp)[] inline?: (string | RegExp)[] | true + moduleDirectories?: string[] cacheDir?: string /** * Try to guess the CJS version of a package when it's invalid ESM diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index ae3cfe462329..e1648daccc8f 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -84,7 +84,7 @@ export function resolveConfig( ...options, root: viteConfig.root, mode, - } as ResolvedConfig + } as any as ResolvedConfig resolved.inspect = Boolean(resolved.inspect) resolved.inspectBrk = Boolean(resolved.inspectBrk) @@ -136,6 +136,14 @@ export function resolveConfig( resolved.deps.inline.push(...extraInlineDeps) } } + resolved.deps.moduleDirectories ??= ['/node_modules/'] + resolved.deps.moduleDirectories = resolved.deps.moduleDirectories.map((dir) => { + if (!dir.startsWith('/')) + dir = `/${dir}` + if (!dir.endsWith('/')) + dir += '/' + return normalize(dir) + }) if (resolved.runner) { resolved.runner = resolveModule(resolved.runner, { paths: [resolved.root] }) diff --git a/packages/vitest/src/runtime/execute.ts b/packages/vitest/src/runtime/execute.ts index 7fb8f2622cad..bacd3c3b1747 100644 --- a/packages/vitest/src/runtime/execute.ts +++ b/packages/vitest/src/runtime/execute.ts @@ -13,6 +13,7 @@ import { rpc } from './rpc' export interface ExecuteOptions extends ViteNodeRunnerOptions { mockMap: MockMap + moduleDirectories?: string[] } export async function createVitestExecutor(options: ExecuteOptions) { @@ -70,6 +71,7 @@ export async function startViteNode(ctx: ContextRPC) { moduleCache, mockMap, interopDefault: config.deps.interopDefault, + moduleDirectories: config.deps.moduleDirectories, root: config.root, base: config.base, }) diff --git a/packages/vitest/src/runtime/mocker.ts b/packages/vitest/src/runtime/mocker.ts index b765aedee11a..fced34286193 100644 --- a/packages/vitest/src/runtime/mocker.ts +++ b/packages/vitest/src/runtime/mocker.ts @@ -58,12 +58,20 @@ export class VitestMocker { return this.executor.moduleCache } + private get moduleDirectories() { + return this.executor.options.moduleDirectories || [] + } + private deleteCachedItem(id: string) { const mockId = this.getMockPath(id) if (this.moduleCache.has(mockId)) this.moduleCache.delete(mockId) } + private isAModuleDirectory(path: string) { + return this.moduleDirectories.some(dir => path.includes(dir)) + } + public getSuiteFilepath(): string { return getWorkerState().filepath || 'global' } @@ -83,7 +91,7 @@ export class VitestMocker { const [id, fsPath] = await this.executor.resolveUrl(rawId, importer) // external is node_module or unresolved module // for example, some people mock "vscode" and don't have it installed - const external = (!isAbsolute(fsPath) || fsPath.includes('/node_modules/')) ? rawId : null + const external = (!isAbsolute(fsPath) || this.isAModuleDirectory(fsPath)) ? rawId : null return { id, diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index 0956b38e8599..e8e9b2f064ff 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -116,6 +116,13 @@ interface DepsOptions { * @default false */ registerNodeLoader?: boolean + + /** + * A list of directories relative to the config file that should be treated as module directories. + * + * @default ['node_modules'] + */ + moduleDirectories?: string[] } export interface InlineConfig { @@ -732,7 +739,7 @@ export type ProjectConfig = Omit< | 'coverage' > & { sequencer?: Omit - deps?: Omit + deps?: Omit } export type RuntimeConfig = Pick< diff --git a/packages/web-worker/src/utils.ts b/packages/web-worker/src/utils.ts index 22487a92df43..9a59d4101e5c 100644 --- a/packages/web-worker/src/utils.ts +++ b/packages/web-worker/src/utils.ts @@ -73,6 +73,7 @@ export function getRunnerOptions(): any { moduleCache, mockMap, interopDefault: config.deps.interopDefault ?? true, + moduleDirectories: config.deps.moduleDirectories, root: config.root, base: config.base, } diff --git a/test/core/projects/custom-lib/index.js b/test/core/projects/custom-lib/index.js new file mode 100644 index 000000000000..b1d53e20c67b --- /dev/null +++ b/test/core/projects/custom-lib/index.js @@ -0,0 +1,4 @@ +export default function () { + // module doesn't exist in Node.js ESM, but exists in vite-node + return typeof module +} diff --git a/test/core/projects/custom-lib/package.json b/test/core/projects/custom-lib/package.json new file mode 100644 index 000000000000..3dbc1ca591c0 --- /dev/null +++ b/test/core/projects/custom-lib/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/test/core/test/external-module-directory.test.ts b/test/core/test/external-module-directory.test.ts new file mode 100644 index 000000000000..41b4f97f03a1 --- /dev/null +++ b/test/core/test/external-module-directory.test.ts @@ -0,0 +1,7 @@ +// @ts-expect-error not typed aliased import +import getModuleType from 'custom-lib' +import { expect, it } from 'vitest' + +it('custom-lib is externalized because it\'s a valid esm file in module directory', () => { + expect(getModuleType()).toBe('undefined') +}) diff --git a/test/core/vitest.config.ts b/test/core/vitest.config.ts index 4a675e9e7b84..4f27df9a19b7 100644 --- a/test/core/vitest.config.ts +++ b/test/core/vitest.config.ts @@ -35,6 +35,7 @@ export default defineConfig({ alias: [ { find: '#', replacement: resolve(__dirname, 'src') }, { find: '$', replacement: 'src' }, + { find: /^custom-lib$/, replacement: resolve(__dirname, 'projects', 'custom-lib') }, ], }, test: { @@ -62,6 +63,7 @@ export default defineConfig({ }, deps: { external: ['tinyspy', /src\/external/], + moduleDirectories: ['node_modules', 'projects'], }, alias: [ {