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: [
{