Skip to content

Commit

Permalink
chore: refactor mock hoisting and esm injector into two separate func…
Browse files Browse the repository at this point in the history
…tions
  • Loading branch information
sheremet-va committed Apr 27, 2023
1 parent d8e4a5b commit 48f7e1a
Show file tree
Hide file tree
Showing 19 changed files with 358 additions and 330 deletions.
30 changes: 17 additions & 13 deletions docs/config/index.md
Expand Up @@ -954,7 +954,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`
Expand Down Expand Up @@ -1026,6 +1026,22 @@ 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


#### 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`
Expand Down Expand Up @@ -1375,15 +1391,3 @@ Influences whether or not the `showDiff` flag should be included in the thrown A
Sets length threshold for actual and expected values in assertion errors. If this threshold is exceeded, for example for large data structures, the value is replaced with something like `[ Array(3) ]` or `{ Object (prop1, prop2) }`. Set it to `0` if you want to disable truncating altogether.

This config option affects truncating values in `test.each` titles and inside the assertion error message.

#### slowHijackESM

- **Type:** `boolean`
- **Default:** `false`
- **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 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.
5 changes: 4 additions & 1 deletion packages/browser/package.json
Expand Up @@ -39,17 +39,20 @@
"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:*"
}
Expand Down
@@ -1,57 +1,14 @@
import MagicString from 'magic-string'
import { extract_names as extractNames } from 'periscopic'
import type { CallExpression, Expression, Identifier, ImportDeclaration, VariableDeclaration } from 'estree'
import { findNodeAround, simple as simpleWalk } from 'acorn-walk'
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 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, mode: 'object' | 'named' = 'object') {
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 ${mode === 'object' ? ':' : 'as'} ${specifier.local.name}`

if (specifier.type === 'ImportSpecifier') {
const local = specifier.local.name
const imported = specifier.imported.name
if (local === imported)
return local
return `${imported} ${mode === 'object' ? ':' : 'as'} ${local}`
}

return null
}).filter(Boolean).join(', ')

if (!dynamicImports.length)
return ''

return `{ ${dynamicImports} }`
}

const viInjectedKey = '__vi_inject__'
// const viImportMetaKey = '__vi_import_meta__' // to allow overwrite
const viExportAllHelper = '__vi_export_all__'

const regexpHoistable = /^[ \t]*\b(vi|vitest)\s*\.\s*(mock|unmock|hoisted)\(/m

const skipHijack = [
'/@vite/client',
'/@vite/env',
Expand All @@ -72,10 +29,9 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin
if (skipHijack.some(skip => id.match(skip)))
return

const hasMocks = regexpHoistable.test(code)
const hijackEsm = options.hijackESM ?? false

if (!hasMocks && !hijackEsm)
if (!hijackEsm)
return

const s = new MagicString(code)
Expand All @@ -100,8 +56,6 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin
const hoistIndex = 0

let hasInjected = false
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
Expand All @@ -110,27 +64,11 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin
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
if (!hijackEsm || skipHijack.some(skip => source.match(skip))) {
const specifiers = transformImportSpecifiers(node)
const code = specifiers
? `const ${specifiers} = await import('${source}')\n`
: `await import('${source}')\n`
return { code }
}
if (skipHijack.some(skip => source.match(skip)))
return null

const importId = `__vi_esm_${uid++}__`
const hasSpecifiers = node.specifiers.length > 0
if (hasMocks) {
const code = hasSpecifiers
? `const { ${viInjectedKey}: ${importId} } = await __vi_wrap_module__(import('${source}'))\n`
: `await __vi_wrap_module__(import('${source}'))\n`
return {
code,
id: importId,
}
}
const code = hasSpecifiers
? `import { ${viInjectedKey} as ${importId} } from '${source}'\n`
: `import '${source}'\n`
Expand All @@ -141,19 +79,11 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin
}

function defineImport(node: ImportDeclaration) {
// always hoist vitest import to top of the file, so
// "vi" helpers can access it
if (node.source.value === 'vitest') {
const importId = `__vi_esm_${uid++}__`
const code = hijackEsm
? `import { ${viInjectedKey} as ${importId} } from 'vitest'\nconst ${transformImportSpecifiers(node)} = ${importId};\n`
: `import ${transformImportSpecifiers(node, 'named')} from 'vitest'\n`
hoistedVitestImports += code
return
}
const { code, id } = transformImportDeclaration(node)
s.appendLeft(hoistIndex, code)
return id
const declaration = transformImportDeclaration(node)
if (!declaration)
return null
s.appendLeft(hoistIndex, declaration.code)
return declaration.id
}

function defineImportAll(source: string) {
Expand All @@ -178,9 +108,9 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin
// import * as ok from 'foo' --> ok -> __import_foo__
if (node.type === 'ImportDeclaration') {
const importId = defineImport(node)
s.remove(node.start, node.end)
if (!hijackEsm || !importId)
if (!importId)
continue
s.remove(node.start, node.end)
for (const spec of node.specifiers) {
if (spec.type === 'ImportSpecifier') {
idToImportMap.set(
Expand All @@ -201,9 +131,6 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin

// 2. check all export statements and define exports
for (const node of ast.body as Node[]) {
if (!hijackEsm)
break

// named exports
if (node.type === 'ExportNamedDeclaration') {
if (node.declaration) {
Expand Down Expand Up @@ -298,115 +225,50 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin
}
}

function CallExpression(node: 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 we don't need to inject anything, skip the walking
if (hijackEsm) {
// 3. convert references to import bindings & import.meta references
esmWalker(ast, {
onCallExpression: CallExpression,
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)
// 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'
)
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)
) {
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`)
}
},
// 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, '))')
},
})
}
// we still need to hoist "vi" helper
else {
simpleWalk(ast, {
CallExpression: CallExpression as any,
})
}

if (hoistedCode || hoistedVitestImports) {
s.prepend(
hoistedVitestImports
+ ((!hoistedVitestImports && hoistedCode) ? API_NOT_FOUND_CHECK : '')
+ hoistedCode,
)
}
}
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
Expand Down

0 comments on commit 48f7e1a

Please sign in to comment.