Skip to content

Commit f8bff9e

Browse files
authored
fix(vitest): throw a syntax error if vi.hoisted is directly exported (#4969)
1 parent ba7ae53 commit f8bff9e

File tree

5 files changed

+209
-91
lines changed

5 files changed

+209
-91
lines changed

packages/vitest/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@
146146
"@vitest/snapshot": "workspace:*",
147147
"@vitest/spy": "workspace:*",
148148
"@vitest/utils": "workspace:*",
149-
"acorn-walk": "^8.3.1",
149+
"acorn-walk": "^8.3.2",
150150
"cac": "^6.7.14",
151151
"chai": "^4.3.10",
152152
"debug": "^4.3.4",

packages/vitest/src/node/hoistMocks.ts

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
11
import MagicString from 'magic-string'
2-
import type { AwaitExpression, CallExpression, Identifier, ImportDeclaration, VariableDeclaration, Node as _Node } from 'estree'
3-
4-
// TODO: should use findNodeBefore, but it's not typed
2+
import type { AwaitExpression, CallExpression, ExportDefaultDeclaration, ExportNamedDeclaration, Identifier, ImportDeclaration, VariableDeclaration, Node as _Node } from 'estree'
53
import { findNodeAround } from 'acorn-walk'
64
import type { PluginContext } from 'rollup'
75
import { esmWalker } from '@vitest/utils/ast'
@@ -153,6 +151,17 @@ export function hoistMocks(code: string, id: string, parse: PluginContext['parse
153151
const declaredConst = new Set<string>()
154152
const hoistedNodes: Positioned<CallExpression | VariableDeclaration | AwaitExpression>[] = []
155153

154+
function createSyntaxError(node: Positioned<Node>, message: string) {
155+
const _error = new SyntaxError(message)
156+
Error.captureStackTrace(_error, createSyntaxError)
157+
return {
158+
name: 'SyntaxError',
159+
message: _error.message,
160+
stack: _error.stack,
161+
frame: generateCodeFrame(highlightCode(id, code, colors), 4, node.start + 1),
162+
}
163+
}
164+
156165
esmWalker(ast, {
157166
onIdentifier(id, info, parentStack) {
158167
const binding = idToImportMap.get(id.name)
@@ -192,6 +201,11 @@ export function hoistMocks(code: string, id: string, parse: PluginContext['parse
192201
hoistedNodes.push(node)
193202

194203
if (methodName === 'hoisted') {
204+
// check it's not a default export
205+
const defaultExport = findNodeAround(ast, node.start, 'ExportDefaultDeclaration')?.node as Positioned<ExportDefaultDeclaration> | undefined
206+
if (defaultExport?.declaration === node || (defaultExport?.declaration.type === 'AwaitExpression' && defaultExport.declaration.argument === node))
207+
throw createSyntaxError(defaultExport, 'Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.')
208+
195209
const declarationNode = findNodeAround(ast, node.start, 'VariableDeclaration')?.node as Positioned<VariableDeclaration> | undefined
196210
const init = declarationNode?.declarations[0]?.init
197211
const isViHoisted = (node: CallExpression) => {
@@ -211,6 +225,10 @@ export function hoistMocks(code: string, id: string, parse: PluginContext['parse
211225
&& isViHoisted(init.argument)) /* const v = await vi.hoisted() */
212226

213227
if (canMoveDeclaration) {
228+
// export const variable = vi.hoisted()
229+
const nodeExported = findNodeAround(ast, declarationNode.start, 'ExportNamedDeclaration')?.node as Positioned<ExportNamedDeclaration> | undefined
230+
if (nodeExported?.declaration === declarationNode)
231+
throw createSyntaxError(nodeExported, 'Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.')
214232
// hoist "const variable = vi.hoisted(() => {})"
215233
hoistedNodes.push(declarationNode)
216234
}
@@ -251,15 +269,10 @@ export function hoistMocks(code: string, id: string, parse: PluginContext['parse
251269
function createError(outsideNode: Node, insideNode: Node) {
252270
const outsideCall = getNodeCall(outsideNode)
253271
const insideCall = getNodeCall(insideNode)
254-
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.`)
255-
// throw an object instead of an error so it can be serialized for RPC, TODO: improve error handling in rpc serializer
256-
const error = {
257-
name: 'SyntaxError',
258-
message: _error.message,
259-
stack: _error.stack,
260-
frame: generateCodeFrame(highlightCode(id, code, colors), 4, insideCall.start + 1),
261-
}
262-
throw error
272+
throw createSyntaxError(
273+
insideCall,
274+
`Cannot call ${getNodeName(insideCall)} inside ${getNodeName(outsideCall)}: both methods are hoisted to the top of the file and not actually called inside each other.`,
275+
)
263276
}
264277

265278
// validate hoistedNodes doesn't have nodes inside other nodes

pnpm-lock.yaml

Lines changed: 5 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 96 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -1,78 +1,122 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

3-
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."`;
4-
5-
exports[`throws an error when nodes are incompatible > correctly throws an error 2`] = `
6-
" 3|
7-
4| vi.mock('./mocked', () => {
8-
5| const variable = vi.hoisted(() => 1)
9-
| ^
10-
6| console.log(variable)
11-
7| })"
3+
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."`;
4+
5+
exports[`throws an error when nodes are incompatible > correctly throws an error if awaited assigned vi.hoisted is called inside vi.mock 2`] = `
6+
" 2|
7+
3| vi.mock('./mocked', async () => {
8+
4| const variable = await vi.hoisted(() => 1)
9+
| ^
10+
5| })
11+
6| "
1212
`;
1313

14-
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."`;
14+
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."`;
1515

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

25-
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."`;
25+
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."`;
2626

27-
exports[`throws an error when nodes are incompatible > correctly throws an error 6`] = `
28-
" 3|
29-
4| vi.mock('./mocked', async () => {
30-
5| const variable = await vi.hoisted(() => 1)
31-
| ^
32-
6| })
33-
7| "
27+
exports[`throws an error when nodes are incompatible > correctly throws an error if awaited vi.hoisted is exported as default export 2`] = `
28+
" 1| import { vi } from 'vitest'
29+
2|
30+
3| export default await vi.hoisted(async () => {
31+
| ^
32+
4| return {}
33+
5| })"
34+
`;
35+
36+
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."`;
37+
38+
exports[`throws an error when nodes are incompatible > correctly throws an error if awaited vi.hoisted is exported as named export 2`] = `
39+
" 1| import { vi } from 'vitest'
40+
2|
41+
3| export const values = await vi.hoisted(async () => {
42+
| ^
43+
4| return {}
44+
5| })"
45+
`;
46+
47+
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."`;
48+
49+
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.hoisted is called inside vi.mock 2`] = `
50+
" 2|
51+
3| vi.mock('./mocked', () => {
52+
4| const variable = vi.hoisted(() => 1)
53+
| ^
54+
5| console.log(variable)
55+
6| })"
56+
`;
57+
58+
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."`;
59+
60+
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.hoisted is exported as a named export 2`] = `
61+
" 1| import { vi } from 'vitest'
62+
2|
63+
3| export const values = vi.hoisted(async () => {
64+
| ^
65+
4| return {}
66+
5| })"
67+
`;
68+
69+
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."`;
70+
71+
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.hoisted is exported as default 2`] = `
72+
" 1| import { vi } from 'vitest'
73+
2|
74+
3| export default vi.hoisted(() => {
75+
| ^
76+
4| return {}
77+
5| })"
3478
`;
3579

36-
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."`;
80+
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."`;
3781

38-
exports[`throws an error when nodes are incompatible > correctly throws an error 8`] = `
39-
" 3|
40-
4| vi.hoisted(() => {
41-
5| vi.mock('./mocked')
82+
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock inside vi.hoisted 2`] = `
83+
" 2|
84+
3| vi.hoisted(() => {
85+
4| vi.mock('./mocked')
4286
| ^
43-
6| })
44-
7| "
87+
5| })
88+
6| "
4589
`;
4690

47-
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."`;
91+
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."`;
4892

49-
exports[`throws an error when nodes are incompatible > correctly throws an error 10`] = `
50-
" 3|
51-
4| const values = vi.hoisted(() => {
52-
5| vi.mock('./mocked')
93+
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside assigned awaited vi.hoisted 2`] = `
94+
" 2|
95+
3| const values = await vi.hoisted(async () => {
96+
4| vi.mock('./mocked')
5397
| ^
54-
6| })
55-
7| "
98+
5| })
99+
6| "
56100
`;
57101

58-
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."`;
102+
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."`;
59103

60-
exports[`throws an error when nodes are incompatible > correctly throws an error 12`] = `
61-
" 3|
62-
4| await vi.hoisted(async () => {
63-
5| vi.mock('./mocked')
104+
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside assigned vi.hoisted 2`] = `
105+
" 2|
106+
3| const values = vi.hoisted(() => {
107+
4| vi.mock('./mocked')
64108
| ^
65-
6| })
66-
7| "
109+
5| })
110+
6| "
67111
`;
68112

69-
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."`;
113+
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."`;
70114

71-
exports[`throws an error when nodes are incompatible > correctly throws an error 14`] = `
72-
" 3|
73-
4| const values = await vi.hoisted(async () => {
74-
5| vi.mock('./mocked')
115+
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside awaited vi.hoisted 2`] = `
116+
" 2|
117+
3| await vi.hoisted(async () => {
118+
4| vi.mock('./mocked')
75119
| ^
76-
6| })
77-
7| "
120+
5| })
121+
6| "
78122
`;

0 commit comments

Comments
 (0)