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 8166d02 commit 4d674b9
Show file tree
Hide file tree
Showing 19 changed files with 358 additions and 318 deletions.
18 changes: 17 additions & 1 deletion docs/config/index.md
Original file line number Diff line number Diff line change
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
5 changes: 4 additions & 1 deletion packages/browser/package.json
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 4d674b9

Please sign in to comment.