Skip to content

Commit

Permalink
fix(vitest): throw a syntax error if vi.hoisted is directly exported (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
sheremet-va committed Jan 17, 2024
1 parent ba7ae53 commit f8bff9e
Show file tree
Hide file tree
Showing 5 changed files with 209 additions and 91 deletions.
2 changes: 1 addition & 1 deletion packages/vitest/package.json
Expand Up @@ -146,7 +146,7 @@
"@vitest/snapshot": "workspace:*",
"@vitest/spy": "workspace:*",
"@vitest/utils": "workspace:*",
"acorn-walk": "^8.3.1",
"acorn-walk": "^8.3.2",
"cac": "^6.7.14",
"chai": "^4.3.10",
"debug": "^4.3.4",
Expand Down
37 changes: 25 additions & 12 deletions packages/vitest/src/node/hoistMocks.ts
@@ -1,7 +1,5 @@
import MagicString from 'magic-string'
import type { AwaitExpression, CallExpression, Identifier, ImportDeclaration, VariableDeclaration, Node as _Node } from 'estree'

// TODO: should use findNodeBefore, but it's not typed
import type { AwaitExpression, CallExpression, ExportDefaultDeclaration, ExportNamedDeclaration, Identifier, ImportDeclaration, VariableDeclaration, Node as _Node } from 'estree'
import { findNodeAround } from 'acorn-walk'
import type { PluginContext } from 'rollup'
import { esmWalker } from '@vitest/utils/ast'
Expand Down Expand Up @@ -153,6 +151,17 @@ export function hoistMocks(code: string, id: string, parse: PluginContext['parse
const declaredConst = new Set<string>()
const hoistedNodes: Positioned<CallExpression | VariableDeclaration | AwaitExpression>[] = []

function createSyntaxError(node: Positioned<Node>, message: string) {
const _error = new SyntaxError(message)
Error.captureStackTrace(_error, createSyntaxError)
return {
name: 'SyntaxError',
message: _error.message,
stack: _error.stack,
frame: generateCodeFrame(highlightCode(id, code, colors), 4, node.start + 1),
}
}

esmWalker(ast, {
onIdentifier(id, info, parentStack) {
const binding = idToImportMap.get(id.name)
Expand Down Expand Up @@ -192,6 +201,11 @@ export function hoistMocks(code: string, id: string, parse: PluginContext['parse
hoistedNodes.push(node)

if (methodName === 'hoisted') {
// check it's not a default export
const defaultExport = findNodeAround(ast, node.start, 'ExportDefaultDeclaration')?.node as Positioned<ExportDefaultDeclaration> | undefined
if (defaultExport?.declaration === node || (defaultExport?.declaration.type === 'AwaitExpression' && defaultExport.declaration.argument === node))
throw createSyntaxError(defaultExport, 'Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.')

const declarationNode = findNodeAround(ast, node.start, 'VariableDeclaration')?.node as Positioned<VariableDeclaration> | undefined
const init = declarationNode?.declarations[0]?.init
const isViHoisted = (node: CallExpression) => {
Expand All @@ -211,6 +225,10 @@ export function hoistMocks(code: string, id: string, parse: PluginContext['parse
&& isViHoisted(init.argument)) /* const v = await vi.hoisted() */

if (canMoveDeclaration) {
// export const variable = vi.hoisted()
const nodeExported = findNodeAround(ast, declarationNode.start, 'ExportNamedDeclaration')?.node as Positioned<ExportNamedDeclaration> | undefined
if (nodeExported?.declaration === declarationNode)
throw createSyntaxError(nodeExported, 'Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.')
// hoist "const variable = vi.hoisted(() => {})"
hoistedNodes.push(declarationNode)
}
Expand Down Expand Up @@ -251,15 +269,10 @@ export function hoistMocks(code: string, id: string, parse: PluginContext['parse
function createError(outsideNode: Node, insideNode: Node) {
const outsideCall = getNodeCall(outsideNode)
const insideCall = getNodeCall(insideNode)
const _error = new SyntaxError(`Cannot call ${getNodeName(insideCall)} inside ${getNodeName(outsideCall)}: both methods are hoisted to the top of the file and not actually called inside each other.`)
// throw an object instead of an error so it can be serialized for RPC, TODO: improve error handling in rpc serializer
const error = {
name: 'SyntaxError',
message: _error.message,
stack: _error.stack,
frame: generateCodeFrame(highlightCode(id, code, colors), 4, insideCall.start + 1),
}
throw error
throw createSyntaxError(
insideCall,
`Cannot call ${getNodeName(insideCall)} inside ${getNodeName(outsideCall)}: both methods are hoisted to the top of the file and not actually called inside each other.`,
)
}

// validate hoistedNodes doesn't have nodes inside other nodes
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

148 changes: 96 additions & 52 deletions test/core/test/__snapshots__/injector-mock.test.ts.snap
@@ -1,78 +1,122 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`throws an error when nodes are incompatible > correctly throws an error 1`] = `"Cannot call vi.hoisted() inside vi.mock(): both methods are hoisted to the top of the file and not actually called inside each other."`;

exports[`throws an error when nodes are incompatible > correctly throws an error 2`] = `
" 3|
4| vi.mock('./mocked', () => {
5| const variable = vi.hoisted(() => 1)
| ^
6| console.log(variable)
7| })"
exports[`throws an error when nodes are incompatible > correctly throws an error if awaited assigned vi.hoisted is called inside vi.mock 1`] = `"Cannot call vi.hoisted() inside vi.mock(): both methods are hoisted to the top of the file and not actually called inside each other."`;

exports[`throws an error when nodes are incompatible > correctly throws an error if awaited assigned vi.hoisted is called inside vi.mock 2`] = `
" 2|
3| vi.mock('./mocked', async () => {
4| const variable = await vi.hoisted(() => 1)
| ^
5| })
6| "
`;

exports[`throws an error when nodes are incompatible > correctly throws an error 3`] = `"Cannot call vi.hoisted() inside vi.mock(): both methods are hoisted to the top of the file and not actually called inside each other."`;
exports[`throws an error when nodes are incompatible > correctly throws an error if awaited vi.hoisted is called inside vi.mock 1`] = `"Cannot call vi.hoisted() inside vi.mock(): both methods are hoisted to the top of the file and not actually called inside each other."`;

exports[`throws an error when nodes are incompatible > correctly throws an error 4`] = `
" 3|
4| vi.mock('./mocked', async () => {
5| await vi.hoisted(() => 1)
exports[`throws an error when nodes are incompatible > correctly throws an error if awaited vi.hoisted is called inside vi.mock 2`] = `
" 2|
3| vi.mock('./mocked', async () => {
4| await vi.hoisted(() => 1)
| ^
6| })
7| "
5| })
6| "
`;

exports[`throws an error when nodes are incompatible > correctly throws an error 5`] = `"Cannot call vi.hoisted() inside vi.mock(): both methods are hoisted to the top of the file and not actually called inside each other."`;
exports[`throws an error when nodes are incompatible > correctly throws an error if awaited vi.hoisted is exported as default export 1`] = `"Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first."`;

exports[`throws an error when nodes are incompatible > correctly throws an error 6`] = `
" 3|
4| vi.mock('./mocked', async () => {
5| const variable = await vi.hoisted(() => 1)
| ^
6| })
7| "
exports[`throws an error when nodes are incompatible > correctly throws an error if awaited vi.hoisted is exported as default export 2`] = `
" 1| import { vi } from 'vitest'
2|
3| export default await vi.hoisted(async () => {
| ^
4| return {}
5| })"
`;

exports[`throws an error when nodes are incompatible > correctly throws an error if awaited vi.hoisted is exported as named export 1`] = `"Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first."`;

exports[`throws an error when nodes are incompatible > correctly throws an error if awaited vi.hoisted is exported as named export 2`] = `
" 1| import { vi } from 'vitest'
2|
3| export const values = await vi.hoisted(async () => {
| ^
4| return {}
5| })"
`;

exports[`throws an error when nodes are incompatible > correctly throws an error if vi.hoisted is called inside vi.mock 1`] = `"Cannot call vi.hoisted() inside vi.mock(): both methods are hoisted to the top of the file and not actually called inside each other."`;

exports[`throws an error when nodes are incompatible > correctly throws an error if vi.hoisted is called inside vi.mock 2`] = `
" 2|
3| vi.mock('./mocked', () => {
4| const variable = vi.hoisted(() => 1)
| ^
5| console.log(variable)
6| })"
`;

exports[`throws an error when nodes are incompatible > correctly throws an error if vi.hoisted is exported as a named export 1`] = `"Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first."`;

exports[`throws an error when nodes are incompatible > correctly throws an error if vi.hoisted is exported as a named export 2`] = `
" 1| import { vi } from 'vitest'
2|
3| export const values = vi.hoisted(async () => {
| ^
4| return {}
5| })"
`;

exports[`throws an error when nodes are incompatible > correctly throws an error if vi.hoisted is exported as default 1`] = `"Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first."`;

exports[`throws an error when nodes are incompatible > correctly throws an error if vi.hoisted is exported as default 2`] = `
" 1| import { vi } from 'vitest'
2|
3| export default vi.hoisted(() => {
| ^
4| return {}
5| })"
`;

exports[`throws an error when nodes are incompatible > correctly throws an error 7`] = `"Cannot call vi.mock() inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock inside vi.hoisted 1`] = `"Cannot call vi.mock() inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;

exports[`throws an error when nodes are incompatible > correctly throws an error 8`] = `
" 3|
4| vi.hoisted(() => {
5| vi.mock('./mocked')
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock inside vi.hoisted 2`] = `
" 2|
3| vi.hoisted(() => {
4| vi.mock('./mocked')
| ^
6| })
7| "
5| })
6| "
`;

exports[`throws an error when nodes are incompatible > correctly throws an error 9`] = `"Cannot call vi.mock() inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside assigned awaited vi.hoisted 1`] = `"Cannot call vi.mock() inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;

exports[`throws an error when nodes are incompatible > correctly throws an error 10`] = `
" 3|
4| const values = vi.hoisted(() => {
5| vi.mock('./mocked')
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside assigned awaited vi.hoisted 2`] = `
" 2|
3| const values = await vi.hoisted(async () => {
4| vi.mock('./mocked')
| ^
6| })
7| "
5| })
6| "
`;

exports[`throws an error when nodes are incompatible > correctly throws an error 11`] = `"Cannot call vi.mock() inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside assigned vi.hoisted 1`] = `"Cannot call vi.mock() inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;

exports[`throws an error when nodes are incompatible > correctly throws an error 12`] = `
" 3|
4| await vi.hoisted(async () => {
5| vi.mock('./mocked')
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside assigned vi.hoisted 2`] = `
" 2|
3| const values = vi.hoisted(() => {
4| vi.mock('./mocked')
| ^
6| })
7| "
5| })
6| "
`;

exports[`throws an error when nodes are incompatible > correctly throws an error 13`] = `"Cannot call vi.mock() inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside awaited vi.hoisted 1`] = `"Cannot call vi.mock() inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;

exports[`throws an error when nodes are incompatible > correctly throws an error 14`] = `
" 3|
4| const values = await vi.hoisted(async () => {
5| vi.mock('./mocked')
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside awaited vi.hoisted 2`] = `
" 2|
3| await vi.hoisted(async () => {
4| vi.mock('./mocked')
| ^
6| })
7| "
5| })
6| "
`;

0 comments on commit f8bff9e

Please sign in to comment.