feat!: update mock implementation to support ESM runtime, introduce "vi.hoisted" (#3258)

This commit is contained in:
Vladimir 2023-04-27 15:04:05 +02:00 committed by GitHub
parent da2f1970b9
commit 0c09a40d2b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 2433 additions and 696 deletions

View File

@ -138,7 +138,7 @@ type Awaitable<T> = T | PromiseLike<T>
```ts
import { Stocks } from './stocks.js'
const stocks = new Stocks()
stocks.sync('Bill')
if (stocks.getInfo('Bill'))
@ -150,7 +150,7 @@ type Awaitable<T> = T | PromiseLike<T>
```ts
import { expect, test } from 'vitest'
import { Stocks } from './stocks.js'
const stocks = new Stocks()
test('if we know Bill stock, sell apples to him', () => {
@ -171,7 +171,7 @@ type Awaitable<T> = T | PromiseLike<T>
```ts
import { Stocks } from './stocks.js'
const stocks = new Stocks()
stocks.sync('Bill')
if (!stocks.stockFailed('Bill'))
@ -183,7 +183,7 @@ type Awaitable<T> = T | PromiseLike<T>
```ts
import { expect, test } from 'vitest'
import { Stocks } from './stocks.js'
const stocks = new Stocks()
test('if Bill stock hasn\'t failed, sell apples to him', () => {
@ -242,7 +242,7 @@ type Awaitable<T> = T | PromiseLike<T>
```ts
import { expect, test } from 'vitest'
const actual = 'stock'
test('stock is type of string', () => {
@ -259,7 +259,7 @@ type Awaitable<T> = T | PromiseLike<T>
```ts
import { expect, test } from 'vitest'
import { Stocks } from './stocks.js'
const stocks = new Stocks()
test('stocks are instance of Stocks', () => {
@ -695,7 +695,7 @@ If the value in the error message is too truncated, you can increase [chaiConfig
## toMatchFileSnapshot
- **Type:** `<T>(filepath: string, message?: string) => Promise<void>`
- **Version:** Vitest 0.30.0
- **Version:** Since Vitest 0.30.0
Compare or update the snapshot with the content of a file explicitly specified (instead of the `.snap` file).

View File

@ -114,11 +114,55 @@ import { vi } from 'vitest'
When using `vi.useFakeTimers`, `Date.now` calls are mocked. If you need to get real time in milliseconds, you can call this function.
## vi.hoisted
- **Type**: `<T>(factory: () => T) => T`
- **Version**: Since Vitest 0.31.0
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.
Hovewer it can be useful to invoke some side effect like mocking dates before importing a module.
To bypass this limitation, you can rewrite static imports into dynamic ones like this:
```diff
callFunctionWithSideEffect()
- import { value } from './some/module.ts'
+ const { value } = await import('./some/module.ts')
```
When running `vitest`, you can do this automatically by using `vi.hoisted` method.
```diff
- callFunctionWithSideEffect()
import { value } from './some/module.ts'
+ vi.hoisted(() => callFunctionWithSideEffect())
```
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:
```ts
import { expect, vi } from 'vitest'
import { originalMethod } from './path/to/module.js'
const { mockedMethod } = vi.hoisted(() => {
return { mockedMethod: vi.fn() }
})
vi.mocked('./path/to/module.js', () => {
return { originalMethod: mockedMethod }
})
mockedMethod.mockReturnValue(100)
expect(originalMethod()).toBe(100)
```
## vi.mock
- **Type**: `(path: string, factory?: () => unknown) => void`
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.
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`.
::: warning
`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'
This also means that you cannot use any variables inside the factory that are defined outside the factory.
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.
You can also reference variables defined by `vi.hoisted` method if it was declared before `vi.mock`:
```ts
import { namedExport } from './path/to/module.js'
const mocks = vi.hoisted(() => {
return {
namedExport: vi.fn(),
}
})
vi.mock('./path/to/module.js', () => {
return {
namedExport: mocks.namedExport,
}
})
vi.mocked(namedExport).mockReturnValue(100)
expect(namedExport()).toBe(100)
expect(namedExport).toBe(mocks.namedExport)
```
:::
::: warning
@ -199,7 +266,7 @@ import { vi } from 'vitest'
```
::: warning
Beware that if you don't call `vi.mock`, modules **are not** mocked automatically.
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).
:::
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).

View File

@ -963,7 +963,7 @@ Listen to port and serve API. When set to true, the default port is 51204
### browser
- **Type:** `{ enabled?, name?, provider?, headless?, api? }`
- **Type:** `{ enabled?, name?, provider?, headless?, api?, slowHijackESM? }`
- **Default:** `{ enabled: false, headless: process.env.CI, api: 63315 }`
- **Version:** Since Vitest 0.29.4
- **CLI:** `--browser`, `--browser=<name>`, `--browser.name=chrome --browser.headless`
@ -1035,6 +1035,19 @@ export interface BrowserProvider {
This is an advanced API for library authors. If you just need to run tests in a browser, use the [browser](/config/#browser) option.
:::
#### browser.slowHijackESM
- **Type:** `boolean`
- **Default:** `true`
- **Version:** Since Vitest 0.31.0
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.
This option has no effect on tests running inside Node.js.
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.
### clearMocks
- **Type:** `boolean`
@ -1358,7 +1371,7 @@ The number of milliseconds after which a test is considered slow and reported as
- **Type:** `{ includeStack?, showDiff?, truncateThreshold? }`
- **Default:** `{ includeStack: false, showDiff: true, truncateThreshold: 40 }`
- **Version:** Vitest 0.30.0
- **Version:** Since Vitest 0.30.0
Equivalent to [Chai config](https://github.com/chaijs/chai/blob/4.x.x/lib/chai/config.js).

View File

@ -0,0 +1,26 @@
import { expect, test, vi } from 'vitest'
import { asyncSquare as importedAsyncSquare, square as importedSquare } from '../src/example'
const mocks = vi.hoisted(() => {
return {
square: vi.fn(),
}
})
const { asyncSquare } = await vi.hoisted(async () => {
return {
asyncSquare: vi.fn(),
}
})
vi.mock('../src/example.ts', () => {
return {
square: mocks.square,
asyncSquare,
}
})
test('hoisted works', () => {
expect(importedSquare).toBe(mocks.square)
expect(importedAsyncSquare).toBe(asyncSquare)
})

View File

@ -1,5 +1,8 @@
{
"compilerOptions": {
"module": "esnext",
"target": "esnext",
"moduleResolution": "nodenext",
"types": ["vitest/globals"]
}
}

View File

@ -1,4 +1,4 @@
// Vitest Snapshot v1
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`mount component 1`] = `
"<div>4 x 2 = 8</div>

View File

@ -39,17 +39,21 @@
"prepublishOnly": "pnpm build"
},
"peerDependencies": {
"vitest": ">=0.29.4"
"vitest": ">=0.31.0"
},
"dependencies": {
"modern-node-polyfills": "^0.1.1",
"sirv": "^2.0.2"
},
"devDependencies": {
"@types/estree": "^1.0.1",
"@types/ws": "^8.5.4",
"@vitest/runner": "workspace:*",
"@vitest/ui": "workspace:*",
"@vitest/ws-client": "workspace:*",
"estree-walker": "^3.0.3",
"periscopic": "^3.1.0",
"rollup": "3.20.2",
"vitest": "workspace:*"
}
}

View File

@ -24,6 +24,55 @@
</head>
<body>
<iframe id="vitest-ui" src=""></iframe>
<script>
const moduleCache = new Map()
// this method receives a module object or "import" promise that it resolves and keeps track of
// and returns a hijacked module object that can be used to mock module exports
function wrapModule(module) {
if (module instanceof Promise) {
moduleCache.set(module, { promise: module, evaluated: false })
return module
.then(m => m.__vi_inject__)
.finally(() => moduleCache.delete(module))
}
return module.__vi_inject__
}
function exportAll(exports, sourceModule) {
// #1120 when a module exports itself it causes
// call stack error
if (exports === sourceModule)
return
if (Object(sourceModule) !== sourceModule || Array.isArray(sourceModule))
return
for (const key in sourceModule) {
if (key !== 'default') {
try {
Object.defineProperty(exports, key, {
enumerable: true,
configurable: true,
get: () => sourceModule[key],
})
}
catch (_err) { }
}
}
}
window.__vi_export_all__ = exportAll
// TODO: allow easier rewriting of import.meta.env
window.__vi_import_meta__ = {
env: {},
url: location.href,
}
window.__vi_module_cache__ = moduleCache
window.__vi_wrap_module__ = wrapModule
</script>
<script type="module" src="/main.ts"></script>
</body>
</html>

View File

@ -8,6 +8,7 @@ import { setupConsoleLogSpy } from './logger'
import { createSafeRpc, rpc, rpcDone } from './rpc'
import { setupDialogsSpy } from './dialog'
import { BrowserSnapshotEnvironment } from './snapshot'
import { VitestBrowserClientMocker } from './mocker'
// @ts-expect-error mocking some node apis
globalThis.process = { env: {}, argv: [], cwd: () => '/', stdout: { write: () => {} }, nextTick: cb => cb() }
@ -72,7 +73,8 @@ ws.addEventListener('open', async () => {
globalThis.__vitest_worker__ = {
config,
browserHashMap,
moduleCache: new Map(),
// @ts-expect-error untyped global for internal use
moduleCache: globalThis.__vi_module_cache__,
rpc: client.rpc,
safeRpc,
durations: {
@ -80,6 +82,8 @@ ws.addEventListener('open', async () => {
prepare: 0,
},
}
// @ts-expect-error mocking vitest apis
globalThis.__vitest_mocker__ = new VitestBrowserClientMocker()
const paths = getQueryPaths()

View File

@ -0,0 +1,25 @@
function throwNotImplemented(name: string) {
throw new Error(`[vitest] ${name} is not implemented in browser environment yet.`)
}
export class VitestBrowserClientMocker {
public importActual() {
throwNotImplemented('importActual')
}
public importMock() {
throwNotImplemented('importMock')
}
public queueMock() {
throwNotImplemented('queueMock')
}
public queueUnmock() {
throwNotImplemented('queueUnmock')
}
public prepare() {
// TODO: prepare
}
}

View File

@ -1,4 +1,5 @@
export function importId(id: string) {
const name = `/@id/${id}`
return import(name)
// @ts-expect-error mocking vitest apis
return __vi_wrap_module__(import(name))
}

View File

@ -0,0 +1,276 @@
import MagicString from 'magic-string'
import { extract_names as extractNames } from 'periscopic'
import type { Expression, ImportDeclaration } from 'estree'
import type { AcornNode } from 'rollup'
import type { Node, Positioned } from './esmWalker'
import { esmWalker, isInDestructuringAssignment, isNodeInPattern, isStaticProperty } from './esmWalker'
const viInjectedKey = '__vi_inject__'
// const viImportMetaKey = '__vi_import_meta__' // to allow overwrite
const viExportAllHelper = '__vi_export_all__'
const skipHijack = [
'/@vite/client',
'/@vite/env',
/vite\/dist\/client/,
]
interface Options {
cacheDir: string
}
// this is basically copypaste from Vite SSR
// this method transforms all import and export statements into `__vi_injected__` variable
// to allow spying on them. this can be disabled by setting `slowHijackESM` to `false`
export function injectVitestModule(code: string, id: string, parse: (code: string, options: any) => AcornNode, options: Options) {
if (skipHijack.some(skip => id.match(skip)))
return
const s = new MagicString(code)
let ast: any
try {
ast = parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
})
}
catch (err) {
console.error(`Cannot parse ${id}:\n${(err as any).message}`)
return
}
let uid = 0
const idToImportMap = new Map<string, string>()
const declaredConst = new Set<string>()
const hoistIndex = 0
let hasInjected = false
// this will tranfrom import statements into dynamic ones, if there are imports
// it will keep the import as is, if we don't need to mock anything
// in browser environment it will wrap the module value with "vitest_wrap_module" function
// that returns a proxy to the module so that named exports can be mocked
const transformImportDeclaration = (node: ImportDeclaration) => {
const source = node.source.value as string
if (skipHijack.some(skip => source.match(skip)))
return null
const importId = `__vi_esm_${uid++}__`
const hasSpecifiers = node.specifiers.length > 0
const code = hasSpecifiers
? `import { ${viInjectedKey} as ${importId} } from '${source}'\n`
: `import '${source}'\n`
return {
code,
id: importId,
}
}
function defineImport(node: ImportDeclaration) {
const declaration = transformImportDeclaration(node)
if (!declaration)
return null
s.appendLeft(hoistIndex, declaration.code)
return declaration.id
}
function defineImportAll(source: string) {
const importId = `__vi_esm_${uid++}__`
s.appendLeft(hoistIndex, `const { ${viInjectedKey}: ${importId} } = await import(${JSON.stringify(source)});\n`)
return importId
}
function defineExport(position: number, name: string, local = name) {
hasInjected = true
s.appendLeft(
position,
`\nObject.defineProperty(${viInjectedKey}, "${name}", `
+ `{ enumerable: true, configurable: true, get(){ return ${local} }});`,
)
}
// 1. check all import statements and record id -> importName map
for (const node of ast.body as Node[]) {
// import foo from 'foo' --> foo -> __import_foo__.default
// import { baz } from 'foo' --> baz -> __import_foo__.baz
// import * as ok from 'foo' --> ok -> __import_foo__
if (node.type === 'ImportDeclaration') {
const importId = defineImport(node)
if (!importId)
continue
s.remove(node.start, node.end)
for (const spec of node.specifiers) {
if (spec.type === 'ImportSpecifier') {
idToImportMap.set(
spec.local.name,
`${importId}.${spec.imported.name}`,
)
}
else if (spec.type === 'ImportDefaultSpecifier') {
idToImportMap.set(spec.local.name, `${importId}.default`)
}
else {
// namespace specifier
idToImportMap.set(spec.local.name, importId)
}
}
}
}
// 2. check all export statements and define exports
for (const node of ast.body as Node[]) {
// named exports
if (node.type === 'ExportNamedDeclaration') {
if (node.declaration) {
if (
node.declaration.type === 'FunctionDeclaration'
|| node.declaration.type === 'ClassDeclaration'
) {
// export function foo() {}
defineExport(node.end, node.declaration.id!.name)
}
else {
// export const foo = 1, bar = 2
for (const declaration of node.declaration.declarations) {
const names = extractNames(declaration.id as any)
for (const name of names)
defineExport(node.end, name)
}
}
s.remove(node.start, (node.declaration as Node).start)
}
else {
s.remove(node.start, node.end)
if (node.source) {
// export { foo, bar } from './foo'
const importId = defineImportAll(node.source.value as string)
// hoist re-exports near the defined import so they are immediately exported
for (const spec of node.specifiers) {
defineExport(
hoistIndex,
spec.exported.name,
`${importId}.${spec.local.name}`,
)
}
}
else {
// export { foo, bar }
for (const spec of node.specifiers) {
const local = spec.local.name
const binding = idToImportMap.get(local)
defineExport(node.end, spec.exported.name, binding || local)
}
}
}
}
// default export
if (node.type === 'ExportDefaultDeclaration') {
const expressionTypes = ['FunctionExpression', 'ClassExpression']
if (
'id' in node.declaration
&& node.declaration.id
&& !expressionTypes.includes(node.declaration.type)
) {
// named hoistable/class exports
// export default function foo() {}
// export default class A {}
hasInjected = true
const { name } = node.declaration.id
s.remove(node.start, node.start + 15 /* 'export default '.length */)
s.append(
`\nObject.defineProperty(${viInjectedKey}, "default", `
+ `{ enumerable: true, configurable: true, value: ${name} });`,
)
}
else {
// anonymous default exports
hasInjected = true
s.update(
node.start,
node.start + 14 /* 'export default'.length */,
`${viInjectedKey}.default =`,
)
if (id.startsWith(options.cacheDir)) {
// keep export default for optimized dependencies
s.append(`\nexport default { ${viInjectedKey}: ${viInjectedKey}.default };\n`)
}
}
}
// export * from './foo'
if (node.type === 'ExportAllDeclaration') {
s.remove(node.start, node.end)
const importId = defineImportAll(node.source.value as string)
// hoist re-exports near the defined import so they are immediately exported
if (node.exported) {
defineExport(hoistIndex, node.exported.name, `${importId}`)
}
else {
hasInjected = true
s.appendLeft(hoistIndex, `${viExportAllHelper}(${viInjectedKey}, ${importId});\n`)
}
}
}
// 3. convert references to import bindings & import.meta references
esmWalker(ast, {
onIdentifier(id, parent, parentStack) {
const grandparent = parentStack[1]
const binding = idToImportMap.get(id.name)
if (!binding)
return
if (isStaticProperty(parent) && parent.shorthand) {
// let binding used in a property shorthand
// { foo } -> { foo: __import_x__.foo }
// skip for destructuring patterns
if (
!isNodeInPattern(parent)
|| isInDestructuringAssignment(parent, parentStack)
)
s.appendLeft(id.end, `: ${binding}`)
}
else if (
(parent.type === 'PropertyDefinition'
&& grandparent?.type === 'ClassBody')
|| (parent.type === 'ClassDeclaration' && id === parent.superClass)
) {
if (!declaredConst.has(id.name)) {
declaredConst.add(id.name)
// locate the top-most node containing the class declaration
const topNode = parentStack[parentStack.length - 2]
s.prependRight(topNode.start, `const ${id.name} = ${binding};\n`)
}
}
else {
s.update(id.start, id.end, binding)
}
},
// TODO: make env updatable
onImportMeta() {
// s.update(node.start, node.end, viImportMetaKey)
},
onDynamicImport(node) {
const replace = '__vi_wrap_module__(import('
s.overwrite(node.start, (node.source as Positioned<Expression>).start, replace)
s.overwrite(node.end - 1, node.end, '))')
},
})
if (hasInjected) {
// make sure "__vi_injected__" is declared as soon as possible
s.prepend(`const ${viInjectedKey} = { [Symbol.toStringTag]: "Module" };\n`)
s.append(`\nexport { ${viInjectedKey} }`)
}
return {
ast,
code: s.toString(),
map: s.generateMap({ hires: true, source: id }),
}
}

View File

@ -0,0 +1,301 @@
import type {
Function as FunctionNode,
Identifier,
ImportExpression,
Pattern,
Property,
VariableDeclaration,
Node as _Node,
} from 'estree'
import { walk as eswalk } from 'estree-walker'
export type Positioned<T> = T & {
start: number
end: number
}
export type Node = Positioned<_Node>
interface Visitors {
onIdentifier: (
node: Positioned<Identifier>,
parent: Node,
parentStack: Node[],
) => void
onImportMeta: (node: Node) => void
onDynamicImport: (node: Positioned<ImportExpression>) => void
}
const isNodeInPatternWeakSet = new WeakSet<_Node>()
export function setIsNodeInPattern(node: Property) {
return isNodeInPatternWeakSet.add(node)
}
export function isNodeInPattern(node: _Node): node is Property {
return isNodeInPatternWeakSet.has(node)
}
/**
* Same logic from \@vue/compiler-core & \@vue/compiler-sfc
* Except this is using acorn AST
*/
export function esmWalker(
root: Node,
{ onIdentifier, onImportMeta, onDynamicImport }: Visitors,
) {
const parentStack: Node[] = []
const varKindStack: VariableDeclaration['kind'][] = []
const scopeMap = new WeakMap<_Node, Set<string>>()
const identifiers: [id: any, stack: Node[]][] = []
const setScope = (node: _Node, name: string) => {
let scopeIds = scopeMap.get(node)
if (scopeIds && scopeIds.has(name))
return
if (!scopeIds) {
scopeIds = new Set()
scopeMap.set(node, scopeIds)
}
scopeIds.add(name)
}
function isInScope(name: string, parents: Node[]) {
return parents.some(node => node && scopeMap.get(node)?.has(name))
}
function handlePattern(p: Pattern, parentScope: _Node) {
if (p.type === 'Identifier') {
setScope(parentScope, p.name)
}
else if (p.type === 'RestElement') {
handlePattern(p.argument, parentScope)
}
else if (p.type === 'ObjectPattern') {
p.properties.forEach((property) => {
if (property.type === 'RestElement')
setScope(parentScope, (property.argument as Identifier).name)
else
handlePattern(property.value, parentScope)
})
}
else if (p.type === 'ArrayPattern') {
p.elements.forEach((element) => {
if (element)
handlePattern(element, parentScope)
})
}
else if (p.type === 'AssignmentPattern') {
handlePattern(p.left, parentScope)
}
else {
setScope(parentScope, (p as any).name)
}
}
(eswalk as any)(root, {
enter(node: Node, parent: Node | null) {
if (node.type === 'ImportDeclaration')
return this.skip()
// track parent stack, skip for "else-if"/"else" branches as acorn nests
// the ast within "if" nodes instead of flattening them
if (
parent
&& !(parent.type === 'IfStatement' && node === parent.alternate)
)
parentStack.unshift(parent)
// track variable declaration kind stack used by VariableDeclarator
if (node.type === 'VariableDeclaration')
varKindStack.unshift(node.kind)
if (node.type === 'MetaProperty' && node.meta.name === 'import')
onImportMeta(node)
else if (node.type === 'ImportExpression')
onDynamicImport(node)
if (node.type === 'Identifier') {
if (
!isInScope(node.name, parentStack)
&& isRefIdentifier(node, parent!, parentStack)
) {
// record the identifier, for DFS -> BFS
identifiers.push([node, parentStack.slice(0)])
}
}
else if (isFunctionNode(node)) {
// If it is a function declaration, it could be shadowing an import
// Add its name to the scope so it won't get replaced
if (node.type === 'FunctionDeclaration') {
const parentScope = findParentScope(parentStack)
if (parentScope)
setScope(parentScope, node.id!.name)
}
// walk function expressions and add its arguments to known identifiers
// so that we don't prefix them
node.params.forEach((p) => {
if (p.type === 'ObjectPattern' || p.type === 'ArrayPattern') {
handlePattern(p, node)
return
}
(eswalk as any)(p.type === 'AssignmentPattern' ? p.left : p, {
enter(child: Node, parent: Node) {
// skip params default value of destructure
if (
parent?.type === 'AssignmentPattern'
&& parent?.right === child
)
return this.skip()
if (child.type !== 'Identifier')
return
// do not record as scope variable if is a destructuring keyword
if (isStaticPropertyKey(child, parent))
return
// do not record if this is a default value
// assignment of a destructuring variable
if (
(parent?.type === 'TemplateLiteral'
&& parent?.expressions.includes(child))
|| (parent?.type === 'CallExpression' && parent?.callee === child)
)
return
setScope(node, child.name)
},
})
})
}
else if (node.type === 'Property' && parent!.type === 'ObjectPattern') {
// mark property in destructuring pattern
setIsNodeInPattern(node)
}
else if (node.type === 'VariableDeclarator') {
const parentFunction = findParentScope(
parentStack,
varKindStack[0] === 'var',
)
if (parentFunction)
handlePattern(node.id, parentFunction)
}
else if (node.type === 'CatchClause' && node.param) {
handlePattern(node.param, node)
}
},
leave(node: Node, parent: Node | null) {
// untrack parent stack from above
if (
parent
&& !(parent.type === 'IfStatement' && node === parent.alternate)
)
parentStack.shift()
if (node.type === 'VariableDeclaration')
varKindStack.shift()
},
})
// emit the identifier events in BFS so the hoisted declarations
// can be captured correctly
identifiers.forEach(([node, stack]) => {
if (!isInScope(node.name, stack))
onIdentifier(node, stack[0], stack)
})
}
function isRefIdentifier(id: Identifier, parent: _Node, parentStack: _Node[]) {
// declaration id
if (
parent.type === 'CatchClause'
|| ((parent.type === 'VariableDeclarator'
|| parent.type === 'ClassDeclaration')
&& parent.id === id)
)
return false
if (isFunctionNode(parent)) {
// function declaration/expression id
if ((parent as any).id === id)
return false
// params list
if (parent.params.includes(id))
return false
}
// class method name
if (parent.type === 'MethodDefinition' && !parent.computed)
return false
// property key
if (isStaticPropertyKey(id, parent))
return false
// object destructuring pattern
if (isNodeInPattern(parent) && parent.value === id)
return false
// non-assignment array destructuring pattern
if (
parent.type === 'ArrayPattern'
&& !isInDestructuringAssignment(parent, parentStack)
)
return false
// member expression property
if (
parent.type === 'MemberExpression'
&& parent.property === id
&& !parent.computed
)
return false
if (parent.type === 'ExportSpecifier')
return false
// is a special keyword but parsed as identifier
if (id.name === 'arguments')
return false
return true
}
export function isStaticProperty(node: _Node): node is Property {
return node && node.type === 'Property' && !node.computed
}
export function isStaticPropertyKey(node: _Node, parent: _Node) {
return isStaticProperty(parent) && parent.key === node
}
const functionNodeTypeRE = /Function(?:Expression|Declaration)$|Method$/
export function isFunctionNode(node: _Node): node is FunctionNode {
return functionNodeTypeRE.test(node.type)
}
const blockNodeTypeRE = /^BlockStatement$|^For(?:In|Of)?Statement$/
function isBlock(node: _Node) {
return blockNodeTypeRE.test(node.type)
}
function findParentScope(
parentStack: _Node[],
isVar = false,
): _Node | undefined {
return parentStack.find(isVar ? isFunctionNode : isBlock)
}
export function isInDestructuringAssignment(
parent: _Node,
parentStack: _Node[],
): boolean {
if (
parent
&& (parent.type === 'Property' || parent.type === 'ArrayPattern')
)
return parentStack.some(i => i.type === 'AssignmentExpression')
return false
}

View File

@ -5,12 +5,14 @@ import { builtinModules } from 'node:module'
import { polyfillPath } from 'modern-node-polyfills'
import sirv from 'sirv'
import type { Plugin } from 'vite'
import { injectVitestModule } from './esmInjector'
const polyfills = [
'util',
]
export default (base = '/'): Plugin[] => {
// don't expose type to not bundle it here
export default (project: any, base = '/'): Plugin[] => {
const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..')
const distRoot = resolve(pkgRoot, 'dist')
@ -51,6 +53,18 @@ export default (base = '/'): Plugin[] => {
return { id: await polyfillPath(id), moduleSideEffects: false }
},
},
{
name: 'vitest:browser:esm-injector',
enforce: 'post',
transform(source, id) {
const hijackESM = project.config.browser.slowHijackESM ?? false
if (!hijackESM)
return
return injectVitestModule(source, id, this.parse, {
cacheDir: project.server.config.cacheDir,
})
},
},
]
}

View File

@ -1,6 +1,10 @@
import concordance from 'concordance'
import * as concordance from 'concordance'
import { getColors } from './colors'
const concordanceModule = 'default' in concordance
? concordance.default
: concordance as any
interface DisplayOptions {
theme?: any
maxDepth?: number
@ -89,6 +93,6 @@ export function getConcordanceTheme() {
}
}
export function diffDescriptors(actual: unknown, expected: unknown, options: DisplayOptions) {
return concordance.diff(expected, actual, options)
export function diffDescriptors(actual: unknown, expected: unknown, options: DisplayOptions): string {
return concordanceModule.diff(expected, actual, options)
}

View File

@ -167,6 +167,7 @@
"@jridgewell/trace-mapping": "^0.3.17",
"@sinonjs/fake-timers": "^10.0.2",
"@types/diff": "^5.0.3",
"@types/estree": "^1.0.1",
"@types/istanbul-lib-coverage": "^2.0.4",
"@types/istanbul-reports": "^3.0.1",
"@types/jsdom": "^21.1.1",

View File

@ -7,6 +7,7 @@ import { ensurePackageInstalled } from '../../node/pkg'
import { resolveApiServerConfig } from '../../node/config'
import { CoverageTransform } from '../../node/plugins/coverageTransform'
import type { WorkspaceProject } from '../../node/workspace'
import { MocksPlugin } from '../../node/plugins/mocks'
export async function createBrowserServer(project: WorkspaceProject, options: UserConfig) {
const root = project.config.root
@ -31,7 +32,7 @@ export async function createBrowserServer(project: WorkspaceProject, options: Us
},
},
plugins: [
(await import('@vitest/browser')).default('/'),
(await import('@vitest/browser')).default(project, '/'),
CoverageTransform(project.ctx),
{
enforce: 'post',
@ -42,7 +43,8 @@ export async function createBrowserServer(project: WorkspaceProject, options: Us
}
config.server = server
config.server.fs = { strict: false }
config.server.fs ??= {}
config.server.fs.strict = false
return {
resolve: {
@ -51,6 +53,7 @@ export async function createBrowserServer(project: WorkspaceProject, options: Us
}
},
},
MocksPlugin(),
],
})

View File

@ -1,5 +1,5 @@
import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers'
import { createSimpleStackTrace } from '@vitest/utils'
import { assertTypes, createSimpleStackTrace } from '@vitest/utils'
import { parseSingleStack } from '../utils/source-map'
import type { VitestMocker } from '../runtime/mocker'
import type { ResolvedConfig, RuntimeConfig } from '../types'
@ -30,6 +30,12 @@ interface VitestUtils {
spyOn: typeof spyOn
fn: typeof fn
/**
* Run the factory before imports are evaluated. You can return a value from the factory
* to reuse it inside your `vi.mock` factory and tests.
*/
hoisted<T>(factory: () => T): T
/**
* Makes all `imports` to passed module to be mocked.
* - 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 {
spyOn,
fn,
hoisted<T>(factory: () => T): T {
assertTypes(factory, '"vi.hoisted" factory', ['function'])
return factory()
},
mock(path: string, factory?: MockFactoryWithHelper) {
const importer = getImporter()
_mocker.queueMock(

View File

@ -272,6 +272,7 @@ export function resolveConfig(
resolved.browser ??= {} as any
resolved.browser.enabled ??= false
resolved.browser.headless ??= isCI
resolved.browser.slowHijackESM ??= true
resolved.browser.api = resolveApiServerConfig(resolved.browser) || {
port: defaultBrowserPort,

View File

@ -9,6 +9,7 @@ import { normalizeRequestId } from 'vite-node/utils'
import { ViteNodeRunner } from 'vite-node/client'
import { SnapshotManager } from '@vitest/snapshot/manager'
import type { CancelReason } from '@vitest/runner'
import { ViteNodeServer } from 'vite-node/server'
import type { ArgumentsType, CoverageProvider, OnServerRestartHandler, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, VitestRunMode } from '../types'
import { hasFailed, noop, slash, toArray } from '../utils'
import { getCoverageProvider } from '../integrations/coverage'
@ -22,7 +23,6 @@ import { resolveConfig } from './config'
import { Logger } from './logger'
import { VitestCache } from './cache'
import { WorkspaceProject, initializeProject } from './workspace'
import { VitestServer } from './server'
const WATCHER_DEBOUNCE = 100
@ -40,7 +40,7 @@ export class Vitest {
logger: Logger
pool: ProcessPool | undefined
vitenode: VitestServer = undefined!
vitenode: ViteNodeServer = undefined!
invalidates: Set<string> = new Set()
changedTests: Set<string> = new Set()
@ -89,7 +89,7 @@ export class Vitest {
if (this.config.watch && this.mode !== 'typecheck')
this.registerWatcher()
this.vitenode = new VitestServer(server, this.config)
this.vitenode = new ViteNodeServer(server, this.config)
const node = this.vitenode
this.runner = new ViteNodeRunner({
root: server.config.root,

View File

@ -0,0 +1,184 @@
import MagicString from 'magic-string'
import type { CallExpression, Identifier, ImportDeclaration, VariableDeclaration, Node as _Node } from 'estree'
import { findNodeAround, simple as simpleWalk } from 'acorn-walk'
import type { AcornNode } from 'rollup'
export type Positioned<T> = T & {
start: number
end: number
}
export type Node = Positioned<_Node>
const API_NOT_FOUND_ERROR = `There are some problems in resolving the mocks API.
You may encounter this issue when importing the mocks API from another module other than 'vitest'.
To fix this issue you can either:
- import the mocks API directly from 'vitest'
- enable the 'globals' options`
const API_NOT_FOUND_CHECK = '\nif (typeof globalThis.vi === "undefined" && typeof globalThis.vitest === "undefined") '
+ `{ throw new Error(${JSON.stringify(API_NOT_FOUND_ERROR)}) }\n`
function isIdentifier(node: any): node is Positioned<Identifier> {
return node.type === 'Identifier'
}
function transformImportSpecifiers(node: ImportDeclaration) {
const specifiers = node.specifiers
if (specifiers.length === 1 && specifiers[0].type === 'ImportNamespaceSpecifier')
return specifiers[0].local.name
const dynamicImports = node.specifiers.map((specifier) => {
if (specifier.type === 'ImportDefaultSpecifier')
return `default: ${specifier.local.name}`
if (specifier.type === 'ImportSpecifier') {
const local = specifier.local.name
const imported = specifier.imported.name
if (local === imported)
return local
return `${imported}: ${local}`
}
return null
}).filter(Boolean).join(', ')
if (!dynamicImports.length)
return ''
return `{ ${dynamicImports} }`
}
const regexpHoistable = /^[ \t]*\b(vi|vitest)\s*\.\s*(mock|unmock|hoisted)\(/m
const hashbangRE = /^#!.*\n/
export function hoistMocks(code: string, id: string, parse: (code: string, options: any) => AcornNode) {
const hasMocks = regexpHoistable.test(code)
if (!hasMocks)
return
const s = new MagicString(code)
let ast: any
try {
ast = parse(code, {
sourceType: 'module',
ecmaVersion: 'latest',
locations: true,
})
}
catch (err) {
console.error(`Cannot parse ${id}:\n${(err as any).message}`)
return
}
const hoistIndex = code.match(hashbangRE)?.[0].length ?? 0
let hoistedCode = ''
let hoistedVitestImports = ''
// this will tranfrom import statements into dynamic ones, if there are imports
// it will keep the import as is, if we don't need to mock anything
// in browser environment it will wrap the module value with "vitest_wrap_module" function
// that returns a proxy to the module so that named exports can be mocked
const transformImportDeclaration = (node: ImportDeclaration) => {
const source = node.source.value as string
// if we don't hijack ESM and process this file, then we definetly have mocks,
// so we need to transform imports into dynamic ones, so "vi.mock" can be executed before
const specifiers = transformImportSpecifiers(node)
const code = specifiers
? `const ${specifiers} = await import('${source}')\n`
: `await import('${source}')\n`
return code
}
function hoistImport(node: Positioned<ImportDeclaration>) {
// always hoist vitest import to top of the file, so
// "vi" helpers can access it
s.remove(node.start, node.end)
if (node.source.value === 'vitest') {
const code = `const ${transformImportSpecifiers(node)} = await import('vitest')\n`
hoistedVitestImports += code
return
}
const code = transformImportDeclaration(node)
s.appendLeft(hoistIndex, code)
}
// 1. check all import statements and record id -> importName map
for (const node of ast.body as Node[]) {
// import foo from 'foo' --> foo -> __import_foo__.default
// import { baz } from 'foo' --> baz -> __import_foo__.baz
// import * as ok from 'foo' --> ok -> __import_foo__
if (node.type === 'ImportDeclaration')
hoistImport(node)
}
simpleWalk(ast, {
CallExpression(_node) {
const node = _node as any as Positioned<CallExpression>
if (
node.callee.type === 'MemberExpression'
&& isIdentifier(node.callee.object)
&& (node.callee.object.name === 'vi' || node.callee.object.name === 'vitest')
&& isIdentifier(node.callee.property)
) {
const methodName = node.callee.property.name
if (methodName === 'mock' || methodName === 'unmock') {
hoistedCode += `${code.slice(node.start, node.end)}\n`
s.remove(node.start, node.end)
}
if (methodName === 'hoisted') {
const declarationNode = findNodeAround(ast, node.start, 'VariableDeclaration')?.node as Positioned<VariableDeclaration> | undefined
const init = declarationNode?.declarations[0]?.init
const isViHoisted = (node: CallExpression) => {
return node.callee.type === 'MemberExpression'
&& isIdentifier(node.callee.object)
&& (node.callee.object.name === 'vi' || node.callee.object.name === 'vitest')
&& isIdentifier(node.callee.property)
&& node.callee.property.name === 'hoisted'
}
const canMoveDeclaration = (init
&& init.type === 'CallExpression'
&& isViHoisted(init)) /* const v = vi.hoisted() */
|| (init
&& init.type === 'AwaitExpression'
&& init.argument.type === 'CallExpression'
&& isViHoisted(init.argument)) /* const v = await vi.hoisted() */
if (canMoveDeclaration) {
// hoist "const variable = vi.hoisted(() => {})"
hoistedCode += `${code.slice(declarationNode.start, declarationNode.end)}\n`
s.remove(declarationNode.start, declarationNode.end)
}
else {
// hoist "vi.hoisted(() => {})"
hoistedCode += `${code.slice(node.start, node.end)}\n`
s.remove(node.start, node.end)
}
}
}
},
})
if (hoistedCode || hoistedVitestImports) {
s.prepend(
hoistedVitestImports
+ ((!hoistedVitestImports && hoistedCode) ? API_NOT_FOUND_CHECK : '')
+ hoistedCode,
)
}
return {
ast,
code: s.toString(),
map: s.generateMap({ hires: true, source: id }),
}
}

View File

@ -1,235 +0,0 @@
import MagicString from 'magic-string'
import type { DecodedSourceMap, RawSourceMap } from '@ampproject/remapping'
import type { SourceMap } from 'rollup'
import type { TransformResult } from 'vite'
import remapping from '@ampproject/remapping'
import { getCallLastIndex } from '../utils'
const hoistRegexp = /^[ \t]*\b(?:__vite_ssr_import_\d+__\.)?((?:vitest|vi)\s*.\s*(mock|unmock)\(["`'\s]+(.*[@\w_-]+)["`'\s]+)[),]{1};?/gm
const API_NOT_FOUND_ERROR = `There are some problems in resolving the mocks API.
You may encounter this issue when importing the mocks API from another module other than 'vitest'.
To fix this issue you can either:
- import the mocks API directly from 'vitest'
- enable the 'globals' options`
export function hoistModuleMocks(mod: TransformResult, vitestPath: string): TransformResult {
if (!mod.code)
return mod
const m = hoistCodeMocks(mod.code)
if (m) {
const vitestRegexp = new RegExp(`const __vite_ssr_import_\\d+__ = await __vite_ssr_import__\\("(?:\/@fs\/?)?(?:${vitestPath}|vitest)"\\);`, 'gm')
// hoist vitest imports in case it was used inside vi.mock factory #425
const vitestImports = mod.code.matchAll(vitestRegexp)
let found = false
for (const match of vitestImports) {
const indexStart = match.index!
const indexEnd = match[0].length + indexStart
m.remove(indexStart, indexEnd)
m.prepend(`${match[0]}\n`)
found = true
}
// if no vitest import found, check if the mock API is reachable after the hoisting
if (!found) {
m.prepend('if (typeof globalThis.vi === "undefined" && typeof globalThis.vitest === "undefined") '
+ `{ throw new Error(${JSON.stringify(API_NOT_FOUND_ERROR)}) }\n`)
}
return {
...mod,
code: m.toString(),
map: mod.map
? combineSourcemaps(
mod.map.file,
[
{
...m.generateMap({ hires: true }),
sourcesContent: mod.map.sourcesContent,
} as RawSourceMap,
mod.map as RawSourceMap,
],
) as SourceMap
: null,
}
}
return mod
}
function hoistCodeMocks(code: string) {
let m: MagicString | undefined
const mocks = code.matchAll(hoistRegexp)
for (const mockResult of mocks) {
const lastIndex = getMockLastIndex(code.slice(mockResult.index!))
if (lastIndex === null)
continue
const startIndex = mockResult.index!
const { insideComment, insideString } = getIndexStatus(code, startIndex)
if (insideComment || insideString)
continue
const endIndex = startIndex + lastIndex
m ??= new MagicString(code)
m.prepend(`${m.slice(startIndex, endIndex)}\n`)
m.remove(startIndex, endIndex)
}
return m
}
function escapeToLinuxLikePath(path: string) {
if (/^[A-Z]:/.test(path))
return path.replace(/^([A-Z]):\//, '/windows/$1/')
if (/^\/[^/]/.test(path))
return `/linux${path}`
return path
}
function unescapeToLinuxLikePath(path: string) {
if (path.startsWith('/linux/'))
return path.slice('/linux'.length)
if (path.startsWith('/windows/'))
return path.replace(/^\/windows\/([A-Z])\//, '$1:/')
return path
}
// based on https://github.com/vitejs/vite/blob/6b40f03574cd71a17cbe564bc63adebb156ff06e/packages/vite/src/node/utils.ts#L727
const nullSourceMap: RawSourceMap = {
names: [],
sources: [],
mappings: '',
version: 3,
}
export function combineSourcemaps(
filename: string,
sourcemapList: Array<DecodedSourceMap | RawSourceMap>,
excludeContent = true,
): RawSourceMap {
if (
sourcemapList.length === 0
|| sourcemapList.every(m => m.sources.length === 0)
)
return { ...nullSourceMap }
// hack for parse broken with normalized absolute paths on windows (C:/path/to/something).
// escape them to linux like paths
// also avoid mutation here to prevent breaking plugin's using cache to generate sourcemaps like vue (see #7442)
sourcemapList = sourcemapList.map((sourcemap) => {
const newSourcemaps = { ...sourcemap }
newSourcemaps.sources = sourcemap.sources.map(source =>
source ? escapeToLinuxLikePath(source) : null,
)
if (sourcemap.sourceRoot)
newSourcemaps.sourceRoot = escapeToLinuxLikePath(sourcemap.sourceRoot)
return newSourcemaps
})
const escapedFilename = escapeToLinuxLikePath(filename)
// We don't declare type here so we can convert/fake/map as RawSourceMap
let map // : SourceMap
let mapIndex = 1
const useArrayInterface
= sourcemapList.slice(0, -1).find(m => m.sources.length !== 1) === undefined
if (useArrayInterface) {
map = remapping(sourcemapList, () => null, excludeContent)
}
else {
map = remapping(
sourcemapList[0],
(sourcefile) => {
if (sourcefile === escapedFilename && sourcemapList[mapIndex])
return sourcemapList[mapIndex++]
else
return null
},
excludeContent,
)
}
if (!map.file)
delete map.file
// unescape the previous hack
map.sources = map.sources.map(source =>
source ? unescapeToLinuxLikePath(source) : source,
)
map.file = filename
return map as RawSourceMap
}
function getMockLastIndex(code: string): number | null {
const index = getCallLastIndex(code)
if (index === null)
return null
return code[index + 1] === ';' ? index + 2 : index + 1
}
function getIndexStatus(code: string, from: number) {
let index = 0
let commentStarted = false
let commentEnded = true
let multilineCommentStarted = false
let multilineCommentEnded = true
let inString: string | null = null
let beforeChar: string | null = null
while (index <= from) {
const char = code[index]
const sub = code[index] + code[index + 1]
if (!inString) {
if (sub === '/*') {
multilineCommentStarted = true
multilineCommentEnded = false
}
if (sub === '*/' && multilineCommentStarted) {
multilineCommentStarted = false
multilineCommentEnded = true
}
if (sub === '//') {
commentStarted = true
commentEnded = false
}
if ((char === '\n' || sub === '\r\n') && commentStarted) {
commentStarted = false
commentEnded = true
}
}
if (!multilineCommentStarted && !commentStarted) {
const isCharString = char === '"' || char === '\'' || char === '`'
if (isCharString && beforeChar !== '\\') {
if (inString === char)
inString = null
else if (!inString)
inString = char
}
}
beforeChar = char
index++
}
return {
insideComment: !multilineCommentEnded || !commentEnded,
insideString: inString !== null,
}
}

View File

@ -11,6 +11,7 @@ import { EnvReplacerPlugin } from './envReplacer'
import { GlobalSetupPlugin } from './globalSetup'
import { CSSEnablerPlugin } from './cssEnabler'
import { CoverageTransform } from './coverageTransform'
import { MocksPlugin } from './mocks'
export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('test')): Promise<VitePlugin[]> {
const userConfig = deepMerge({}, options) as UserConfig
@ -242,6 +243,7 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t
options.ui
? await UIPlugin()
: null,
MocksPlugin(),
]
.filter(notNullish)
}

View File

@ -0,0 +1,12 @@
import type { Plugin } from 'vite'
import { hoistMocks } from '../hoistMocks'
export function MocksPlugin(): Plugin {
return {
name: 'vite:mocks',
enforce: 'post',
transform(code, id) {
return hoistMocks(code, id, this.parse)
},
}
}

View File

@ -9,6 +9,7 @@ import { CoverageTransform } from './coverageTransform'
import { CSSEnablerPlugin } from './cssEnabler'
import { EnvReplacerPlugin } from './envReplacer'
import { GlobalSetupPlugin } from './globalSetup'
import { MocksPlugin } from './mocks'
interface WorkspaceOptions extends UserWorkspaceConfig {
root?: string
@ -138,5 +139,6 @@ export function WorkspaceVitestPlugin(project: WorkspaceProject, options: Worksp
...CSSEnablerPlugin(project),
CoverageTransform(project.ctx),
GlobalSetupPlugin(project, project.ctx.logger),
MocksPlugin(),
]
}

View File

@ -42,8 +42,10 @@ export function createPool(ctx: Vitest): ProcessPool {
function getPoolName([project, file]: WorkspaceSpec) {
for (const [glob, pool] of project.config.poolMatchGlobs || []) {
if (pool === 'browser')
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')
if (mm.isMatch(file, glob, { cwd: project.config.root }))
return pool
return pool as VitestPool
}
return getDefaultPoolName(project)
}

View File

@ -1,20 +0,0 @@
import type { TransformResult } from 'vite'
import { ViteNodeServer } from 'vite-node/server'
import { hoistModuleMocks } from './mock'
export class VitestServer extends ViteNodeServer {
private _vitestPath?: string
private async getVitestPath() {
if (!this._vitestPath) {
const { id } = await this.resolveId('vitest') || { id: 'vitest' }
this._vitestPath = id
}
return this._vitestPath
}
protected async processTransformResult(id: string, result: TransformResult): Promise<TransformResult> {
const vitestId = await this.getVitestPath()
return super.processTransformResult(id, hoistModuleMocks(result, vitestId))
}
}

View File

@ -5,6 +5,7 @@ import { dirname, relative, resolve, toNamespacedPath } from 'pathe'
import { createServer } from 'vite'
import type { ViteDevServer, InlineConfig as ViteInlineConfig } from 'vite'
import { ViteNodeRunner } from 'vite-node/client'
import { ViteNodeServer } from 'vite-node/server'
import { createBrowserServer } from '../integrations/browser/server'
import type { ArgumentsType, Reporter, ResolvedConfig, UserConfig, UserWorkspaceConfig, Vitest } from '../types'
import { deepMerge, hasFailed } from '../utils'
@ -13,10 +14,9 @@ import type { BrowserProvider } from '../types/browser'
import { getBrowserProvider } from '../integrations/browser'
import { isBrowserEnabled, resolveConfig } from './config'
import { WorkspaceVitestPlugin } from './plugins/workspace'
import { VitestServer } from './server'
interface InitializeServerOptions {
server?: VitestServer
server?: ViteNodeServer
runner?: ViteNodeRunner
}
@ -65,7 +65,7 @@ export class WorkspaceProject {
config!: ResolvedConfig
server!: ViteDevServer
vitenode!: VitestServer
vitenode!: ViteNodeServer
runner!: ViteNodeRunner
browser: ViteDevServer = undefined!
typechecker?: Typechecker
@ -170,7 +170,7 @@ export class WorkspaceProject {
this.config = resolveConfig(this.ctx.mode, options, server.config)
this.server = server
this.vitenode = params.server ?? new VitestServer(server, this.config)
this.vitenode = params.server ?? new ViteNodeServer(server, this.config)
const node = this.vitenode
this.runner = params.runner ?? new ViteNodeRunner({
root: server.config.root,

View File

@ -124,8 +124,6 @@ export async function run(files: string[], config: ResolvedConfig, environment:
await startTests([file], runner)
workerState.filepath = undefined
// reset after tests, because user might call `vi.setConfig` in setupFile
vi.resetConfig()
// mocks should not affect different files

View File

@ -51,6 +51,15 @@ export interface BrowserConfigOptions {
* The default port is 63315.
*/
api?: ApiConfig | number
/**
* Update ESM imports so they can be spied/stubbed with vi.spyOn.
* Enabled by default when running in browser.
*
* @default true
* @experimental
*/
slowHijackESM?: boolean
}
export interface ResolvedBrowserOptions extends BrowserConfigOptions {

View File

@ -188,6 +188,7 @@ export interface InlineConfig {
/**
* Automatically assign environment based on globs. The first match will be used.
* This has effect only when running tests inside Node.js.
*
* Format: [glob, environment-name]
*
@ -209,13 +210,13 @@ export interface InlineConfig {
*
* @default []
* @example [
* // all tests in "browser" directory will run in an actual browser
* ['tests/browser/**', 'browser'],
* // all tests in "child_process" directory will run using "child_process" API
* ['tests/child_process/**', 'child_process'],
* // all other tests will run based on "threads" option, if you didn't specify other globs
* // ...
* ]
*/
poolMatchGlobs?: [string, VitestPool][]
poolMatchGlobs?: [string, Omit<VitestPool, 'browser'>][]
/**
* Update snapshot

760
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@ await test('tests are actually running', async () => {
const browserResult = await readFile('./browser.json', 'utf-8')
const browserResultJson = JSON.parse(browserResult)
assert.ok(browserResultJson.testResults.length === 6, 'Not all the tests have been run')
assert.ok(browserResultJson.testResults.length === 7, 'Not all the tests have been run')
for (const result of browserResultJson.testResults)
assert.ok(result.status === 'passed', `${result.name} has failed`)

View File

@ -0,0 +1,3 @@
export function plus(a: number, b: number) {
return a + b
}

View File

@ -0,0 +1,8 @@
import { plus } from './actions'
export function calculator(operation: 'plus', a: number, b: number) {
if (operation === 'plus')
return plus(a, b)
throw new Error('unknown operation')
}

View File

@ -0,0 +1,30 @@
import { expect, test, vi } from 'vitest'
import * as actions from '../src/actions'
import { calculator } from '../src/calculator'
import * as calculatorModule from '../src/calculator'
test('spyOn works on ESM', () => {
vi.spyOn(actions, 'plus').mockReturnValue(30)
expect(calculator('plus', 1, 2)).toBe(30)
vi.mocked(actions.plus).mockRestore()
expect(calculator('plus', 1, 2)).toBe(3)
})
test('has module name', () => {
expect(String(actions)).toBe('[object Module]')
expect(actions[Symbol.toStringTag]).toBe('Module')
})
test('exports are correct', () => {
expect(Object.keys(actions)).toEqual(['plus'])
expect(Object.keys(calculatorModule)).toEqual(['calculator'])
expect(calculatorModule.calculator).toBe(calculator)
})
test('imports are still the same', async () => {
// @ts-expect-error typescript resolution
await expect(import('../src/calculator')).resolves.toBe(calculatorModule)
// @ts-expect-error typescript resolution
// eslint-disable-next-line @typescript-eslint/quotes
await expect(import(`../src/calculator`)).resolves.toBe(calculatorModule)
})

View File

@ -9,6 +9,7 @@
"@vitest/expect": "workspace:*",
"@vitest/runner": "workspace:*",
"@vitest/utils": "workspace:*",
"acorn": "^8.8.2",
"tinyspy": "^1.0.2",
"url": "^0.11.0",
"vitest": "workspace:*"

View File

@ -0,0 +1,927 @@
import { Parser } from 'acorn'
import { injectVitestModule } from '@vitest/browser/src/node/esmInjector'
import { expect, test } from 'vitest'
import { transformWithEsbuild } from 'vite'
function parse(code: string, options: any) {
return Parser.parse(code, options)
}
function injectSimpleCode(code: string) {
return injectVitestModule(code, '/test.js', parse, {
cacheDir: '/tmp',
})?.code
}
test('default import', async () => {
expect(
injectSimpleCode('import foo from \'vue\';console.log(foo.bar)'),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
console.log(__vi_esm_0__.default.bar)"
`)
})
test('named import', async () => {
expect(
injectSimpleCode(
'import { ref } from \'vue\';function foo() { return ref(0) }',
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
function foo() { return __vi_esm_0__.ref(0) }"
`)
})
test('namespace import', async () => {
expect(
injectSimpleCode(
'import * as vue from \'vue\';function foo() { return vue.ref(0) }',
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
function foo() { return __vi_esm_0__.ref(0) }"
`)
})
test('export function declaration', async () => {
expect(injectSimpleCode('export function foo() {}'))
.toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
function foo() {}
Object.defineProperty(__vi_inject__, \\"foo\\", { enumerable: true, configurable: true, get(){ return foo }});
export { __vi_inject__ }"
`)
})
test('export class declaration', async () => {
expect(await injectSimpleCode('export class foo {}'))
.toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
class foo {}
Object.defineProperty(__vi_inject__, \\"foo\\", { enumerable: true, configurable: true, get(){ return foo }});
export { __vi_inject__ }"
`)
})
test('export var declaration', async () => {
expect(await injectSimpleCode('export const a = 1, b = 2'))
.toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
const a = 1, b = 2
Object.defineProperty(__vi_inject__, \\"a\\", { enumerable: true, configurable: true, get(){ return a }});
Object.defineProperty(__vi_inject__, \\"b\\", { enumerable: true, configurable: true, get(){ return b }});
export { __vi_inject__ }"
`)
})
test('export named', async () => {
expect(
injectSimpleCode('const a = 1, b = 2; export { a, b as c }'),
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
const a = 1, b = 2;
Object.defineProperty(__vi_inject__, \\"a\\", { enumerable: true, configurable: true, get(){ return a }});
Object.defineProperty(__vi_inject__, \\"c\\", { enumerable: true, configurable: true, get(){ return b }});
export { __vi_inject__ }"
`)
})
test('export named from', async () => {
expect(
injectSimpleCode('export { ref, computed as c } from \'vue\''),
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
const { __vi_inject__: __vi_esm_0__ } = await import(\\"vue\\");
Object.defineProperty(__vi_inject__, \\"ref\\", { enumerable: true, configurable: true, get(){ return __vi_esm_0__.ref }});
Object.defineProperty(__vi_inject__, \\"c\\", { enumerable: true, configurable: true, get(){ return __vi_esm_0__.computed }});
export { __vi_inject__ }"
`)
})
test('named exports of imported binding', async () => {
expect(
injectSimpleCode(
'import {createApp} from \'vue\';export {createApp}',
),
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
import { __vi_inject__ as __vi_esm_0__ } from 'vue'
Object.defineProperty(__vi_inject__, \\"createApp\\", { enumerable: true, configurable: true, get(){ return __vi_esm_0__.createApp }});
export { __vi_inject__ }"
`)
})
test('export * from', async () => {
expect(
injectSimpleCode(
'export * from \'vue\'\n' + 'export * from \'react\'',
),
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
const { __vi_inject__: __vi_esm_0__ } = await import(\\"vue\\");
__vi_export_all__(__vi_inject__, __vi_esm_0__);
const { __vi_inject__: __vi_esm_1__ } = await import(\\"react\\");
__vi_export_all__(__vi_inject__, __vi_esm_1__);
export { __vi_inject__ }"
`)
})
test('export * as from', async () => {
expect(injectSimpleCode('export * as foo from \'vue\''))
.toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
const { __vi_inject__: __vi_esm_0__ } = await import(\\"vue\\");
Object.defineProperty(__vi_inject__, \\"foo\\", { enumerable: true, configurable: true, get(){ return __vi_esm_0__ }});
export { __vi_inject__ }"
`)
})
test('export default', async () => {
expect(
injectSimpleCode('export default {}'),
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
__vi_inject__.default = {}
export { __vi_inject__ }"
`)
})
test('export then import minified', async () => {
expect(
injectSimpleCode(
'export * from \'vue\';import {createApp} from \'vue\';',
),
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
import { __vi_inject__ as __vi_esm_0__ } from 'vue'
const { __vi_inject__: __vi_esm_1__ } = await import(\\"vue\\");
__vi_export_all__(__vi_inject__, __vi_esm_1__);
export { __vi_inject__ }"
`)
})
test('hoist import to top', async () => {
expect(
injectSimpleCode(
'path.resolve(\'server.js\');import path from \'node:path\';',
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'node:path'
__vi_esm_0__.default.resolve('server.js');"
`)
})
// test('import.meta', async () => {
// expect(
// injectSimpleCode('console.log(import.meta.url)'),
// ).toMatchInlineSnapshot('"console.log(__vite_ssr_import_meta__.url)"')
// })
test('dynamic import', async () => {
const result = injectSimpleCode(
'export const i = () => import(\'./foo\')',
)
expect(result).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
const i = () => __vi_wrap_module__(import('./foo'))
export { __vi_inject__ }"
`)
})
test('do not rewrite method definition', async () => {
const result = injectSimpleCode(
'import { fn } from \'vue\';class A { fn() { fn() } }',
)
expect(result).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
class A { fn() { __vi_esm_0__.fn() } }"
`)
})
test('do not rewrite when variable is in scope', async () => {
const result = injectSimpleCode(
'import { fn } from \'vue\';function A(){ const fn = () => {}; return { fn }; }',
)
expect(result).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
function A(){ const fn = () => {}; return { fn }; }"
`)
})
// #5472
test('do not rewrite when variable is in scope with object destructuring', async () => {
const result = injectSimpleCode(
'import { fn } from \'vue\';function A(){ let {fn, test} = {fn: \'foo\', test: \'bar\'}; return { fn }; }',
)
expect(result).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
function A(){ let {fn, test} = {fn: 'foo', test: 'bar'}; return { fn }; }"
`)
})
// #5472
test('do not rewrite when variable is in scope with array destructuring', async () => {
const result = injectSimpleCode(
'import { fn } from \'vue\';function A(){ let [fn, test] = [\'foo\', \'bar\']; return { fn }; }',
)
expect(result).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
function A(){ let [fn, test] = ['foo', 'bar']; return { fn }; }"
`)
})
// #5727
test('rewrite variable in string interpolation in function nested arguments', async () => {
const result = injectSimpleCode(
// eslint-disable-next-line no-template-curly-in-string
'import { fn } from \'vue\';function A({foo = `test${fn}`} = {}){ return {}; }',
)
expect(result).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
function A({foo = \`test\${__vi_esm_0__.fn}\`} = {}){ return {}; }"
`)
})
// #6520
test('rewrite variables in default value of destructuring params', async () => {
const result = injectSimpleCode(
'import { fn } from \'vue\';function A({foo = fn}){ return {}; }',
)
expect(result).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
function A({foo = __vi_esm_0__.fn}){ return {}; }"
`)
})
test('do not rewrite when function declaration is in scope', async () => {
const result = injectSimpleCode(
'import { fn } from \'vue\';function A(){ function fn() {}; return { fn }; }',
)
expect(result).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
function A(){ function fn() {}; return { fn }; }"
`)
})
test('do not rewrite catch clause', async () => {
const result = injectSimpleCode(
'import {error} from \'./dependency\';try {} catch(error) {}',
)
expect(result).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from './dependency'
try {} catch(error) {}"
`)
})
// #2221
test('should declare variable for imported super class', async () => {
expect(
injectSimpleCode(
'import { Foo } from \'./dependency\';' + 'class A extends Foo {}',
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from './dependency'
const Foo = __vi_esm_0__.Foo;
class A extends Foo {}"
`)
// exported classes: should prepend the declaration at root level, before the
// first class that uses the binding
expect(
injectSimpleCode(
'import { Foo } from \'./dependency\';'
+ 'export default class A extends Foo {}\n'
+ 'export class B extends Foo {}',
),
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
import { __vi_inject__ as __vi_esm_0__ } from './dependency'
const Foo = __vi_esm_0__.Foo;
class A extends Foo {}
class B extends Foo {}
Object.defineProperty(__vi_inject__, \\"B\\", { enumerable: true, configurable: true, get(){ return B }});
Object.defineProperty(__vi_inject__, \\"default\\", { enumerable: true, configurable: true, value: A });
export { __vi_inject__ }"
`)
})
// #4049
test('should handle default export variants', async () => {
// default anonymous functions
expect(injectSimpleCode('export default function() {}\n'))
.toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
__vi_inject__.default = function() {}
export { __vi_inject__ }"
`)
// default anonymous class
expect(injectSimpleCode('export default class {}\n'))
.toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
__vi_inject__.default = class {}
export { __vi_inject__ }"
`)
// default named functions
expect(
injectSimpleCode(
'export default function foo() {}\n'
+ 'foo.prototype = Object.prototype;',
),
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
function foo() {}
foo.prototype = Object.prototype;
Object.defineProperty(__vi_inject__, \\"default\\", { enumerable: true, configurable: true, value: foo });
export { __vi_inject__ }"
`)
// default named classes
expect(
injectSimpleCode(
'export default class A {}\n' + 'export class B extends A {}',
),
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
class A {}
class B extends A {}
Object.defineProperty(__vi_inject__, \\"B\\", { enumerable: true, configurable: true, get(){ return B }});
Object.defineProperty(__vi_inject__, \\"default\\", { enumerable: true, configurable: true, value: A });
export { __vi_inject__ }"
`)
})
test('overwrite bindings', async () => {
expect(
injectSimpleCode(
'import { inject } from \'vue\';'
+ 'const a = { inject }\n'
+ 'const b = { test: inject }\n'
+ 'function c() { const { test: inject } = { test: true }; console.log(inject) }\n'
+ 'const d = inject\n'
+ 'function f() { console.log(inject) }\n'
+ 'function e() { const { inject } = { inject: true } }\n'
+ 'function g() { const f = () => { const inject = true }; console.log(inject) }\n',
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
const a = { inject: __vi_esm_0__.inject }
const b = { test: __vi_esm_0__.inject }
function c() { const { test: inject } = { test: true }; console.log(inject) }
const d = __vi_esm_0__.inject
function f() { console.log(__vi_esm_0__.inject) }
function e() { const { inject } = { inject: true } }
function g() { const f = () => { const inject = true }; console.log(__vi_esm_0__.inject) }
"
`)
})
test('Empty array pattern', async () => {
expect(
injectSimpleCode('const [, LHS, RHS] = inMatch;'),
).toMatchInlineSnapshot('"const [, LHS, RHS] = inMatch;"')
})
test('function argument destructure', async () => {
expect(
injectSimpleCode(
`
import { foo, bar } from 'foo'
const a = ({ _ = foo() }) => {}
function b({ _ = bar() }) {}
function c({ _ = bar() + foo() }) {}
`,
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'foo'
const a = ({ _ = __vi_esm_0__.foo() }) => {}
function b({ _ = __vi_esm_0__.bar() }) {}
function c({ _ = __vi_esm_0__.bar() + __vi_esm_0__.foo() }) {}
"
`)
})
test('object destructure alias', async () => {
expect(
injectSimpleCode(
`
import { n } from 'foo'
const a = () => {
const { type: n = 'bar' } = {}
console.log(n)
}
`,
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'foo'
const a = () => {
const { type: n = 'bar' } = {}
console.log(n)
}
"
`)
// #9585
expect(
injectSimpleCode(
`
import { n, m } from 'foo'
const foo = {}
{
const { [n]: m } = foo
}
`,
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'foo'
const foo = {}
{
const { [__vi_esm_0__.n]: m } = foo
}
"
`)
})
test('nested object destructure alias', async () => {
expect(
injectSimpleCode(
`
import { remove, add, get, set, rest, objRest } from 'vue'
function a() {
const {
o: { remove },
a: { b: { c: [ add ] }},
d: [{ get }, set, ...rest],
...objRest
} = foo
remove()
add()
get()
set()
rest()
objRest()
}
remove()
add()
get()
set()
rest()
objRest()
`,
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
function a() {
const {
o: { remove },
a: { b: { c: [ add ] }},
d: [{ get }, set, ...rest],
...objRest
} = foo
remove()
add()
get()
set()
rest()
objRest()
}
__vi_esm_0__.remove()
__vi_esm_0__.add()
__vi_esm_0__.get()
__vi_esm_0__.set()
__vi_esm_0__.rest()
__vi_esm_0__.objRest()
"
`)
})
test('object props and methods', async () => {
expect(
injectSimpleCode(
`
import foo from 'foo'
const bar = 'bar'
const obj = {
foo() {},
[foo]() {},
[bar]() {},
foo: () => {},
[foo]: () => {},
[bar]: () => {},
bar(foo) {}
}
`,
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'foo'
const bar = 'bar'
const obj = {
foo() {},
[__vi_esm_0__.default]() {},
[bar]() {},
foo: () => {},
[__vi_esm_0__.default]: () => {},
[bar]: () => {},
bar(foo) {}
}
"
`)
})
test('class props', async () => {
expect(
injectSimpleCode(
`
import { remove, add } from 'vue'
class A {
remove = 1
add = null
}
`,
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
const add = __vi_esm_0__.add;
const remove = __vi_esm_0__.remove;
class A {
remove = 1
add = null
}
"
`)
})
test('class methods', async () => {
expect(
injectSimpleCode(
`
import foo from 'foo'
const bar = 'bar'
class A {
foo() {}
[foo]() {}
[bar]() {}
#foo() {}
bar(foo) {}
}
`,
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'foo'
const bar = 'bar'
class A {
foo() {}
[__vi_esm_0__.default]() {}
[bar]() {}
#foo() {}
bar(foo) {}
}
"
`)
})
test('declare scope', async () => {
expect(
injectSimpleCode(
`
import { aaa, bbb, ccc, ddd } from 'vue'
function foobar() {
ddd()
const aaa = () => {
bbb(ccc)
ddd()
}
const bbb = () => {
console.log('hi')
}
const ccc = 1
function ddd() {}
aaa()
bbb()
ccc()
}
aaa()
bbb()
`,
),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'vue'
function foobar() {
ddd()
const aaa = () => {
bbb(ccc)
ddd()
}
const bbb = () => {
console.log('hi')
}
const ccc = 1
function ddd() {}
aaa()
bbb()
ccc()
}
__vi_esm_0__.aaa()
__vi_esm_0__.bbb()
"
`)
})
test('jsx', async () => {
const code = `
import React from 'react'
import { Foo, Slot } from 'foo'
function Bar({ Slot = <Foo /> }) {
return (
<>
<Slot />
</>
)
}
`
const id = '/foo.jsx'
const result = await transformWithEsbuild(code, id)
expect(injectSimpleCode(result.code))
.toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'react'
import { __vi_inject__ as __vi_esm_1__ } from 'foo'
function Bar({ Slot: Slot2 = /* @__PURE__ */ __vi_esm_0__.default.createElement(__vi_esm_1__.Foo, null) }) {
return /* @__PURE__ */ __vi_esm_0__.default.createElement(__vi_esm_0__.default.Fragment, null, /* @__PURE__ */ __vi_esm_0__.default.createElement(Slot2, null));
}
"
`)
})
test('continuous exports', async () => {
expect(
injectSimpleCode(
`
export function fn1() {
}export function fn2() {
}
`,
),
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
function fn1() {
}
Object.defineProperty(__vi_inject__, \\"fn1\\", { enumerable: true, configurable: true, get(){ return fn1 }});function fn2() {
}
Object.defineProperty(__vi_inject__, \\"fn2\\", { enumerable: true, configurable: true, get(){ return fn2 }});
export { __vi_inject__ }"
`)
})
// https://github.com/vitest-dev/vitest/issues/1141
test('export default expression', async () => {
// esbuild transform result of following TS code
// export default <MyFn> function getRandom() {
// return Math.random()
// }
const code = `
export default (function getRandom() {
return Math.random();
});
`.trim()
expect(injectSimpleCode(code)).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
__vi_inject__.default = (function getRandom() {
return Math.random();
});
export { __vi_inject__ }"
`)
expect(
injectSimpleCode('export default (class A {});'),
).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
__vi_inject__.default = (class A {});
export { __vi_inject__ }"
`)
})
test('track scope in for loops', async () => {
expect(
injectSimpleCode(`
import { test } from './test.js'
for (const test of tests) {
console.log(test)
}
for (let test = 0; test < 10; test++) {
console.log(test)
}
for (const test in tests) {
console.log(test)
}`),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from './test.js'
for (const test of tests) {
console.log(test)
}
for (let test = 0; test < 10; test++) {
console.log(test)
}
for (const test in tests) {
console.log(test)
}"
`)
})
// #8002
// test('with hashbang', async () => {
// expect(
// injectSimpleCode(
// `#!/usr/bin/env node
// console.log("it can parse the hashbang")`,
// ),
// ).toMatchInlineSnapshot(`
// "#!/usr/bin/env node
// console.log(\\"it can parse the hashbang\\")"
// `)
// })
// test('import hoisted after hashbang', async () => {
// expect(
// await injectSimpleCode(
// `#!/usr/bin/env node
// import "foo"`,
// ),
// ).toMatchInlineSnapshot(`
// "#!/usr/bin/env node
// const __vite_ssr_import_0__ = await __vite_ssr_import__(\\"foo\\");
// "
// `)
// })
// #10289
test('track scope by class, function, condition blocks', async () => {
const code = `
import { foo, bar } from 'foobar'
if (false) {
const foo = 'foo'
console.log(foo)
} else if (false) {
const [bar] = ['bar']
console.log(bar)
} else {
console.log(foo)
console.log(bar)
}
export class Test {
constructor() {
if (false) {
const foo = 'foo'
console.log(foo)
} else if (false) {
const [bar] = ['bar']
console.log(bar)
} else {
console.log(foo)
console.log(bar)
}
}
};`.trim()
expect(injectSimpleCode(code)).toMatchInlineSnapshot(`
"const __vi_inject__ = { [Symbol.toStringTag]: \\"Module\\" };
import { __vi_inject__ as __vi_esm_0__ } from 'foobar'
if (false) {
const foo = 'foo'
console.log(foo)
} else if (false) {
const [bar] = ['bar']
console.log(bar)
} else {
console.log(__vi_esm_0__.foo)
console.log(__vi_esm_0__.bar)
}
class Test {
constructor() {
if (false) {
const foo = 'foo'
console.log(foo)
} else if (false) {
const [bar] = ['bar']
console.log(bar)
} else {
console.log(__vi_esm_0__.foo)
console.log(__vi_esm_0__.bar)
}
}
}
Object.defineProperty(__vi_inject__, \\"Test\\", { enumerable: true, configurable: true, get(){ return Test }});;
export { __vi_inject__ }"
`)
})
// #10386
test('track var scope by function', async () => {
expect(
injectSimpleCode(`
import { foo, bar } from 'foobar'
function test() {
if (true) {
var foo = () => { var why = 'would' }, bar = 'someone'
}
return [foo, bar]
}`),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'foobar'
function test() {
if (true) {
var foo = () => { var why = 'would' }, bar = 'someone'
}
return [foo, bar]
}"
`)
})
// #11806
test('track scope by blocks', async () => {
expect(
injectSimpleCode(`
import { foo, bar, baz } from 'foobar'
function test() {
[foo];
{
let foo = 10;
let bar = 10;
}
try {} catch (baz){ baz };
return bar;
}`),
).toMatchInlineSnapshot(`
"import { __vi_inject__ as __vi_esm_0__ } from 'foobar'
function test() {
[__vi_esm_0__.foo];
{
let foo = 10;
let bar = 10;
}
try {} catch (baz){ baz };
return __vi_esm_0__.bar;
}"
`)
})

View File

@ -0,0 +1,60 @@
import { Parser } from 'acorn'
import { hoistMocks } from 'vitest/src/node/hoistMocks'
import { expect, test } from 'vitest'
function parse(code: string, options: any) {
return Parser.parse(code, options)
}
function hoistSimpleCode(code: string) {
return hoistMocks(code, '/test.js', parse)?.code.trim()
}
test('hoists mock, unmock, hoisted', () => {
expect(hoistSimpleCode(`
vi.mock('path', () => {})
vi.unmock('path')
vi.hoisted(() => {})
`)).toMatchInlineSnapshot(`
"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\\") }
vi.mock('path', () => {})
vi.unmock('path')
vi.hoisted(() => {})"
`)
})
test('always hoists import from vitest', () => {
expect(hoistSimpleCode(`
import { vi } from 'vitest'
vi.mock('path', () => {})
vi.unmock('path')
vi.hoisted(() => {})
import { test } from 'vitest'
`)).toMatchInlineSnapshot(`
"const { vi } = await import('vitest')
const { test } = await import('vitest')
vi.mock('path', () => {})
vi.unmock('path')
vi.hoisted(() => {})"
`)
})
test('always hoists all imports but they are under mocks', () => {
expect(hoistSimpleCode(`
import { vi } from 'vitest'
import { someValue } from './path.js'
import { someValue2 } from './path2.js'
vi.mock('path', () => {})
vi.unmock('path')
vi.hoisted(() => {})
import { test } from 'vitest'
`)).toMatchInlineSnapshot(`
"const { vi } = await import('vitest')
const { test } = await import('vitest')
vi.mock('path', () => {})
vi.unmock('path')
vi.hoisted(() => {})
const { someValue } = await import('./path.js')
const { someValue2 } = await import('./path2.js')"
`)
})