Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
feat(dev): add moduleDirectories option to the vitest config (#3337)
Co-authored-by: Vladimir Sheremet <sleuths.slews0s@icloud.com>
  • Loading branch information
fooddilsn and sheremet-va committed May 30, 2023
1 parent 8254737 commit b3602bc
Show file tree
Hide file tree
Showing 19 changed files with 109 additions and 13 deletions.
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

0 comments on commit b3602bc

Please sign in to comment.