Skip to content

Commit 5db54a4

Browse files
authored
feat: show a warning if vi.mock or vi.hoisted are declared outside of top level of the module (#9387)
1 parent 9937fab commit 5db54a4

File tree

4 files changed

+199
-22
lines changed

4 files changed

+199
-22
lines changed

packages/mocker/src/node/esmWalker.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
Identifier,
66
ImportExpression,
77
Literal,
8+
MetaProperty,
89
Pattern,
910
Property,
1011
VariableDeclaration,
@@ -43,7 +44,7 @@ interface Visitors {
4344
info: IdentifierInfo,
4445
parentStack: Node[],
4546
) => void
46-
onImportMeta?: (node: Node) => void
47+
onImportMeta?: (node: Positioned<MetaProperty>) => void
4748
onDynamicImport?: (node: Positioned<ImportExpression>) => void
4849
onCallExpression?: (node: Positioned<CallExpression>) => void
4950
}
@@ -142,7 +143,7 @@ export function esmWalker(
142143
}
143144

144145
if (node.type === 'MetaProperty' && node.meta.name === 'import') {
145-
onImportMeta?.(node as Node)
146+
onImportMeta?.(node as Positioned<MetaProperty>)
146147
}
147148
else if (node.type === 'ImportExpression') {
148149
onDynamicImport?.(node as Positioned<ImportExpression>)

packages/mocker/src/node/hoistMocksPlugin.ts

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,9 @@ export function hoistMocks(
237237
}
238238

239239
const declaredConst = new Set<string>()
240-
const hoistedNodes: Positioned<
240+
const hoistedNodes: Set<Positioned<
241241
CallExpression | VariableDeclaration | AwaitExpression
242-
>[] = []
242+
>> = new Set()
243243

244244
function createSyntaxError(node: Positioned<Node>, message: string) {
245245
const _error = new SyntaxError(message)
@@ -304,8 +304,15 @@ export function hoistMocks(
304304
}
305305

306306
const usedUtilityExports = new Set<string>()
307+
let hasImportMetaVitest = false
307308

308309
esmWalker(ast, {
310+
onImportMeta(node) {
311+
const property = code.slice(node.end, node.end + 7) // '.vitest'.length
312+
if (property === '.vitest') {
313+
hasImportMetaVitest = true
314+
}
315+
},
309316
onIdentifier(id, info, parentStack) {
310317
const binding = idToImportMap.get(id.name)
311318
if (!binding) {
@@ -382,7 +389,7 @@ export function hoistMocks(
382389
)
383390
}
384391
}
385-
hoistedNodes.push(node)
392+
hoistedNodes.add(node)
386393
}
387394
// vi.doMock(import('./path')) -> vi.doMock('./path')
388395
// vi.doMock(await import('./path')) -> vi.doMock('./path')
@@ -420,7 +427,7 @@ export function hoistMocks(
420427
'Cannot export hoisted variable. You can control hoisting behavior by placing the import from this file first.',
421428
)
422429
// hoist "const variable = vi.hoisted(() => {})"
423-
hoistedNodes.push(declarationNode)
430+
hoistedNodes.add(declarationNode)
424431
}
425432
else {
426433
const awaitedExpression = findNodeAround(
@@ -430,7 +437,7 @@ export function hoistMocks(
430437
)?.node as Positioned<AwaitExpression> | undefined
431438
// hoist "await vi.hoisted(async () => {})" or "vi.hoisted(() => {})"
432439
const moveNode = awaitedExpression?.argument === node ? awaitedExpression : node
433-
hoistedNodes.push(moveNode)
440+
hoistedNodes.add(moveNode)
434441
}
435442
}
436443
}
@@ -444,7 +451,11 @@ export function hoistMocks(
444451
&& isIdentifier(callee.property)
445452
&& isIdentifier(callee.object)
446453
) {
447-
return `${callee.object.name}.${callee.property.name}()`
454+
const argument = node.arguments[0] as Positioned<Expression>
455+
const argStr = argument.type === 'Literal' || argument.type === 'ImportExpression'
456+
? code.slice(argument.start, argument.end)
457+
: ''
458+
return `${callee.object.name}.${callee.property.name}(${argStr})`
448459
}
449460
return '"hoisted method"'
450461
}
@@ -481,10 +492,11 @@ export function hoistMocks(
481492
}
482493

483494
// validate hoistedNodes doesn't have nodes inside other nodes
484-
for (let i = 0; i < hoistedNodes.length; i++) {
485-
const node = hoistedNodes[i]
486-
for (let j = i + 1; j < hoistedNodes.length; j++) {
487-
const otherNode = hoistedNodes[j]
495+
const arrayNodes = Array.from(hoistedNodes)
496+
for (let i = 0; i < arrayNodes.length; i++) {
497+
const node = arrayNodes[i]
498+
for (let j = i + 1; j < arrayNodes.length; j++) {
499+
const otherNode = arrayNodes[j]
488500

489501
if (node.start >= otherNode.start && node.end <= otherNode.end) {
490502
throw createError(otherNode, node)
@@ -495,8 +507,29 @@ export function hoistMocks(
495507
}
496508
}
497509

510+
// validate that hoisted nodes are defined on the top level
511+
// ignore `import.meta.vitest` because it needs to be inside an IfStatement
512+
// and it can be used anywhere in the code (inside methods too)
513+
if (!hasImportMetaVitest) {
514+
for (const node of ast.body as Node[]) {
515+
hoistedNodes.delete(node as any)
516+
if (node.type === 'ExpressionStatement') {
517+
hoistedNodes.delete(node.expression as any)
518+
}
519+
}
520+
521+
for (const invalidNode of hoistedNodes) {
522+
console.warn(
523+
`Warning: A ${getNodeName(getNodeCall(invalidNode))} call in "${id}" is not at the top level of the module. `
524+
+ `Although it appears nested, it will be hoisted and executed before any tests run. `
525+
+ `Move it to the top level to reflect its actual execution order. This will become an error in a future version.\n`
526+
+ `See: https://vitest.dev/guide/mocking/modules#how-it-works`,
527+
)
528+
}
529+
}
530+
498531
// hoist vi.mock/vi.hoisted
499-
for (const node of hoistedNodes) {
532+
for (const node of arrayNodes) {
500533
const end = getNodeTail(code, node)
501534
// don't hoist into itself if it's already at the top
502535
if (hoistIndex === end || hoistIndex === node.start) {
@@ -530,7 +563,7 @@ export function hoistMocks(
530563
}
531564
}
532565

533-
if (!hoistedModuleImported && hoistedNodes.length) {
566+
if (!hoistedModuleImported && arrayNodes.length > 0) {
534567
const utilityImports = [...usedUtilityExports]
535568
// "vi" or "vitest" is imported from a module other than "vitest"
536569
if (utilityImports.some(name => idToImportMap.has(name))) {

test/core/test/__snapshots__/injector-mock.test.ts.snap

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
22

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."`;
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('./mocked'): both methods are hoisted to the top of the file and not actually called inside each other."`;
44

55
exports[`throws an error when nodes are incompatible > correctly throws an error if awaited assigned vi.hoisted is called inside vi.mock 2`] = `
66
" 2|
@@ -11,7 +11,7 @@ exports[`throws an error when nodes are incompatible > correctly throws an error
1111
6| "
1212
`;
1313

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."`;
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('./mocked'): both methods are hoisted to the top of the file and not actually called inside each other."`;
1515

1616
exports[`throws an error when nodes are incompatible > correctly throws an error if awaited vi.hoisted is called inside vi.mock 2`] = `
1717
" 2|
@@ -44,7 +44,7 @@ exports[`throws an error when nodes are incompatible > correctly throws an error
4444
5| })"
4545
`;
4646

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."`;
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('./mocked'): both methods are hoisted to the top of the file and not actually called inside each other."`;
4848

4949
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.hoisted is called inside vi.mock 2`] = `
5050
" 2|
@@ -77,7 +77,7 @@ exports[`throws an error when nodes are incompatible > correctly throws an error
7777
5| })"
7878
`;
7979

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."`;
80+
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock inside vi.hoisted 1`] = `"Cannot call vi.mock('./mocked') inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;
8181

8282
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock inside vi.hoisted 2`] = `
8383
" 2|
@@ -88,7 +88,7 @@ exports[`throws an error when nodes are incompatible > correctly throws an error
8888
6| "
8989
`;
9090

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."`;
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('./mocked') inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;
9292

9393
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside assigned awaited vi.hoisted 2`] = `
9494
" 2|
@@ -99,7 +99,7 @@ exports[`throws an error when nodes are incompatible > correctly throws an error
9999
6| "
100100
`;
101101

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."`;
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('./mocked') inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;
103103

104104
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside assigned vi.hoisted 2`] = `
105105
" 2|
@@ -110,7 +110,7 @@ exports[`throws an error when nodes are incompatible > correctly throws an error
110110
6| "
111111
`;
112112

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."`;
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('./mocked') inside vi.hoisted(): both methods are hoisted to the top of the file and not actually called inside each other."`;
114114

115115
exports[`throws an error when nodes are incompatible > correctly throws an error if vi.mock is called inside awaited vi.hoisted 2`] = `
116116
" 2|

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

Lines changed: 144 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { HoistMocksPluginOptions } from '../../../packages/mocker/src/node/hoistMocksPlugin'
22
import { stripVTControlCharacters } from 'node:util'
33
import { parseAst } from 'vite'
4-
import { describe, expect, it, test } from 'vitest'
4+
import { describe, expect, it, test, vi } from 'vitest'
55
import { hoistMocks } from '../../../packages/mocker/src/node/hoistMocksPlugin'
66
import { generateCodeFrame } from '../../../packages/vitest/src/node/printError.js'
77

@@ -1554,4 +1554,147 @@ export const mocked = vi.unmock('./mocked')
15541554
expect(error.message).toMatchSnapshot()
15551555
expect(stripVTControlCharacters(error.frame)).toMatchSnapshot()
15561556
})
1557+
1558+
it('shows an error when hoisted methods are used outside the top level scope', ({ onTestFinished }) => {
1559+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
1560+
onTestFinished(() => warn.mockRestore())
1561+
const result = hoistSimpleCode(`
1562+
// correct
1563+
vi.mock('./hello-world-1')
1564+
1565+
if (condition) {
1566+
vi.mock('./hello-world-2')
1567+
}
1568+
1569+
test('some test', () => {
1570+
vi.mock('./hello-world-3')
1571+
})
1572+
1573+
test('some test', () => {
1574+
if (condition) {
1575+
vi.mock('./hello-world-4')
1576+
vi.hoisted(() => {})
1577+
vi.mock(import('./hello-world-5'))
1578+
const variable = vi.hoisted(() => {})
1579+
}
1580+
})
1581+
1582+
describe('some suite', () => {
1583+
if (condition) {
1584+
vi.mock('./hello-world-6')
1585+
}
1586+
})
1587+
`)
1588+
expect(result).toMatchInlineSnapshot(`
1589+
"import { vi } from "vitest"
1590+
vi.mock('./hello-world-1')
1591+
vi.mock('./hello-world-2')
1592+
vi.mock('./hello-world-3')
1593+
vi.mock('./hello-world-4')
1594+
vi.hoisted(() => {})
1595+
vi.mock('./hello-world-5')
1596+
const variable = vi.hoisted(() => {})
1597+
vi.mock('./hello-world-6')
1598+
1599+
// correct
1600+
1601+
if (condition) {
1602+
}
1603+
1604+
test('some test', () => {
1605+
})
1606+
1607+
test('some test', () => {
1608+
if (condition) {
1609+
}
1610+
})
1611+
1612+
describe('some suite', () => {
1613+
if (condition) {
1614+
}
1615+
})"
1616+
`)
1617+
expect(warn).toMatchInlineSnapshot(`
1618+
[MockFunction warn] {
1619+
"calls": [
1620+
[
1621+
"Warning: A vi.mock('./hello-world-2') call in "/test.js" is not at the top level of the module. Although it appears nested, it will be hoisted and executed before any tests run. Move it to the top level to reflect its actual execution order. This will become an error in a future version.
1622+
See: https://vitest.dev/guide/mocking/modules#how-it-works",
1623+
],
1624+
[
1625+
"Warning: A vi.mock('./hello-world-3') call in "/test.js" is not at the top level of the module. Although it appears nested, it will be hoisted and executed before any tests run. Move it to the top level to reflect its actual execution order. This will become an error in a future version.
1626+
See: https://vitest.dev/guide/mocking/modules#how-it-works",
1627+
],
1628+
[
1629+
"Warning: A vi.mock('./hello-world-4') call in "/test.js" is not at the top level of the module. Although it appears nested, it will be hoisted and executed before any tests run. Move it to the top level to reflect its actual execution order. This will become an error in a future version.
1630+
See: https://vitest.dev/guide/mocking/modules#how-it-works",
1631+
],
1632+
[
1633+
"Warning: A vi.hoisted() call in "/test.js" is not at the top level of the module. Although it appears nested, it will be hoisted and executed before any tests run. Move it to the top level to reflect its actual execution order. This will become an error in a future version.
1634+
See: https://vitest.dev/guide/mocking/modules#how-it-works",
1635+
],
1636+
[
1637+
"Warning: A vi.mock(import('./hello-world-5')) call in "/test.js" is not at the top level of the module. Although it appears nested, it will be hoisted and executed before any tests run. Move it to the top level to reflect its actual execution order. This will become an error in a future version.
1638+
See: https://vitest.dev/guide/mocking/modules#how-it-works",
1639+
],
1640+
[
1641+
"Warning: A vi.hoisted() call in "/test.js" is not at the top level of the module. Although it appears nested, it will be hoisted and executed before any tests run. Move it to the top level to reflect its actual execution order. This will become an error in a future version.
1642+
See: https://vitest.dev/guide/mocking/modules#how-it-works",
1643+
],
1644+
[
1645+
"Warning: A vi.mock('./hello-world-6') call in "/test.js" is not at the top level of the module. Although it appears nested, it will be hoisted and executed before any tests run. Move it to the top level to reflect its actual execution order. This will become an error in a future version.
1646+
See: https://vitest.dev/guide/mocking/modules#how-it-works",
1647+
],
1648+
],
1649+
"results": [
1650+
{
1651+
"type": "return",
1652+
"value": undefined,
1653+
},
1654+
{
1655+
"type": "return",
1656+
"value": undefined,
1657+
},
1658+
{
1659+
"type": "return",
1660+
"value": undefined,
1661+
},
1662+
{
1663+
"type": "return",
1664+
"value": undefined,
1665+
},
1666+
{
1667+
"type": "return",
1668+
"value": undefined,
1669+
},
1670+
{
1671+
"type": "return",
1672+
"value": undefined,
1673+
},
1674+
{
1675+
"type": "return",
1676+
"value": undefined,
1677+
},
1678+
],
1679+
}
1680+
`)
1681+
})
1682+
1683+
it('ignores vi.mock position if import.meta.vitest is present', ({ onTestFinished }) => {
1684+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
1685+
onTestFinished(() => warn.mockRestore())
1686+
const result = hoistSimpleCode(`
1687+
if (import.meta.vitest) {
1688+
vi.mock('./hello-world-1')
1689+
}
1690+
`)
1691+
expect(result).toMatchInlineSnapshot(`
1692+
"import { vi } from "vitest"
1693+
vi.mock('./hello-world-1')
1694+
1695+
if (import.meta.vitest) {
1696+
}"
1697+
`)
1698+
expect(warn).not.toHaveBeenCalled()
1699+
})
15571700
})

0 commit comments

Comments
 (0)