fix(vitest): throw a syntax error if vi.hoisted is directly exported (#4969)

This commit is contained in:
Vladimir 2024-01-17 15:43:09 +01:00 committed by GitHub
parent ba7ae53b22
commit f8bff9efb8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 214 additions and 96 deletions

View File

@ -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",

View File

@ -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'
@ -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)
@ -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) => {
@ -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)
}
@ -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

10
pnpm-lock.yaml generated
View File

@ -1285,8 +1285,8 @@ importers:
specifier: workspace:*
version: link:../utils
acorn-walk:
specifier: ^8.3.1
version: 8.3.1
specifier: ^8.3.2
version: 8.3.2
cac:
specifier: ^6.7.14
version: 6.7.14
@ -11376,7 +11376,7 @@ packages:
resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==}
dependencies:
acorn: 8.11.2
acorn-walk: 8.3.1
acorn-walk: 8.3.2
dev: true
/acorn-import-assertions@1.8.0(acorn@8.11.2):
@ -11416,8 +11416,8 @@ packages:
engines: {node: '>=0.4.0'}
dev: true
/acorn-walk@8.3.1:
resolution: {integrity: sha512-TgUZgYvqZprrl7YldZNoa9OciCAyZR+Ejm9eXzKCmjsF5IKp/wgQ7Z/ZpjpGTIUPwrHQIcYeI8qDh4PsEwxMbw==}
/acorn-walk@8.3.2:
resolution: {integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==}
engines: {node: '>=0.4.0'}
/acorn@6.4.2:

View File

@ -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 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 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 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 4`] = `
" 3|
4| vi.mock('./mocked', async () => {
5| await vi.hoisted(() => 1)
| ^
6| })
7| "
`;
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 6`] = `
" 3|
4| vi.mock('./mocked', async () => {
5| const variable = await vi.hoisted(() => 1)
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)
| ^
6| })
7| "
5| })
6| "
`;
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 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 8`] = `
" 3|
4| vi.hoisted(() => {
5| vi.mock('./mocked')
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)
| ^
5| })
6| "
`;
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 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 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 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| "
`;

View File

@ -1214,57 +1214,118 @@ describe('throws an error when nodes are incompatible', () => {
}
it.each([
`
import { vi } from 'vitest'
vi.mock('./mocked', () => {
const variable = vi.hoisted(() => 1)
console.log(variable)
})
`,
`
[
'vi.hoisted is called inside vi.mock',
`\
import { vi } from 'vitest'
vi.mock('./mocked', () => {
const variable = vi.hoisted(() => 1)
console.log(variable)
})
`,
],
[
'awaited vi.hoisted is called inside vi.mock',
`\
import { vi } from 'vitest'
vi.mock('./mocked', async () => {
await vi.hoisted(() => 1)
})
`,
`
`,
],
[
'awaited assigned vi.hoisted is called inside vi.mock',
`\
import { vi } from 'vitest'
vi.mock('./mocked', async () => {
const variable = await vi.hoisted(() => 1)
})
`,
`
`,
],
[
'vi.mock inside vi.hoisted',
`\
import { vi } from 'vitest'
vi.hoisted(() => {
vi.mock('./mocked')
})
`,
`
`,
],
[
'vi.mock is called inside assigned vi.hoisted',
`\
import { vi } from 'vitest'
const values = vi.hoisted(() => {
vi.mock('./mocked')
})
`,
`
`,
],
[
'vi.mock is called inside awaited vi.hoisted',
`\
import { vi } from 'vitest'
await vi.hoisted(async () => {
vi.mock('./mocked')
})
`,
`
`,
],
[
'vi.mock is called inside assigned awaited vi.hoisted',
`\
import { vi } from 'vitest'
const values = await vi.hoisted(async () => {
vi.mock('./mocked')
})
`,
])('correctly throws an error', (code) => {
`,
],
[
'vi.hoisted is exported as a named export',
`\
import { vi } from 'vitest'
export const values = vi.hoisted(async () => {
return {}
})
`,
],
[
'vi.hoisted is exported as default',
`\
import { vi } from 'vitest'
export default vi.hoisted(() => {
return {}
})
`,
],
[
'awaited vi.hoisted is exported as named export',
`\
import { vi } from 'vitest'
export const values = await vi.hoisted(async () => {
return {}
})
`,
],
[
'awaited vi.hoisted is exported as default export',
`\
import { vi } from 'vitest'
export default await vi.hoisted(async () => {
return {}
})
`,
],
])('correctly throws an error if %s', (_, code) => {
const error = getErrorWhileHoisting(code)
expect(error.message).toMatchSnapshot()
expect(stripAnsi(error.frame)).toMatchSnapshot()