Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(dev): add moduleDirectories option to the vitest config #3337

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/api/vi.md
Expand Up @@ -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:

Expand Down
23 changes: 23 additions & 0 deletions docs/config/index.md
Expand Up @@ -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`
Expand Down
3 changes: 3 additions & 0 deletions examples/mocks/__mocks__/custom-lib.ts
@@ -0,0 +1,3 @@
export default function () {
return 'mocked'
}
3 changes: 3 additions & 0 deletions examples/mocks/projects/custom-lib/index.js
@@ -0,0 +1,3 @@
export default function () {
return 'original'
}
3 changes: 3 additions & 0 deletions examples/mocks/projects/custom-lib/package.json
@@ -0,0 +1,3 @@
{
"type": "module"
}
8 changes: 8 additions & 0 deletions 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')
})
7 changes: 7 additions & 0 deletions examples/mocks/vite.config.ts
@@ -1,5 +1,6 @@
/// <reference types="vitest" />

import { resolve } from 'node:path'
import { defineConfig } from 'vite'

// https://vitejs.dev/config/
Expand All @@ -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'],
},
},
})
3 changes: 3 additions & 0 deletions packages/vite-node/src/cli.ts
Expand Up @@ -128,6 +128,9 @@ function parseServerOptions(serverOptions: ViteNodeServerOptionsCLI): ViteNodeSe
? new RegExp(dep)
: dep
}),
moduleDirectories: serverOptions.deps?.moduleDirectories
? toArray(serverOptions.deps?.moduleDirectories)
: undefined,
},

transformMode: {
Expand Down
21 changes: 12 additions & 9 deletions 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'

Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/vite-node/src/types.ts
Expand Up @@ -9,6 +9,7 @@ export type Awaitable<T> = T | PromiseLike<T>
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
Expand Down
10 changes: 9 additions & 1 deletion packages/vitest/src/node/config.ts
Expand Up @@ -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)
Expand Down Expand Up @@ -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] })
Expand Down
2 changes: 2 additions & 0 deletions packages/vitest/src/runtime/execute.ts
Expand Up @@ -13,6 +13,7 @@ import { rpc } from './rpc'

export interface ExecuteOptions extends ViteNodeRunnerOptions {
mockMap: MockMap
moduleDirectories?: string[]
}

export async function createVitestExecutor(options: ExecuteOptions) {
Expand Down Expand Up @@ -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,
})
Expand Down
10 changes: 9 additions & 1 deletion packages/vitest/src/runtime/mocker.ts
Expand Up @@ -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'
}
Expand All @@ -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,
Expand Down
9 changes: 8 additions & 1 deletion packages/vitest/src/types/config.ts
Expand Up @@ -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 {
Expand Down Expand Up @@ -732,7 +739,7 @@ export type ProjectConfig = Omit<
| 'coverage'
> & {
sequencer?: Omit<SequenceOptions, 'sequencer' | 'seed'>
deps?: Omit<DepsOptions, 'registerNodeLoader'>
deps?: Omit<DepsOptions, 'registerNodeLoader' | 'moduleDirectories'>
}

export type RuntimeConfig = Pick<
Expand Down
1 change: 1 addition & 0 deletions packages/web-worker/src/utils.ts
Expand Up @@ -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,
}
Expand Down
4 changes: 4 additions & 0 deletions 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
}
3 changes: 3 additions & 0 deletions test/core/projects/custom-lib/package.json
@@ -0,0 +1,3 @@
{
"type": "module"
}
7 changes: 7 additions & 0 deletions 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')
})
2 changes: 2 additions & 0 deletions test/core/vitest.config.ts
Expand Up @@ -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: {
Expand Down Expand Up @@ -62,6 +63,7 @@ export default defineConfig({
},
deps: {
external: ['tinyspy', /src\/external/],
moduleDirectories: ['node_modules', 'projects'],
},
alias: [
{
Expand Down