Skip to content

Commit 0c09a40

Browse files
authoredApr 27, 2023
feat!: update mock implementation to support ESM runtime, introduce "vi.hoisted" (#3258)
1 parent da2f197 commit 0c09a40

39 files changed

+2430
-693
lines changed
 

‎docs/api/expect.md

+7-7
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ type Awaitable<T> = T | PromiseLike<T>
138138

139139
```ts
140140
import { Stocks } from './stocks.js'
141-
141+
142142
const stocks = new Stocks()
143143
stocks.sync('Bill')
144144
if (stocks.getInfo('Bill'))
@@ -150,7 +150,7 @@ type Awaitable<T> = T | PromiseLike<T>
150150
```ts
151151
import { expect, test } from 'vitest'
152152
import { Stocks } from './stocks.js'
153-
153+
154154
const stocks = new Stocks()
155155

156156
test('if we know Bill stock, sell apples to him', () => {
@@ -171,7 +171,7 @@ type Awaitable<T> = T | PromiseLike<T>
171171

172172
```ts
173173
import { Stocks } from './stocks.js'
174-
174+
175175
const stocks = new Stocks()
176176
stocks.sync('Bill')
177177
if (!stocks.stockFailed('Bill'))
@@ -183,7 +183,7 @@ type Awaitable<T> = T | PromiseLike<T>
183183
```ts
184184
import { expect, test } from 'vitest'
185185
import { Stocks } from './stocks.js'
186-
186+
187187
const stocks = new Stocks()
188188

189189
test('if Bill stock hasn\'t failed, sell apples to him', () => {
@@ -242,7 +242,7 @@ type Awaitable<T> = T | PromiseLike<T>
242242

243243
```ts
244244
import { expect, test } from 'vitest'
245-
245+
246246
const actual = 'stock'
247247

248248
test('stock is type of string', () => {
@@ -259,7 +259,7 @@ type Awaitable<T> = T | PromiseLike<T>
259259
```ts
260260
import { expect, test } from 'vitest'
261261
import { Stocks } from './stocks.js'
262-
262+
263263
const stocks = new Stocks()
264264

265265
test('stocks are instance of Stocks', () => {
@@ -695,7 +695,7 @@ If the value in the error message is too truncated, you can increase [chaiConfig
695695
## toMatchFileSnapshot
696696

697697
- **Type:** `<T>(filepath: string, message?: string) => Promise<void>`
698-
- **Version:** Vitest 0.30.0
698+
- **Version:** Since Vitest 0.30.0
699699

700700
Compare or update the snapshot with the content of a file explicitly specified (instead of the `.snap` file).
701701

‎docs/api/vi.md

+69-2
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,55 @@ import { vi } from 'vitest'
114114

115115
When using `vi.useFakeTimers`, `Date.now` calls are mocked. If you need to get real time in milliseconds, you can call this function.
116116

117+
## vi.hoisted
118+
119+
- **Type**: `<T>(factory: () => T) => T`
120+
- **Version**: Since Vitest 0.31.0
121+
122+
All static `import` statements in ES modules are hoisted to top of the file, so any code that is define before the imports will actually be executed after imports are evaluated.
123+
124+
Hovewer it can be useful to invoke some side effect like mocking dates before importing a module.
125+
126+
To bypass this limitation, you can rewrite static imports into dynamic ones like this:
127+
128+
```diff
129+
callFunctionWithSideEffect()
130+
- import { value } from './some/module.ts'
131+
+ const { value } = await import('./some/module.ts')
132+
```
133+
134+
When running `vitest`, you can do this automatically by using `vi.hoisted` method.
135+
136+
```diff
137+
- callFunctionWithSideEffect()
138+
import { value } from './some/module.ts'
139+
+ vi.hoisted(() => callFunctionWithSideEffect())
140+
```
141+
142+
This method returns the value that was returned from the factory. You can use that value in your `vi.mock` factories if you need an easy access to locally defined variables:
143+
144+
```ts
145+
import { expect, vi } from 'vitest'
146+
import { originalMethod } from './path/to/module.js'
147+
148+
const { mockedMethod } = vi.hoisted(() => {
149+
return { mockedMethod: vi.fn() }
150+
})
151+
152+
vi.mocked('./path/to/module.js', () => {
153+
return { originalMethod: mockedMethod }
154+
})
155+
156+
mockedMethod.mockReturnValue(100)
157+
expect(originalMethod()).toBe(100)
158+
```
159+
160+
117161
## vi.mock
118162

119163
- **Type**: `(path: string, factory?: () => unknown) => void`
120164

121-
Substitutes all imported modules from provided `path` with another module. You can use configured Vite aliases inside a path. The call to `vi.mock` is hoisted, so it doesn't matter where you call it. It will always be executed before all imports.
165+
Substitutes all imported modules from provided `path` with another module. You can use configured Vite aliases inside a path. The call to `vi.mock` is hoisted, so it doesn't matter where you call it. It will always be executed before all imports. If you need to reference some variables outside of its scope, you can defined them inside [`vi.hoisted`](/api/vi#vi-hoisted) and reference inside `vi.mock`.
122166

123167
::: warning
124168
`vi.mock` works only for modules that were imported with the `import` keyword. It doesn't work with `require`.
@@ -151,6 +195,29 @@ import { vi } from 'vitest'
151195
This also means that you cannot use any variables inside the factory that are defined outside the factory.
152196

153197
If you need to use variables inside the factory, try [`vi.doMock`](#vi-domock). It works the same way but isn't hoisted. Beware that it only mocks subsequent imports.
198+
199+
You can also reference variables defined by `vi.hoisted` method if it was declared before `vi.mock`:
200+
201+
```ts
202+
import { namedExport } from './path/to/module.js'
203+
204+
const mocks = vi.hoisted(() => {
205+
return {
206+
namedExport: vi.fn(),
207+
}
208+
})
209+
210+
vi.mock('./path/to/module.js', () => {
211+
return {
212+
namedExport: mocks.namedExport,
213+
}
214+
})
215+
216+
vi.mocked(namedExport).mockReturnValue(100)
217+
218+
expect(namedExport()).toBe(100)
219+
expect(namedExport).toBe(mocks.namedExport)
220+
```
154221
:::
155222

156223
::: warning
@@ -199,7 +266,7 @@ import { vi } from 'vitest'
199266
```
200267

201268
::: warning
202-
Beware that if you don't call `vi.mock`, modules **are not** mocked automatically.
269+
Beware that if you don't call `vi.mock`, modules **are not** mocked automatically. To replicate Jest's automocking behaviour, you can call `vi.mock` for each required module inside [`setupFiles`](/config/#setupfiles).
203270
:::
204271

205272
If there is no `__mocks__` folder or a factory provided, Vitest will import the original module and auto-mock all its exports. For the rules applied, see [algorithm](/guide/mocking#automocking-algorithm).

‎docs/config/index.md

+15-2
Original file line numberDiff line numberDiff line change
@@ -963,7 +963,7 @@ Listen to port and serve API. When set to true, the default port is 51204
963963

964964
### browser
965965

966-
- **Type:** `{ enabled?, name?, provider?, headless?, api? }`
966+
- **Type:** `{ enabled?, name?, provider?, headless?, api?, slowHijackESM? }`
967967
- **Default:** `{ enabled: false, headless: process.env.CI, api: 63315 }`
968968
- **Version:** Since Vitest 0.29.4
969969
- **CLI:** `--browser`, `--browser=<name>`, `--browser.name=chrome --browser.headless`
@@ -1035,6 +1035,19 @@ export interface BrowserProvider {
10351035
This is an advanced API for library authors. If you just need to run tests in a browser, use the [browser](/config/#browser) option.
10361036
:::
10371037

1038+
#### browser.slowHijackESM
1039+
1040+
- **Type:** `boolean`
1041+
- **Default:** `true`
1042+
- **Version:** Since Vitest 0.31.0
1043+
1044+
When running tests in Node.js Vitest can use its own module resolution to easily mock modules with `vi.mock` syntax. However it's not so easy to replicate ES module resolution in browser, so we need to transform your source files before browser can consume it.
1045+
1046+
This option has no effect on tests running inside Node.js.
1047+
1048+
This options is enabled by default when running in the browser. If you don't rely on spying on ES modules with `vi.spyOn` and don't use `vi.mock`, you can disable this to get a slight boost to performance.
1049+
1050+
10381051
### clearMocks
10391052

10401053
- **Type:** `boolean`
@@ -1358,7 +1371,7 @@ The number of milliseconds after which a test is considered slow and reported as
13581371

13591372
- **Type:** `{ includeStack?, showDiff?, truncateThreshold? }`
13601373
- **Default:** `{ includeStack: false, showDiff: true, truncateThreshold: 40 }`
1361-
- **Version:** Vitest 0.30.0
1374+
- **Version:** Since Vitest 0.30.0
13621375

13631376
Equivalent to [Chai config](https://github.com/chaijs/chai/blob/4.x.x/lib/chai/config.js).
13641377

‎examples/mocks/test/hoisted.test.ts

+26
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { expect, test, vi } from 'vitest'
2+
import { asyncSquare as importedAsyncSquare, square as importedSquare } from '../src/example'
3+
4+
const mocks = vi.hoisted(() => {
5+
return {
6+
square: vi.fn(),
7+
}
8+
})
9+
10+
const { asyncSquare } = await vi.hoisted(async () => {
11+
return {
12+
asyncSquare: vi.fn(),
13+
}
14+
})
15+
16+
vi.mock('../src/example.ts', () => {
17+
return {
18+
square: mocks.square,
19+
asyncSquare,
20+
}
21+
})
22+
23+
test('hoisted works', () => {
24+
expect(importedSquare).toBe(mocks.square)
25+
expect(importedAsyncSquare).toBe(asyncSquare)
26+
})

‎examples/mocks/tsconfig.json

+3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"compilerOptions": {
3+
"module": "esnext",
4+
"target": "esnext",
5+
"moduleResolution": "nodenext",
36
"types": ["vitest/globals"]
47
}
58
}

‎examples/vue/test/__snapshots__/basic.test.ts.snap

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Vitest Snapshot v1
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

33
exports[`mount component 1`] = `
44
"<div>4 x 2 = 8</div>

‎packages/browser/package.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,21 @@
3939
"prepublishOnly": "pnpm build"
4040
},
4141
"peerDependencies": {
42-
"vitest": ">=0.29.4"
42+
"vitest": ">=0.31.0"
4343
},
4444
"dependencies": {
4545
"modern-node-polyfills": "^0.1.1",
4646
"sirv": "^2.0.2"
4747
},
4848
"devDependencies": {
49+
"@types/estree": "^1.0.1",
4950
"@types/ws": "^8.5.4",
5051
"@vitest/runner": "workspace:*",
5152
"@vitest/ui": "workspace:*",
5253
"@vitest/ws-client": "workspace:*",
54+
"estree-walker": "^3.0.3",
55+
"periscopic": "^3.1.0",
56+
"rollup": "3.20.2",
5357
"vitest": "workspace:*"
5458
}
5559
}

‎packages/browser/src/client/index.html

+49
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,55 @@
2424
</head>
2525
<body>
2626
<iframe id="vitest-ui" src=""></iframe>
27+
<script>
28+
const moduleCache = new Map()
29+
30+
// this method receives a module object or "import" promise that it resolves and keeps track of
31+
// and returns a hijacked module object that can be used to mock module exports
32+
function wrapModule(module) {
33+
if (module instanceof Promise) {
34+
moduleCache.set(module, { promise: module, evaluated: false })
35+
return module
36+
.then(m => m.__vi_inject__)
37+
.finally(() => moduleCache.delete(module))
38+
}
39+
return module.__vi_inject__
40+
}
41+
42+
function exportAll(exports, sourceModule) {
43+
// #1120 when a module exports itself it causes
44+
// call stack error
45+
if (exports === sourceModule)
46+
return
47+
48+
if (Object(sourceModule) !== sourceModule || Array.isArray(sourceModule))
49+
return
50+
51+
for (const key in sourceModule) {
52+
if (key !== 'default') {
53+
try {
54+
Object.defineProperty(exports, key, {
55+
enumerable: true,
56+
configurable: true,
57+
get: () => sourceModule[key],
58+
})
59+
}
60+
catch (_err) { }
61+
}
62+
}
63+
}
64+
65+
window.__vi_export_all__ = exportAll
66+
67+
// TODO: allow easier rewriting of import.meta.env
68+
window.__vi_import_meta__ = {
69+
env: {},
70+
url: location.href,
71+
}
72+
73+
window.__vi_module_cache__ = moduleCache
74+
window.__vi_wrap_module__ = wrapModule
75+
</script>
2776
<script type="module" src="/main.ts"></script>
2877
</body>
2978
</html>

‎packages/browser/src/client/main.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { setupConsoleLogSpy } from './logger'
88
import { createSafeRpc, rpc, rpcDone } from './rpc'
99
import { setupDialogsSpy } from './dialog'
1010
import { BrowserSnapshotEnvironment } from './snapshot'
11+
import { VitestBrowserClientMocker } from './mocker'
1112

1213
// @ts-expect-error mocking some node apis
1314
globalThis.process = { env: {}, argv: [], cwd: () => '/', stdout: { write: () => {} }, nextTick: cb => cb() }
@@ -72,14 +73,17 @@ ws.addEventListener('open', async () => {
7273
globalThis.__vitest_worker__ = {
7374
config,
7475
browserHashMap,
75-
moduleCache: new Map(),
76+
// @ts-expect-error untyped global for internal use
77+
moduleCache: globalThis.__vi_module_cache__,
7678
rpc: client.rpc,
7779
safeRpc,
7880
durations: {
7981
environment: 0,
8082
prepare: 0,
8183
},
8284
}
85+
// @ts-expect-error mocking vitest apis
86+
globalThis.__vitest_mocker__ = new VitestBrowserClientMocker()
8387

8488
const paths = getQueryPaths()
8589

‎packages/browser/src/client/mocker.ts

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
function throwNotImplemented(name: string) {
2+
throw new Error(`[vitest] ${name} is not implemented in browser environment yet.`)
3+
}
4+
5+
export class VitestBrowserClientMocker {
6+
public importActual() {
7+
throwNotImplemented('importActual')
8+
}
9+
10+
public importMock() {
11+
throwNotImplemented('importMock')
12+
}
13+
14+
public queueMock() {
15+
throwNotImplemented('queueMock')
16+
}
17+
18+
public queueUnmock() {
19+
throwNotImplemented('queueUnmock')
20+
}
21+
22+
public prepare() {
23+
// TODO: prepare
24+
}
25+
}

‎packages/browser/src/client/utils.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export function importId(id: string) {
22
const name = `/@id/${id}`
3-
return import(name)
3+
// @ts-expect-error mocking vitest apis
4+
return __vi_wrap_module__(import(name))
45
}
+276
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
import MagicString from 'magic-string'
2+
import { extract_names as extractNames } from 'periscopic'
3+
import type { Expression, ImportDeclaration } from 'estree'
4+
import type { AcornNode } from 'rollup'
5+
import type { Node, Positioned } from './esmWalker'
6+
import { esmWalker, isInDestructuringAssignment, isNodeInPattern, isStaticProperty } from './esmWalker'
7+
8+
const viInjectedKey = '__vi_inject__'
9+
// const viImportMetaKey = '__vi_import_meta__' // to allow overwrite
10+
const viExportAllHelper = '__vi_export_all__'
11+
12+
const skipHijack = [
13+
'/@vite/client',
14+
'/@vite/env',
15+
/vite\/dist\/client/,
16+
]
17+
18+
interface Options {
19+
cacheDir: string
20+
}
21+
22+
// this is basically copypaste from Vite SSR
23+
// this method transforms all import and export statements into `__vi_injected__` variable
24+
// to allow spying on them. this can be disabled by setting `slowHijackESM` to `false`
25+
export function injectVitestModule(code: string, id: string, parse: (code: string, options: any) => AcornNode, options: Options) {
26+
if (skipHijack.some(skip => id.match(skip)))
27+
return
28+
29+
const s = new MagicString(code)
30+
31+
let ast: any
32+
try {
33+
ast = parse(code, {
34+
sourceType: 'module',
35+
ecmaVersion: 'latest',
36+
locations: true,
37+
})
38+
}
39+
catch (err) {
40+
console.error(`Cannot parse ${id}:\n${(err as any).message}`)
41+
return
42+
}
43+
44+
let uid = 0
45+
const idToImportMap = new Map<string, string>()
46+
const declaredConst = new Set<string>()
47+
48+
const hoistIndex = 0
49+
50+
let hasInjected = false
51+
52+
// this will tranfrom import statements into dynamic ones, if there are imports
53+
// it will keep the import as is, if we don't need to mock anything
54+
// in browser environment it will wrap the module value with "vitest_wrap_module" function
55+
// that returns a proxy to the module so that named exports can be mocked
56+
const transformImportDeclaration = (node: ImportDeclaration) => {
57+
const source = node.source.value as string
58+
59+
if (skipHijack.some(skip => source.match(skip)))
60+
return null
61+
62+
const importId = `__vi_esm_${uid++}__`
63+
const hasSpecifiers = node.specifiers.length > 0
64+
const code = hasSpecifiers
65+
? `import { ${viInjectedKey} as ${importId} } from '${source}'\n`
66+
: `import '${source}'\n`
67+
return {
68+
code,
69+
id: importId,
70+
}
71+
}
72+
73+
function defineImport(node: ImportDeclaration) {
74+
const declaration = transformImportDeclaration(node)
75+
if (!declaration)
76+
return null
77+
s.appendLeft(hoistIndex, declaration.code)
78+
return declaration.id
79+
}
80+
81+
function defineImportAll(source: string) {
82+
const importId = `__vi_esm_${uid++}__`
83+
s.appendLeft(hoistIndex, `const { ${viInjectedKey}: ${importId} } = await import(${JSON.stringify(source)});\n`)
84+
return importId
85+
}
86+
87+
function defineExport(position: number, name: string, local = name) {
88+
hasInjected = true
89+
s.appendLeft(
90+
position,
91+
`\nObject.defineProperty(${viInjectedKey}, "${name}", `
92+
+ `{ enumerable: true, configurable: true, get(){ return ${local} }});`,
93+
)
94+
}
95+
96+
// 1. check all import statements and record id -> importName map
97+
for (const node of ast.body as Node[]) {
98+
// import foo from 'foo' --> foo -> __import_foo__.default
99+
// import { baz } from 'foo' --> baz -> __import_foo__.baz
100+
// import * as ok from 'foo' --> ok -> __import_foo__
101+
if (node.type === 'ImportDeclaration') {
102+
const importId = defineImport(node)
103+
if (!importId)
104+
continue
105+
s.remove(node.start, node.end)
106+
for (const spec of node.specifiers) {
107+
if (spec.type === 'ImportSpecifier') {
108+
idToImportMap.set(
109+
spec.local.name,
110+
`${importId}.${spec.imported.name}`,
111+
)
112+
}
113+
else if (spec.type === 'ImportDefaultSpecifier') {
114+
idToImportMap.set(spec.local.name, `${importId}.default`)
115+
}
116+
else {
117+
// namespace specifier
118+
idToImportMap.set(spec.local.name, importId)
119+
}
120+
}
121+
}
122+
}
123+
124+
// 2. check all export statements and define exports
125+
for (const node of ast.body as Node[]) {
126+
// named exports
127+
if (node.type === 'ExportNamedDeclaration') {
128+
if (node.declaration) {
129+
if (
130+
node.declaration.type === 'FunctionDeclaration'
131+
|| node.declaration.type === 'ClassDeclaration'
132+
) {
133+
// export function foo() {}
134+
defineExport(node.end, node.declaration.id!.name)
135+
}
136+
else {
137+
// export const foo = 1, bar = 2
138+
for (const declaration of node.declaration.declarations) {
139+
const names = extractNames(declaration.id as any)
140+
for (const name of names)
141+
defineExport(node.end, name)
142+
}
143+
}
144+
s.remove(node.start, (node.declaration as Node).start)
145+
}
146+
else {
147+
s.remove(node.start, node.end)
148+
if (node.source) {
149+
// export { foo, bar } from './foo'
150+
const importId = defineImportAll(node.source.value as string)
151+
// hoist re-exports near the defined import so they are immediately exported
152+
for (const spec of node.specifiers) {
153+
defineExport(
154+
hoistIndex,
155+
spec.exported.name,
156+
`${importId}.${spec.local.name}`,
157+
)
158+
}
159+
}
160+
else {
161+
// export { foo, bar }
162+
for (const spec of node.specifiers) {
163+
const local = spec.local.name
164+
const binding = idToImportMap.get(local)
165+
defineExport(node.end, spec.exported.name, binding || local)
166+
}
167+
}
168+
}
169+
}
170+
171+
// default export
172+
if (node.type === 'ExportDefaultDeclaration') {
173+
const expressionTypes = ['FunctionExpression', 'ClassExpression']
174+
if (
175+
'id' in node.declaration
176+
&& node.declaration.id
177+
&& !expressionTypes.includes(node.declaration.type)
178+
) {
179+
// named hoistable/class exports
180+
// export default function foo() {}
181+
// export default class A {}
182+
hasInjected = true
183+
const { name } = node.declaration.id
184+
s.remove(node.start, node.start + 15 /* 'export default '.length */)
185+
s.append(
186+
`\nObject.defineProperty(${viInjectedKey}, "default", `
187+
+ `{ enumerable: true, configurable: true, value: ${name} });`,
188+
)
189+
}
190+
else {
191+
// anonymous default exports
192+
hasInjected = true
193+
s.update(
194+
node.start,
195+
node.start + 14 /* 'export default'.length */,
196+
`${viInjectedKey}.default =`,
197+
)
198+
if (id.startsWith(options.cacheDir)) {
199+
// keep export default for optimized dependencies
200+
s.append(`\nexport default { ${viInjectedKey}: ${viInjectedKey}.default };\n`)
201+
}
202+
}
203+
}
204+
205+
// export * from './foo'
206+
if (node.type === 'ExportAllDeclaration') {
207+
s.remove(node.start, node.end)
208+
const importId = defineImportAll(node.source.value as string)
209+
// hoist re-exports near the defined import so they are immediately exported
210+
if (node.exported) {
211+
defineExport(hoistIndex, node.exported.name, `${importId}`)
212+
}
213+
else {
214+
hasInjected = true
215+
s.appendLeft(hoistIndex, `${viExportAllHelper}(${viInjectedKey}, ${importId});\n`)
216+
}
217+
}
218+
}
219+
220+
// 3. convert references to import bindings & import.meta references
221+
esmWalker(ast, {
222+
onIdentifier(id, parent, parentStack) {
223+
const grandparent = parentStack[1]
224+
const binding = idToImportMap.get(id.name)
225+
if (!binding)
226+
return
227+
228+
if (isStaticProperty(parent) && parent.shorthand) {
229+
// let binding used in a property shorthand
230+
// { foo } -> { foo: __import_x__.foo }
231+
// skip for destructuring patterns
232+
if (
233+
!isNodeInPattern(parent)
234+
|| isInDestructuringAssignment(parent, parentStack)
235+
)
236+
s.appendLeft(id.end, `: ${binding}`)
237+
}
238+
else if (
239+
(parent.type === 'PropertyDefinition'
240+
&& grandparent?.type === 'ClassBody')
241+
|| (parent.type === 'ClassDeclaration' && id === parent.superClass)
242+
) {
243+
if (!declaredConst.has(id.name)) {
244+
declaredConst.add(id.name)
245+
// locate the top-most node containing the class declaration
246+
const topNode = parentStack[parentStack.length - 2]
247+
s.prependRight(topNode.start, `const ${id.name} = ${binding};\n`)
248+
}
249+
}
250+
else {
251+
s.update(id.start, id.end, binding)
252+
}
253+
},
254+
// TODO: make env updatable
255+
onImportMeta() {
256+
// s.update(node.start, node.end, viImportMetaKey)
257+
},
258+
onDynamicImport(node) {
259+
const replace = '__vi_wrap_module__(import('
260+
s.overwrite(node.start, (node.source as Positioned<Expression>).start, replace)
261+
s.overwrite(node.end - 1, node.end, '))')
262+
},
263+
})
264+
265+
if (hasInjected) {
266+
// make sure "__vi_injected__" is declared as soon as possible
267+
s.prepend(`const ${viInjectedKey} = { [Symbol.toStringTag]: "Module" };\n`)
268+
s.append(`\nexport { ${viInjectedKey} }`)
269+
}
270+
271+
return {
272+
ast,
273+
code: s.toString(),
274+
map: s.generateMap({ hires: true, source: id }),
275+
}
276+
}
+301
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import type {
2+
Function as FunctionNode,
3+
Identifier,
4+
ImportExpression,
5+
Pattern,
6+
Property,
7+
VariableDeclaration,
8+
Node as _Node,
9+
} from 'estree'
10+
import { walk as eswalk } from 'estree-walker'
11+
12+
export type Positioned<T> = T & {
13+
start: number
14+
end: number
15+
}
16+
17+
export type Node = Positioned<_Node>
18+
19+
interface Visitors {
20+
onIdentifier: (
21+
node: Positioned<Identifier>,
22+
parent: Node,
23+
parentStack: Node[],
24+
) => void
25+
onImportMeta: (node: Node) => void
26+
onDynamicImport: (node: Positioned<ImportExpression>) => void
27+
}
28+
29+
const isNodeInPatternWeakSet = new WeakSet<_Node>()
30+
export function setIsNodeInPattern(node: Property) {
31+
return isNodeInPatternWeakSet.add(node)
32+
}
33+
export function isNodeInPattern(node: _Node): node is Property {
34+
return isNodeInPatternWeakSet.has(node)
35+
}
36+
37+
/**
38+
* Same logic from \@vue/compiler-core & \@vue/compiler-sfc
39+
* Except this is using acorn AST
40+
*/
41+
export function esmWalker(
42+
root: Node,
43+
{ onIdentifier, onImportMeta, onDynamicImport }: Visitors,
44+
) {
45+
const parentStack: Node[] = []
46+
const varKindStack: VariableDeclaration['kind'][] = []
47+
const scopeMap = new WeakMap<_Node, Set<string>>()
48+
const identifiers: [id: any, stack: Node[]][] = []
49+
50+
const setScope = (node: _Node, name: string) => {
51+
let scopeIds = scopeMap.get(node)
52+
if (scopeIds && scopeIds.has(name))
53+
return
54+
55+
if (!scopeIds) {
56+
scopeIds = new Set()
57+
scopeMap.set(node, scopeIds)
58+
}
59+
scopeIds.add(name)
60+
}
61+
62+
function isInScope(name: string, parents: Node[]) {
63+
return parents.some(node => node && scopeMap.get(node)?.has(name))
64+
}
65+
function handlePattern(p: Pattern, parentScope: _Node) {
66+
if (p.type === 'Identifier') {
67+
setScope(parentScope, p.name)
68+
}
69+
else if (p.type === 'RestElement') {
70+
handlePattern(p.argument, parentScope)
71+
}
72+
else if (p.type === 'ObjectPattern') {
73+
p.properties.forEach((property) => {
74+
if (property.type === 'RestElement')
75+
setScope(parentScope, (property.argument as Identifier).name)
76+
77+
else
78+
handlePattern(property.value, parentScope)
79+
})
80+
}
81+
else if (p.type === 'ArrayPattern') {
82+
p.elements.forEach((element) => {
83+
if (element)
84+
handlePattern(element, parentScope)
85+
})
86+
}
87+
else if (p.type === 'AssignmentPattern') {
88+
handlePattern(p.left, parentScope)
89+
}
90+
else {
91+
setScope(parentScope, (p as any).name)
92+
}
93+
}
94+
95+
(eswalk as any)(root, {
96+
enter(node: Node, parent: Node | null) {
97+
if (node.type === 'ImportDeclaration')
98+
return this.skip()
99+
100+
// track parent stack, skip for "else-if"/"else" branches as acorn nests
101+
// the ast within "if" nodes instead of flattening them
102+
if (
103+
parent
104+
&& !(parent.type === 'IfStatement' && node === parent.alternate)
105+
)
106+
parentStack.unshift(parent)
107+
108+
// track variable declaration kind stack used by VariableDeclarator
109+
if (node.type === 'VariableDeclaration')
110+
varKindStack.unshift(node.kind)
111+
112+
if (node.type === 'MetaProperty' && node.meta.name === 'import')
113+
onImportMeta(node)
114+
115+
else if (node.type === 'ImportExpression')
116+
onDynamicImport(node)
117+
118+
if (node.type === 'Identifier') {
119+
if (
120+
!isInScope(node.name, parentStack)
121+
&& isRefIdentifier(node, parent!, parentStack)
122+
) {
123+
// record the identifier, for DFS -> BFS
124+
identifiers.push([node, parentStack.slice(0)])
125+
}
126+
}
127+
else if (isFunctionNode(node)) {
128+
// If it is a function declaration, it could be shadowing an import
129+
// Add its name to the scope so it won't get replaced
130+
if (node.type === 'FunctionDeclaration') {
131+
const parentScope = findParentScope(parentStack)
132+
if (parentScope)
133+
setScope(parentScope, node.id!.name)
134+
}
135+
// walk function expressions and add its arguments to known identifiers
136+
// so that we don't prefix them
137+
node.params.forEach((p) => {
138+
if (p.type === 'ObjectPattern' || p.type === 'ArrayPattern') {
139+
handlePattern(p, node)
140+
return
141+
}
142+
(eswalk as any)(p.type === 'AssignmentPattern' ? p.left : p, {
143+
enter(child: Node, parent: Node) {
144+
// skip params default value of destructure
145+
if (
146+
parent?.type === 'AssignmentPattern'
147+
&& parent?.right === child
148+
)
149+
return this.skip()
150+
151+
if (child.type !== 'Identifier')
152+
return
153+
// do not record as scope variable if is a destructuring keyword
154+
if (isStaticPropertyKey(child, parent))
155+
return
156+
// do not record if this is a default value
157+
// assignment of a destructuring variable
158+
if (
159+
(parent?.type === 'TemplateLiteral'
160+
&& parent?.expressions.includes(child))
161+
|| (parent?.type === 'CallExpression' && parent?.callee === child)
162+
)
163+
return
164+
165+
setScope(node, child.name)
166+
},
167+
})
168+
})
169+
}
170+
else if (node.type === 'Property' && parent!.type === 'ObjectPattern') {
171+
// mark property in destructuring pattern
172+
setIsNodeInPattern(node)
173+
}
174+
else if (node.type === 'VariableDeclarator') {
175+
const parentFunction = findParentScope(
176+
parentStack,
177+
varKindStack[0] === 'var',
178+
)
179+
if (parentFunction)
180+
handlePattern(node.id, parentFunction)
181+
}
182+
else if (node.type === 'CatchClause' && node.param) {
183+
handlePattern(node.param, node)
184+
}
185+
},
186+
187+
leave(node: Node, parent: Node | null) {
188+
// untrack parent stack from above
189+
if (
190+
parent
191+
&& !(parent.type === 'IfStatement' && node === parent.alternate)
192+
)
193+
parentStack.shift()
194+
195+
if (node.type === 'VariableDeclaration')
196+
varKindStack.shift()
197+
},
198+
})
199+
200+
// emit the identifier events in BFS so the hoisted declarations
201+
// can be captured correctly
202+
identifiers.forEach(([node, stack]) => {
203+
if (!isInScope(node.name, stack))
204+
onIdentifier(node, stack[0], stack)
205+
})
206+
}
207+
208+
function isRefIdentifier(id: Identifier, parent: _Node, parentStack: _Node[]) {
209+
// declaration id
210+
if (
211+
parent.type === 'CatchClause'
212+
|| ((parent.type === 'VariableDeclarator'
213+
|| parent.type === 'ClassDeclaration')
214+
&& parent.id === id)
215+
)
216+
return false
217+
218+
if (isFunctionNode(parent)) {
219+
// function declaration/expression id
220+
if ((parent as any).id === id)
221+
return false
222+
223+
// params list
224+
if (parent.params.includes(id))
225+
return false
226+
}
227+
228+
// class method name
229+
if (parent.type === 'MethodDefinition' && !parent.computed)
230+
return false
231+
232+
// property key
233+
if (isStaticPropertyKey(id, parent))
234+
return false
235+
236+
// object destructuring pattern
237+
if (isNodeInPattern(parent) && parent.value === id)
238+
return false
239+
240+
// non-assignment array destructuring pattern
241+
if (
242+
parent.type === 'ArrayPattern'
243+
&& !isInDestructuringAssignment(parent, parentStack)
244+
)
245+
return false
246+
247+
// member expression property
248+
if (
249+
parent.type === 'MemberExpression'
250+
&& parent.property === id
251+
&& !parent.computed
252+
)
253+
return false
254+
255+
if (parent.type === 'ExportSpecifier')
256+
return false
257+
258+
// is a special keyword but parsed as identifier
259+
if (id.name === 'arguments')
260+
return false
261+
262+
return true
263+
}
264+
265+
export function isStaticProperty(node: _Node): node is Property {
266+
return node && node.type === 'Property' && !node.computed
267+
}
268+
269+
export function isStaticPropertyKey(node: _Node, parent: _Node) {
270+
return isStaticProperty(parent) && parent.key === node
271+
}
272+
273+
const functionNodeTypeRE = /Function(?:Expression|Declaration)$|Method$/
274+
export function isFunctionNode(node: _Node): node is FunctionNode {
275+
return functionNodeTypeRE.test(node.type)
276+
}
277+
278+
const blockNodeTypeRE = /^BlockStatement$|^For(?:In|Of)?Statement$/
279+
function isBlock(node: _Node) {
280+
return blockNodeTypeRE.test(node.type)
281+
}
282+
283+
function findParentScope(
284+
parentStack: _Node[],
285+
isVar = false,
286+
): _Node | undefined {
287+
return parentStack.find(isVar ? isFunctionNode : isBlock)
288+
}
289+
290+
export function isInDestructuringAssignment(
291+
parent: _Node,
292+
parentStack: _Node[],
293+
): boolean {
294+
if (
295+
parent
296+
&& (parent.type === 'Property' || parent.type === 'ArrayPattern')
297+
)
298+
return parentStack.some(i => i.type === 'AssignmentExpression')
299+
300+
return false
301+
}

‎packages/browser/src/node/index.ts

+15-1
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,14 @@ import { builtinModules } from 'node:module'
55
import { polyfillPath } from 'modern-node-polyfills'
66
import sirv from 'sirv'
77
import type { Plugin } from 'vite'
8+
import { injectVitestModule } from './esmInjector'
89

910
const polyfills = [
1011
'util',
1112
]
1213

13-
export default (base = '/'): Plugin[] => {
14+
// don't expose type to not bundle it here
15+
export default (project: any, base = '/'): Plugin[] => {
1416
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
1517
const distRoot = resolve(pkgRoot, 'dist')
1618

@@ -51,6 +53,18 @@ export default (base = '/'): Plugin[] => {
5153
return { id: await polyfillPath(id), moduleSideEffects: false }
5254
},
5355
},
56+
{
57+
name: 'vitest:browser:esm-injector',
58+
enforce: 'post',
59+
transform(source, id) {
60+
const hijackESM = project.config.browser.slowHijackESM ?? false
61+
if (!hijackESM)
62+
return
63+
return injectVitestModule(source, id, this.parse, {
64+
cacheDir: project.server.config.cacheDir,
65+
})
66+
},
67+
},
5468
]
5569
}
5670

‎packages/utils/src/descriptors.ts

+7-3
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1-
import concordance from 'concordance'
1+
import * as concordance from 'concordance'
22
import { getColors } from './colors'
33

4+
const concordanceModule = 'default' in concordance
5+
? concordance.default
6+
: concordance as any
7+
48
interface DisplayOptions {
59
theme?: any
610
maxDepth?: number
@@ -89,6 +93,6 @@ export function getConcordanceTheme() {
8993
}
9094
}
9195

92-
export function diffDescriptors(actual: unknown, expected: unknown, options: DisplayOptions) {
93-
return concordance.diff(expected, actual, options)
96+
export function diffDescriptors(actual: unknown, expected: unknown, options: DisplayOptions): string {
97+
return concordanceModule.diff(expected, actual, options)
9498
}

‎packages/vitest/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@
167167
"@jridgewell/trace-mapping": "^0.3.17",
168168
"@sinonjs/fake-timers": "^10.0.2",
169169
"@types/diff": "^5.0.3",
170+
"@types/estree": "^1.0.1",
170171
"@types/istanbul-lib-coverage": "^2.0.4",
171172
"@types/istanbul-reports": "^3.0.1",
172173
"@types/jsdom": "^21.1.1",

‎packages/vitest/src/integrations/browser/server.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ensurePackageInstalled } from '../../node/pkg'
77
import { resolveApiServerConfig } from '../../node/config'
88
import { CoverageTransform } from '../../node/plugins/coverageTransform'
99
import type { WorkspaceProject } from '../../node/workspace'
10+
import { MocksPlugin } from '../../node/plugins/mocks'
1011

1112
export async function createBrowserServer(project: WorkspaceProject, options: UserConfig) {
1213
const root = project.config.root
@@ -31,7 +32,7 @@ export async function createBrowserServer(project: WorkspaceProject, options: Us
3132
},
3233
},
3334
plugins: [
34-
(await import('@vitest/browser')).default('/'),
35+
(await import('@vitest/browser')).default(project, '/'),
3536
CoverageTransform(project.ctx),
3637
{
3738
enforce: 'post',
@@ -42,7 +43,8 @@ export async function createBrowserServer(project: WorkspaceProject, options: Us
4243
}
4344

4445
config.server = server
45-
config.server.fs = { strict: false }
46+
config.server.fs ??= {}
47+
config.server.fs.strict = false
4648

4749
return {
4850
resolve: {
@@ -51,6 +53,7 @@ export async function createBrowserServer(project: WorkspaceProject, options: Us
5153
}
5254
},
5355
},
56+
MocksPlugin(),
5457
],
5558
})
5659

‎packages/vitest/src/integrations/vi.ts

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers'
2-
import { createSimpleStackTrace } from '@vitest/utils'
2+
import { assertTypes, createSimpleStackTrace } from '@vitest/utils'
33
import { parseSingleStack } from '../utils/source-map'
44
import type { VitestMocker } from '../runtime/mocker'
55
import type { ResolvedConfig, RuntimeConfig } from '../types'
@@ -30,6 +30,12 @@ interface VitestUtils {
3030
spyOn: typeof spyOn
3131
fn: typeof fn
3232

33+
/**
34+
* Run the factory before imports are evaluated. You can return a value from the factory
35+
* to reuse it inside your `vi.mock` factory and tests.
36+
*/
37+
hoisted<T>(factory: () => T): T
38+
3339
/**
3440
* Makes all `imports` to passed module to be mocked.
3541
* - If there is a factory, will return it's result. The call to `vi.mock` is hoisted to the top of the file,
@@ -286,6 +292,11 @@ function createVitest(): VitestUtils {
286292
spyOn,
287293
fn,
288294

295+
hoisted<T>(factory: () => T): T {
296+
assertTypes(factory, '"vi.hoisted" factory', ['function'])
297+
return factory()
298+
},
299+
289300
mock(path: string, factory?: MockFactoryWithHelper) {
290301
const importer = getImporter()
291302
_mocker.queueMock(

‎packages/vitest/src/node/config.ts

+1
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,7 @@ export function resolveConfig(
272272
resolved.browser ??= {} as any
273273
resolved.browser.enabled ??= false
274274
resolved.browser.headless ??= isCI
275+
resolved.browser.slowHijackESM ??= true
275276

276277
resolved.browser.api = resolveApiServerConfig(resolved.browser) || {
277278
port: defaultBrowserPort,

‎packages/vitest/src/node/core.ts

+3-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { normalizeRequestId } from 'vite-node/utils'
99
import { ViteNodeRunner } from 'vite-node/client'
1010
import { SnapshotManager } from '@vitest/snapshot/manager'
1111
import type { CancelReason } from '@vitest/runner'
12+
import { ViteNodeServer } from 'vite-node/server'
1213
import type { ArgumentsType, CoverageProvider, OnServerRestartHandler, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, VitestRunMode } from '../types'
1314
import { hasFailed, noop, slash, toArray } from '../utils'
1415
import { getCoverageProvider } from '../integrations/coverage'
@@ -22,7 +23,6 @@ import { resolveConfig } from './config'
2223
import { Logger } from './logger'
2324
import { VitestCache } from './cache'
2425
import { WorkspaceProject, initializeProject } from './workspace'
25-
import { VitestServer } from './server'
2626

2727
const WATCHER_DEBOUNCE = 100
2828

@@ -40,7 +40,7 @@ export class Vitest {
4040
logger: Logger
4141
pool: ProcessPool | undefined
4242

43-
vitenode: VitestServer = undefined!
43+
vitenode: ViteNodeServer = undefined!
4444

4545
invalidates: Set<string> = new Set()
4646
changedTests: Set<string> = new Set()
@@ -89,7 +89,7 @@ export class Vitest {
8989
if (this.config.watch && this.mode !== 'typecheck')
9090
this.registerWatcher()
9191

92-
this.vitenode = new VitestServer(server, this.config)
92+
this.vitenode = new ViteNodeServer(server, this.config)
9393
const node = this.vitenode
9494
this.runner = new ViteNodeRunner({
9595
root: server.config.root,
+184
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import MagicString from 'magic-string'
2+
import type { CallExpression, Identifier, ImportDeclaration, VariableDeclaration, Node as _Node } from 'estree'
3+
import { findNodeAround, simple as simpleWalk } from 'acorn-walk'
4+
import type { AcornNode } from 'rollup'
5+
6+
export type Positioned<T> = T & {
7+
start: number
8+
end: number
9+
}
10+
11+
export type Node = Positioned<_Node>
12+
13+
const API_NOT_FOUND_ERROR = `There are some problems in resolving the mocks API.
14+
You may encounter this issue when importing the mocks API from another module other than 'vitest'.
15+
To fix this issue you can either:
16+
- import the mocks API directly from 'vitest'
17+
- enable the 'globals' options`
18+
19+
const API_NOT_FOUND_CHECK = '\nif (typeof globalThis.vi === "undefined" && typeof globalThis.vitest === "undefined") '
20+
+ `{ throw new Error(${JSON.stringify(API_NOT_FOUND_ERROR)}) }\n`
21+
22+
function isIdentifier(node: any): node is Positioned<Identifier> {
23+
return node.type === 'Identifier'
24+
}
25+
26+
function transformImportSpecifiers(node: ImportDeclaration) {
27+
const specifiers = node.specifiers
28+
29+
if (specifiers.length === 1 && specifiers[0].type === 'ImportNamespaceSpecifier')
30+
return specifiers[0].local.name
31+
32+
const dynamicImports = node.specifiers.map((specifier) => {
33+
if (specifier.type === 'ImportDefaultSpecifier')
34+
return `default: ${specifier.local.name}`
35+
36+
if (specifier.type === 'ImportSpecifier') {
37+
const local = specifier.local.name
38+
const imported = specifier.imported.name
39+
if (local === imported)
40+
return local
41+
return `${imported}: ${local}`
42+
}
43+
44+
return null
45+
}).filter(Boolean).join(', ')
46+
47+
if (!dynamicImports.length)
48+
return ''
49+
50+
return `{ ${dynamicImports} }`
51+
}
52+
53+
const regexpHoistable = /^[ \t]*\b(vi|vitest)\s*\.\s*(mock|unmock|hoisted)\(/m
54+
const hashbangRE = /^#!.*\n/
55+
56+
export function hoistMocks(code: string, id: string, parse: (code: string, options: any) => AcornNode) {
57+
const hasMocks = regexpHoistable.test(code)
58+
59+
if (!hasMocks)
60+
return
61+
62+
const s = new MagicString(code)
63+
64+
let ast: any
65+
try {
66+
ast = parse(code, {
67+
sourceType: 'module',
68+
ecmaVersion: 'latest',
69+
locations: true,
70+
})
71+
}
72+
catch (err) {
73+
console.error(`Cannot parse ${id}:\n${(err as any).message}`)
74+
return
75+
}
76+
77+
const hoistIndex = code.match(hashbangRE)?.[0].length ?? 0
78+
79+
let hoistedCode = ''
80+
let hoistedVitestImports = ''
81+
82+
// this will tranfrom import statements into dynamic ones, if there are imports
83+
// it will keep the import as is, if we don't need to mock anything
84+
// in browser environment it will wrap the module value with "vitest_wrap_module" function
85+
// that returns a proxy to the module so that named exports can be mocked
86+
const transformImportDeclaration = (node: ImportDeclaration) => {
87+
const source = node.source.value as string
88+
89+
// if we don't hijack ESM and process this file, then we definetly have mocks,
90+
// so we need to transform imports into dynamic ones, so "vi.mock" can be executed before
91+
const specifiers = transformImportSpecifiers(node)
92+
const code = specifiers
93+
? `const ${specifiers} = await import('${source}')\n`
94+
: `await import('${source}')\n`
95+
return code
96+
}
97+
98+
function hoistImport(node: Positioned<ImportDeclaration>) {
99+
// always hoist vitest import to top of the file, so
100+
// "vi" helpers can access it
101+
s.remove(node.start, node.end)
102+
103+
if (node.source.value === 'vitest') {
104+
const code = `const ${transformImportSpecifiers(node)} = await import('vitest')\n`
105+
hoistedVitestImports += code
106+
return
107+
}
108+
const code = transformImportDeclaration(node)
109+
s.appendLeft(hoistIndex, code)
110+
}
111+
112+
// 1. check all import statements and record id -> importName map
113+
for (const node of ast.body as Node[]) {
114+
// import foo from 'foo' --> foo -> __import_foo__.default
115+
// import { baz } from 'foo' --> baz -> __import_foo__.baz
116+
// import * as ok from 'foo' --> ok -> __import_foo__
117+
if (node.type === 'ImportDeclaration')
118+
hoistImport(node)
119+
}
120+
121+
simpleWalk(ast, {
122+
CallExpression(_node) {
123+
const node = _node as any as Positioned<CallExpression>
124+
if (
125+
node.callee.type === 'MemberExpression'
126+
&& isIdentifier(node.callee.object)
127+
&& (node.callee.object.name === 'vi' || node.callee.object.name === 'vitest')
128+
&& isIdentifier(node.callee.property)
129+
) {
130+
const methodName = node.callee.property.name
131+
132+
if (methodName === 'mock' || methodName === 'unmock') {
133+
hoistedCode += `${code.slice(node.start, node.end)}\n`
134+
s.remove(node.start, node.end)
135+
}
136+
137+
if (methodName === 'hoisted') {
138+
const declarationNode = findNodeAround(ast, node.start, 'VariableDeclaration')?.node as Positioned<VariableDeclaration> | undefined
139+
const init = declarationNode?.declarations[0]?.init
140+
const isViHoisted = (node: CallExpression) => {
141+
return node.callee.type === 'MemberExpression'
142+
&& isIdentifier(node.callee.object)
143+
&& (node.callee.object.name === 'vi' || node.callee.object.name === 'vitest')
144+
&& isIdentifier(node.callee.property)
145+
&& node.callee.property.name === 'hoisted'
146+
}
147+
148+
const canMoveDeclaration = (init
149+
&& init.type === 'CallExpression'
150+
&& isViHoisted(init)) /* const v = vi.hoisted() */
151+
|| (init
152+
&& init.type === 'AwaitExpression'
153+
&& init.argument.type === 'CallExpression'
154+
&& isViHoisted(init.argument)) /* const v = await vi.hoisted() */
155+
156+
if (canMoveDeclaration) {
157+
// hoist "const variable = vi.hoisted(() => {})"
158+
hoistedCode += `${code.slice(declarationNode.start, declarationNode.end)}\n`
159+
s.remove(declarationNode.start, declarationNode.end)
160+
}
161+
else {
162+
// hoist "vi.hoisted(() => {})"
163+
hoistedCode += `${code.slice(node.start, node.end)}\n`
164+
s.remove(node.start, node.end)
165+
}
166+
}
167+
}
168+
},
169+
})
170+
171+
if (hoistedCode || hoistedVitestImports) {
172+
s.prepend(
173+
hoistedVitestImports
174+
+ ((!hoistedVitestImports && hoistedCode) ? API_NOT_FOUND_CHECK : '')
175+
+ hoistedCode,
176+
)
177+
}
178+
179+
return {
180+
ast,
181+
code: s.toString(),
182+
map: s.generateMap({ hires: true, source: id }),
183+
}
184+
}

‎packages/vitest/src/node/mock.ts

-235
This file was deleted.

‎packages/vitest/src/node/plugins/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { EnvReplacerPlugin } from './envReplacer'
1111
import { GlobalSetupPlugin } from './globalSetup'
1212
import { CSSEnablerPlugin } from './cssEnabler'
1313
import { CoverageTransform } from './coverageTransform'
14+
import { MocksPlugin } from './mocks'
1415

1516
export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('test')): Promise<VitePlugin[]> {
1617
const userConfig = deepMerge({}, options) as UserConfig
@@ -242,6 +243,7 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t
242243
options.ui
243244
? await UIPlugin()
244245
: null,
246+
MocksPlugin(),
245247
]
246248
.filter(notNullish)
247249
}
+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type { Plugin } from 'vite'
2+
import { hoistMocks } from '../hoistMocks'
3+
4+
export function MocksPlugin(): Plugin {
5+
return {
6+
name: 'vite:mocks',
7+
enforce: 'post',
8+
transform(code, id) {
9+
return hoistMocks(code, id, this.parse)
10+
},
11+
}
12+
}

‎packages/vitest/src/node/plugins/workspace.ts

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { CoverageTransform } from './coverageTransform'
99
import { CSSEnablerPlugin } from './cssEnabler'
1010
import { EnvReplacerPlugin } from './envReplacer'
1111
import { GlobalSetupPlugin } from './globalSetup'
12+
import { MocksPlugin } from './mocks'
1213

1314
interface WorkspaceOptions extends UserWorkspaceConfig {
1415
root?: string
@@ -138,5 +139,6 @@ export function WorkspaceVitestPlugin(project: WorkspaceProject, options: Worksp
138139
...CSSEnablerPlugin(project),
139140
CoverageTransform(project.ctx),
140141
GlobalSetupPlugin(project, project.ctx.logger),
142+
MocksPlugin(),
141143
]
142144
}

‎packages/vitest/src/node/pool.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,10 @@ export function createPool(ctx: Vitest): ProcessPool {
4242

4343
function getPoolName([project, file]: WorkspaceSpec) {
4444
for (const [glob, pool] of project.config.poolMatchGlobs || []) {
45+
if (pool === 'browser')
46+
throw new Error('Since Vitest 0.31.0 "browser" pool is not supported in "poolMatchGlobs". You can create a workspace to run some of your tests in browser in parallel. Read more: https://vitest.dev/guide/workspace')
4547
if (mm.isMatch(file, glob, { cwd: project.config.root }))
46-
return pool
48+
return pool as VitestPool
4749
}
4850
return getDefaultPoolName(project)
4951
}

‎packages/vitest/src/node/server.ts

-20
This file was deleted.

‎packages/vitest/src/node/workspace.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { dirname, relative, resolve, toNamespacedPath } from 'pathe'
55
import { createServer } from 'vite'
66
import type { ViteDevServer, InlineConfig as ViteInlineConfig } from 'vite'
77
import { ViteNodeRunner } from 'vite-node/client'
8+
import { ViteNodeServer } from 'vite-node/server'
89
import { createBrowserServer } from '../integrations/browser/server'
910
import type { ArgumentsType, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, Vitest } from '../types'
1011
import { deepMerge, hasFailed } from '../utils'
@@ -13,10 +14,9 @@ import type { BrowserProvider } from '../types/browser'
1314
import { getBrowserProvider } from '../integrations/browser'
1415
import { isBrowserEnabled, resolveConfig } from './config'
1516
import { WorkspaceVitestPlugin } from './plugins/workspace'
16-
import { VitestServer } from './server'
1717

1818
interface InitializeServerOptions {
19-
server?: VitestServer
19+
server?: ViteNodeServer
2020
runner?: ViteNodeRunner
2121
}
2222

@@ -65,7 +65,7 @@ export class WorkspaceProject {
6565

6666
config!: ResolvedConfig
6767
server!: ViteDevServer
68-
vitenode!: VitestServer
68+
vitenode!: ViteNodeServer
6969
runner!: ViteNodeRunner
7070
browser: ViteDevServer = undefined!
7171
typechecker?: Typechecker
@@ -170,7 +170,7 @@ export class WorkspaceProject {
170170
this.config = resolveConfig(this.ctx.mode, options, server.config)
171171
this.server = server
172172

173-
this.vitenode = params.server ?? new VitestServer(server, this.config)
173+
this.vitenode = params.server ?? new ViteNodeServer(server, this.config)
174174
const node = this.vitenode
175175
this.runner = params.runner ?? new ViteNodeRunner({
176176
root: server.config.root,

‎packages/vitest/src/runtime/entry.ts

-2
Original file line numberDiff line numberDiff line change
@@ -124,8 +124,6 @@ export async function run(files: string[], config: ResolvedConfig, environment:
124124

125125
await startTests([file], runner)
126126

127-
workerState.filepath = undefined
128-
129127
// reset after tests, because user might call `vi.setConfig` in setupFile
130128
vi.resetConfig()
131129
// mocks should not affect different files

‎packages/vitest/src/types/browser.ts

+9
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ export interface BrowserConfigOptions {
5151
* The default port is 63315.
5252
*/
5353
api?: ApiConfig | number
54+
55+
/**
56+
* Update ESM imports so they can be spied/stubbed with vi.spyOn.
57+
* Enabled by default when running in browser.
58+
*
59+
* @default true
60+
* @experimental
61+
*/
62+
slowHijackESM?: boolean
5463
}
5564

5665
export interface ResolvedBrowserOptions extends BrowserConfigOptions {

‎packages/vitest/src/types/config.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ export interface InlineConfig {
188188

189189
/**
190190
* Automatically assign environment based on globs. The first match will be used.
191+
* This has effect only when running tests inside Node.js.
191192
*
192193
* Format: [glob, environment-name]
193194
*
@@ -209,13 +210,13 @@ export interface InlineConfig {
209210
*
210211
* @default []
211212
* @example [
212-
* // all tests in "browser" directory will run in an actual browser
213-
* ['tests/browser/**', 'browser'],
213+
* // all tests in "child_process" directory will run using "child_process" API
214+
* ['tests/child_process/**', 'child_process'],
214215
* // all other tests will run based on "threads" option, if you didn't specify other globs
215216
* // ...
216217
* ]
217218
*/
218-
poolMatchGlobs?: [string, VitestPool][]
219+
poolMatchGlobs?: [string, Omit<VitestPool, 'browser'>][]
219220

220221
/**
221222
* Update snapshot

‎pnpm-lock.yaml

+352-402
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/browser/specs/runner.test.mjs

+1-1
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ await test('tests are actually running', async () => {
1717
const browserResult = await readFile('./browser.json', 'utf-8')
1818
const browserResultJson = JSON.parse(browserResult)
1919

20-
assert.ok(browserResultJson.testResults.length === 6, 'Not all the tests have been run')
20+
assert.ok(browserResultJson.testResults.length === 7, 'Not all the tests have been run')
2121

2222
for (const result of browserResultJson.testResults)
2323
assert.ok(result.status === 'passed', `${result.name} has failed`)

‎test/browser/src/actions.ts

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function plus(a: number, b: number) {
2+
return a + b
3+
}

‎test/browser/src/calculator.ts

+8
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { plus } from './actions'
2+
3+
export function calculator(operation: 'plus', a: number, b: number) {
4+
if (operation === 'plus')
5+
return plus(a, b)
6+
7+
throw new Error('unknown operation')
8+
}

‎test/browser/test/mocked.test.ts

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { expect, test, vi } from 'vitest'
2+
import * as actions from '../src/actions'
3+
import { calculator } from '../src/calculator'
4+
import * as calculatorModule from '../src/calculator'
5+
6+
test('spyOn works on ESM', () => {
7+
vi.spyOn(actions, 'plus').mockReturnValue(30)
8+
expect(calculator('plus', 1, 2)).toBe(30)
9+
vi.mocked(actions.plus).mockRestore()
10+
expect(calculator('plus', 1, 2)).toBe(3)
11+
})
12+
13+
test('has module name', () => {
14+
expect(String(actions)).toBe('[object Module]')
15+
expect(actions[Symbol.toStringTag]).toBe('Module')
16+
})
17+
18+
test('exports are correct', () => {
19+
expect(Object.keys(actions)).toEqual(['plus'])
20+
expect(Object.keys(calculatorModule)).toEqual(['calculator'])
21+
expect(calculatorModule.calculator).toBe(calculator)
22+
})
23+
24+
test('imports are still the same', async () => {
25+
// @ts-expect-error typescript resolution
26+
await expect(import('../src/calculator')).resolves.toBe(calculatorModule)
27+
// @ts-expect-error typescript resolution
28+
// eslint-disable-next-line @typescript-eslint/quotes
29+
await expect(import(`../src/calculator`)).resolves.toBe(calculatorModule)
30+
})

‎test/core/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
"@vitest/expect": "workspace:*",
1010
"@vitest/runner": "workspace:*",
1111
"@vitest/utils": "workspace:*",
12+
"acorn": "^8.8.2",
1213
"tinyspy": "^1.0.2",
1314
"url": "^0.11.0",
1415
"vitest": "workspace:*"

‎test/core/test/injector-esm.test.ts

+927
Large diffs are not rendered by default.

‎test/core/test/injector-mock.test.ts

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Parser } from 'acorn'
2+
import { hoistMocks } from 'vitest/src/node/hoistMocks'
3+
import { expect, test } from 'vitest'
4+
5+
function parse(code: string, options: any) {
6+
return Parser.parse(code, options)
7+
}
8+
9+
function hoistSimpleCode(code: string) {
10+
return hoistMocks(code, '/test.js', parse)?.code.trim()
11+
}
12+
13+
test('hoists mock, unmock, hoisted', () => {
14+
expect(hoistSimpleCode(`
15+
vi.mock('path', () => {})
16+
vi.unmock('path')
17+
vi.hoisted(() => {})
18+
`)).toMatchInlineSnapshot(`
19+
"if (typeof globalThis.vi === \\"undefined\\" && typeof globalThis.vitest === \\"undefined\\") { throw new Error(\\"There are some problems in resolving the mocks API.\\\\nYou may encounter this issue when importing the mocks API from another module other than 'vitest'.\\\\nTo fix this issue you can either:\\\\n- import the mocks API directly from 'vitest'\\\\n- enable the 'globals' options\\") }
20+
vi.mock('path', () => {})
21+
vi.unmock('path')
22+
vi.hoisted(() => {})"
23+
`)
24+
})
25+
26+
test('always hoists import from vitest', () => {
27+
expect(hoistSimpleCode(`
28+
import { vi } from 'vitest'
29+
vi.mock('path', () => {})
30+
vi.unmock('path')
31+
vi.hoisted(() => {})
32+
import { test } from 'vitest'
33+
`)).toMatchInlineSnapshot(`
34+
"const { vi } = await import('vitest')
35+
const { test } = await import('vitest')
36+
vi.mock('path', () => {})
37+
vi.unmock('path')
38+
vi.hoisted(() => {})"
39+
`)
40+
})
41+
42+
test('always hoists all imports but they are under mocks', () => {
43+
expect(hoistSimpleCode(`
44+
import { vi } from 'vitest'
45+
import { someValue } from './path.js'
46+
import { someValue2 } from './path2.js'
47+
vi.mock('path', () => {})
48+
vi.unmock('path')
49+
vi.hoisted(() => {})
50+
import { test } from 'vitest'
51+
`)).toMatchInlineSnapshot(`
52+
"const { vi } = await import('vitest')
53+
const { test } = await import('vitest')
54+
vi.mock('path', () => {})
55+
vi.unmock('path')
56+
vi.hoisted(() => {})
57+
const { someValue } = await import('./path.js')
58+
const { someValue2 } = await import('./path2.js')"
59+
`)
60+
})

0 commit comments

Comments
 (0)
Please sign in to comment.