Skip to content

Commit c4eacbb

Browse files
authoredJan 10, 2024
fix(vitest): show correct error when vi.hoisted is used inside vi.mock and the other way around (#4916)
1 parent 0e77e69 commit c4eacbb

File tree

6 files changed

+210
-9
lines changed

6 files changed

+210
-9
lines changed
 

‎packages/vitest/src/node/error.ts

+3-4
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export async function printError(error: unknown, project: WorkspaceProject | und
8080
printStack(project, stacks, nearest, errorProperties, (s) => {
8181
if (showCodeFrame && s === nearest && nearest) {
8282
const sourceCode = readFileSync(nearest.file, 'utf-8')
83-
logger.error(generateCodeFrame(sourceCode, 4, s.line, s.column))
83+
logger.error(generateCodeFrame(sourceCode, 4, s))
8484
}
8585
})
8686
}
@@ -248,11 +248,10 @@ function printStack(
248248
export function generateCodeFrame(
249249
source: string,
250250
indent = 0,
251-
lineNumber: number,
252-
columnNumber: number,
251+
loc: { line: number; column: number } | number,
253252
range = 2,
254253
): string {
255-
const start = positionToOffset(source, lineNumber, columnNumber)
254+
const start = typeof loc === 'object' ? positionToOffset(source, loc.line, loc.column) : loc
256255
const end = start
257256
const lines = source.split(lineSplitRE)
258257
const nl = /\r\n/.test(source) ? 2 : 1

‎packages/vitest/src/node/hoistMocks.ts

+55-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { AwaitExpression, CallExpression, Identifier, ImportDeclaration, Va
55
import { findNodeAround } from 'acorn-walk'
66
import type { PluginContext } from 'rollup'
77
import { esmWalker } from '@vitest/utils/ast'
8+
import { generateCodeFrame } from './error'
89

910
export type Positioned<T> = T & {
1011
start: number
@@ -62,9 +63,9 @@ const regexpAssignedHoisted = /=[ \t]*(\bawait|)[ \t]*\b(vi|vitest)\s*\.\s*hoist
6263
const hashbangRE = /^#!.*\n/
6364

6465
export function hoistMocks(code: string, id: string, parse: PluginContext['parse']) {
65-
const hasMocks = regexpHoistable.test(code) || regexpAssignedHoisted.test(code)
66+
const needHoisting = regexpHoistable.test(code) || regexpAssignedHoisted.test(code)
6667

67-
if (!hasMocks)
68+
if (!needHoisting)
6869
return
6970

7071
const s = new MagicString(code)
@@ -149,7 +150,7 @@ export function hoistMocks(code: string, id: string, parse: PluginContext['parse
149150
}
150151

151152
const declaredConst = new Set<string>()
152-
const hoistedNodes: Node[] = []
153+
const hoistedNodes: Positioned<CallExpression | VariableDeclaration | AwaitExpression>[] = []
153154

154155
esmWalker(ast, {
155156
onIdentifier(id, info, parentStack) {
@@ -222,6 +223,57 @@ export function hoistMocks(code: string, id: string, parse: PluginContext['parse
222223
},
223224
})
224225

226+
function getNodeName(node: CallExpression) {
227+
const callee = node.callee || {}
228+
if (callee.type === 'MemberExpression' && isIdentifier(callee.property) && isIdentifier(callee.object))
229+
return `${callee.object.name}.${callee.property.name}()`
230+
return '"hoisted method"'
231+
}
232+
233+
function getNodeCall(node: Node): Positioned<CallExpression> {
234+
if (node.type === 'CallExpression')
235+
return node
236+
if (node.type === 'VariableDeclaration') {
237+
const { declarations } = node
238+
const init = declarations[0].init
239+
if (init)
240+
return getNodeCall(init as Node)
241+
}
242+
if (node.type === 'AwaitExpression') {
243+
const { argument } = node
244+
if (argument.type === 'CallExpression')
245+
return getNodeCall(argument as Node)
246+
}
247+
return node as Positioned<CallExpression>
248+
}
249+
250+
function createError(outsideNode: Node, insideNode: Node) {
251+
const outsideCall = getNodeCall(outsideNode)
252+
const insideCall = getNodeCall(insideNode)
253+
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.`)
254+
// throw an object instead of an error so it can be serialized for RPC, TODO: improve error handling in rpc serializer
255+
const error = {
256+
name: 'SyntaxError',
257+
message: _error.message,
258+
stack: _error.stack,
259+
frame: generateCodeFrame(code, 4, insideCall.start + 1),
260+
}
261+
throw error
262+
}
263+
264+
// validate hoistedNodes doesn't have nodes inside other nodes
265+
for (let i = 0; i < hoistedNodes.length; i++) {
266+
const node = hoistedNodes[i]
267+
for (let j = i + 1; j < hoistedNodes.length; j++) {
268+
const otherNode = hoistedNodes[j]
269+
270+
if (node.start >= otherNode.start && node.end <= otherNode.end)
271+
throw createError(otherNode, node)
272+
if (otherNode.start >= node.start && otherNode.end <= node.end)
273+
throw createError(node, otherNode)
274+
}
275+
}
276+
225277
// Wait for imports to be hoisted and then hoist the mocks
226278
const hoistedCode = hoistedNodes.map((node) => {
227279
const end = getBetterEnd(code, node)

‎pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎test/core/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"@vitest/expect": "workspace:*",
1212
"@vitest/runner": "workspace:*",
1313
"@vitest/utils": "workspace:*",
14+
"strip-ansi": "^7.1.0",
1415
"tinyspy": "^1.0.2",
1516
"url": "^0.11.0",
1617
"vitest": "workspace:*"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2+
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| })"
12+
`;
13+
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."`;
15+
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)
20+
| ^
21+
6| })
22+
7| "
23+
`;
24+
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."`;
26+
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| "
34+
`;
35+
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."`;
37+
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')
42+
| ^
43+
6| })
44+
7| "
45+
`;
46+
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."`;
48+
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')
53+
| ^
54+
6| })
55+
7| "
56+
`;
57+
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."`;
59+
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')
64+
| ^
65+
6| })
66+
7| "
67+
`;
68+
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."`;
70+
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')
75+
| ^
76+
6| })
77+
7| "
78+
`;

‎test/core/test/injector-mock.test.ts

+70-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { parseAst } from 'rollup/parseAst'
2-
import { expect, test } from 'vitest'
3-
import { describe } from 'node:test'
2+
import { describe, expect, it, test } from 'vitest'
3+
import stripAnsi from 'strip-ansi'
44
import { hoistMocks } from '../../../packages/vitest/src/node/hoistMocks'
55

66
function parse(code: string, options: any) {
@@ -1183,3 +1183,71 @@ console.log(foo + 2)
11831183
`)
11841184
})
11851185
})
1186+
1187+
describe('throws an error when nodes are incompatible', () => {
1188+
const getErrorWhileHoisting = (code: string) => {
1189+
try {
1190+
hoistSimpleCode(code)
1191+
}
1192+
catch (err: any) {
1193+
return err
1194+
}
1195+
}
1196+
1197+
it.each([
1198+
`
1199+
import { vi } from 'vitest'
1200+
1201+
vi.mock('./mocked', () => {
1202+
const variable = vi.hoisted(() => 1)
1203+
console.log(variable)
1204+
})
1205+
`,
1206+
`
1207+
import { vi } from 'vitest'
1208+
1209+
vi.mock('./mocked', async () => {
1210+
await vi.hoisted(() => 1)
1211+
})
1212+
`,
1213+
`
1214+
import { vi } from 'vitest'
1215+
1216+
vi.mock('./mocked', async () => {
1217+
const variable = await vi.hoisted(() => 1)
1218+
})
1219+
`,
1220+
`
1221+
import { vi } from 'vitest'
1222+
1223+
vi.hoisted(() => {
1224+
vi.mock('./mocked')
1225+
})
1226+
`,
1227+
`
1228+
import { vi } from 'vitest'
1229+
1230+
const values = vi.hoisted(() => {
1231+
vi.mock('./mocked')
1232+
})
1233+
`,
1234+
`
1235+
import { vi } from 'vitest'
1236+
1237+
await vi.hoisted(async () => {
1238+
vi.mock('./mocked')
1239+
})
1240+
`,
1241+
`
1242+
import { vi } from 'vitest'
1243+
1244+
const values = await vi.hoisted(async () => {
1245+
vi.mock('./mocked')
1246+
})
1247+
`,
1248+
])('correctly throws an error', (code) => {
1249+
const error = getErrorWhileHoisting(code)
1250+
expect(error.message).toMatchSnapshot()
1251+
expect(stripAnsi(error.frame)).toMatchSnapshot()
1252+
})
1253+
})

0 commit comments

Comments
 (0)
Please sign in to comment.