diff --git a/packages/mocker/package.json b/packages/mocker/package.json index 3151cee71..f868adf1f 100644 --- a/packages/mocker/package.json +++ b/packages/mocker/package.json @@ -87,6 +87,7 @@ } }, "dependencies": { + "@jridgewell/trace-mapping": "catalog:", "@vitest/spy": "workspace:*", "estree-walker": "^3.0.3", "magic-string": "catalog:" diff --git a/packages/mocker/src/node/hoistMocks.ts b/packages/mocker/src/node/hoistMocks.ts index 67902c788..ee73a95fd 100644 --- a/packages/mocker/src/node/hoistMocks.ts +++ b/packages/mocker/src/node/hoistMocks.ts @@ -8,9 +8,12 @@ import type { ImportDeclaration, VariableDeclaration, } from 'estree' +import type { Rollup } from 'vite' import type { Node, Positioned } from './esmWalker' +import { originalPositionFor, TraceMap } from '@jridgewell/trace-mapping' import { findNodeAround } from 'acorn-walk' import MagicString from 'magic-string' +import { relative } from 'pathe' import { esmWalker } from './esmWalker' export interface HoistMocksOptions { @@ -39,6 +42,12 @@ export interface HoistMocksOptions { regexpHoistable?: RegExp codeFrameGenerator?: CodeFrameGenerator magicString?: () => MagicString + /** + * Root of the project + * @default process.cwd() + */ + root?: string + getMap?: () => Rollup.SourceMap } const API_NOT_FOUND_ERROR = `There are some problems in resolving the mocks API. @@ -488,13 +497,26 @@ export function hoistMocks( } } - for (const invalidNode of hoistedNodes) { - console.warn( - `Warning: A ${getNodeName(getNodeCall(invalidNode))} call in "${id}" 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.\n` - + `See: https://vitest.dev/guide/mocking/modules#how-it-works`, - ) + if (hoistedNodes.size) { + const locations = createIndexLocationsMap(code) + const map = options.getMap && new TraceMap(options.getMap() as any) + const plural = hoistedNodes.size > 1 + const message = [ + `${hoistedNodes.size} call${plural ? 's' : ''} in "${relative(options.root || process.cwd(), id)}" ${plural ? 'were' : 'was'} defined outside of the module's top level scope:`, + '', + ...[...hoistedNodes].map((invalidNode) => { + const currentLocation = locations.get(invalidNode.start) + const originalLocation = map && currentLocation && originalPositionFor(map, currentLocation) + const location = originalLocation?.column != null && originalLocation?.line != null + ? ` at ${relative(options.root || process.cwd(), id)}:${originalLocation.line}:${originalLocation.column + 1}` + : '' + return `- ${getNodeName(getNodeCall(invalidNode))}${location}` + }), + '', + `Although ${plural ? 'they appear nested, they' : 'it appears nested, it'} will be hoisted and executed before anything in this file. Move ${plural ? 'them' : 'it'} to the top level to reflect ${plural ? 'their' : 'its'} actual execution order.`, + 'See: https://vitest.dev/guide/mocking/modules#how-it-works', + ].join('\n') + throw new Error(message) } } @@ -563,3 +585,21 @@ export function hoistMocks( interface CodeFrameGenerator { (node: Positioned, id: string, code: string): string } + +function createIndexLocationsMap(source: string): Map { + const map = new Map() + let offset = 0 + let line = 1 + let column = 1 + for (const char of source) { + map.set(offset++, { line, column }) + if (char === '\n' || char === '\r\n') { + line++ + column = 0 + } + else { + column++ + } + } + return map +} diff --git a/packages/mocker/src/node/hoistMocksPlugin.ts b/packages/mocker/src/node/hoistMocksPlugin.ts index de29eb1f8..001577acf 100644 --- a/packages/mocker/src/node/hoistMocksPlugin.ts +++ b/packages/mocker/src/node/hoistMocksPlugin.ts @@ -34,9 +34,14 @@ export function hoistMocksPlugin(options: HoistMocksPluginOptions = {}): Plugin `\\b(?:${utilsObjectNames.join('|')})\\s*\.\\s*(?:${Array.from(methods).join('|')})\\s*\\(`, ) + let root: string + return { name: 'vitest:mocks', enforce: 'post', + configResolved(config) { + root = config.root + }, transform(code, id) { if (!filter(id)) { return @@ -47,6 +52,8 @@ export function hoistMocksPlugin(options: HoistMocksPluginOptions = {}): Plugin hoistedMethodNames, utilsObjectNames, dynamicImportMockMethodNames, + root, + getMap: () => this.getCombinedSourcemap(), ...options, }) if (s) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a1726009c..706c6a390 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -259,7 +259,7 @@ importers: version: 6.3.0(rollup@4.59.0)(typescript@5.9.3) rollup-plugin-license: specifier: ^3.7.0 - version: 3.7.0(picomatch@4.0.4)(rollup@4.59.0) + version: 3.7.0(picomatch@4.0.3)(rollup@4.59.0) tinyglobby: specifier: 'catalog:' version: 0.2.15 @@ -779,6 +779,9 @@ importers: packages/mocker: dependencies: + '@jridgewell/trace-mapping': + specifier: 'catalog:' + version: 0.3.31 '@vitest/spy': specifier: workspace:* version: link:../spy @@ -18297,10 +18300,10 @@ snapshots: optionalDependencies: '@babel/code-frame': 7.27.1 - rollup-plugin-license@3.7.0(picomatch@4.0.4)(rollup@4.59.0): + rollup-plugin-license@3.7.0(picomatch@4.0.3)(rollup@4.59.0): dependencies: commenting: 1.1.0 - fdir: 6.5.0(picomatch@4.0.4) + fdir: 6.5.0(picomatch@4.0.3) lodash: 4.17.21 magic-string: 0.30.21 moment: 2.30.1 diff --git a/test/unit/test/hoisted-async-simple.test.ts b/test/unit/test/hoisted-async-simple.test.ts index 8f2141e1d..dd51b70f1 100644 --- a/test/unit/test/hoisted-async-simple.test.ts +++ b/test/unit/test/hoisted-async-simple.test.ts @@ -18,24 +18,13 @@ afterAll(() => { // _order is set in the hoisted function before tests are collected // @ts-expect-error not typed global -expect(globalThis._order).toEqual([1, 2, 3]) +expect(globalThis._order).toEqual([1, 2]) it('imported value is equal to returned from hoisted', () => { expect(value).toBe(globalValue) }) -it('hoists async "vi.hoisted", but leaves the wrapper alone', async () => { - expect.assertions(1) - await (async () => { - expect(1).toBe(1) - vi.hoisted(() => { - // @ts-expect-error not typed global - ;(globalThis._order ??= []).push(2) - }) - })() -}) - await vi.hoisted(async () => { // @ts-expect-error not typed global - ;(globalThis._order ??= []).push(3) + ;(globalThis._order ??= []).push(2) }) diff --git a/test/unit/test/injector-mock.test.ts b/test/unit/test/injector-mock.test.ts index a57c72ca0..a0ae2dc8f 100644 --- a/test/unit/test/injector-mock.test.ts +++ b/test/unit/test/injector-mock.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable style/no-tabs */ +import type { Rolldown } from 'vite' import type { HoistMocksPluginOptions } from '../../../packages/mocker/src/node/hoistMocksPlugin' import { stripVTControlCharacters } from 'node:util' import { parseAst } from 'vite' @@ -17,14 +19,15 @@ const hoistMocksOptions: HoistMocksPluginOptions = { node.start + 1, ) }, + root: '/', } -function hoistSimple(code: string, url = '') { +function hoistSimple(code: string, url = '/test.js') { return hoistMockAndResolve(code, url, parse, hoistMocksOptions) } -function hoistSimpleCode(code: string) { - return hoistMockAndResolve(code, '/test.js', parse, hoistMocksOptions)?.code.trim() +function hoistSimpleCode(code: string, options?: HoistMocksPluginOptions) { + return hoistMockAndResolve(code, '/test.js', parse, { ...hoistMocksOptions, ...options })?.code.trim() } test('hoists mock, unmock, hoisted', () => { @@ -527,10 +530,10 @@ vi.mock('./mock.js', () => { (hoistSimple( `vi.mock(any); export const a = 1`, - 'input.js', + '/input.js', ))?.map ) - expect(map?.sources).toStrictEqual(['input.js']) + expect(map?.sources).toStrictEqual(['/input.js']) }) test('overwrite bindings', () => { @@ -1599,128 +1602,82 @@ export const mocked = vi.unmock('./mocked') expect(stripVTControlCharacters(error.frame)).toMatchSnapshot() }) - it('shows an error when hoisted methods are used outside the top level scope', ({ onTestFinished }) => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - onTestFinished(() => warn.mockRestore()) - const result = hoistSimpleCode(` + it('shows an error when hoisted methods are used outside the top level scope', () => { + const code = `import { test, vi } from "vitest"; // correct -vi.mock('./hello-world-1') - +vi.mock("./hello-world-1"); if (condition) { - vi.mock('./hello-world-2') + vi.mock("./hello-world-2"); } - -test('some test', () => { - vi.mock('./hello-world-3') -}) - -test('some test', () => { - if (condition) { - vi.mock('./hello-world-4') - vi.hoisted(() => {}) - vi.mock(import('./hello-world-5')) - const variable = vi.hoisted(() => {}) - } -}) - -describe('some suite', () => { - if (condition) { - vi.mock('./hello-world-6') - } -}) - `) - expect(result).toMatchInlineSnapshot(` - "import { vi } from "vitest" - vi.mock('./hello-world-1') - vi.mock('./hello-world-2') - vi.mock('./hello-world-3') - vi.mock('./hello-world-4') - vi.hoisted(() => {}) - vi.mock('./hello-world-5') - const variable = vi.hoisted(() => {}) - vi.mock('./hello-world-6') - - // correct - - if (condition) { - } - - test('some test', () => { - }) - - test('some test', () => { - if (condition) { - } - }) - - describe('some suite', () => { - if (condition) { - } - })" - `) - expect(warn).toMatchInlineSnapshot(` - [MockFunction warn] { - "calls": [ - [ - "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. - See: https://vitest.dev/guide/mocking/modules#how-it-works", - ], - [ - "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. - See: https://vitest.dev/guide/mocking/modules#how-it-works", - ], - [ - "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. - See: https://vitest.dev/guide/mocking/modules#how-it-works", - ], - [ - "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. - See: https://vitest.dev/guide/mocking/modules#how-it-works", - ], - [ - "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. - See: https://vitest.dev/guide/mocking/modules#how-it-works", - ], - [ - "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. - See: https://vitest.dev/guide/mocking/modules#how-it-works", - ], - [ - "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. - See: https://vitest.dev/guide/mocking/modules#how-it-works", - ], - ], - "results": [ - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - { - "type": "return", - "value": undefined, - }, - ], - } +test("some test", () => { + vi.mock("./hello-world-3"); +}); +test("some test", () => { + if (condition) { + vi.mock("./hello-world-4"); + vi.hoisted(() => {}); + vi.mock(import("./hello-world-5")); + const variable = vi.hoisted(() => {}); + } +}); +describe("some suite", () => { + if (condition) { + vi.mock("./hello-world-6"); + } +}); +` + const map: Rolldown.SourceMap = { + file: '/test.js', + toUrl: () => 'not called', + mappings: 'AAAA,SAAS,MAAM,UAAU;;AAGzB,GAAG,KAAK,kBAAkB;AAE1B,IAAI,WAAW;CACb,GAAG,KAAK,kBAAkB;;AAG5B,KAAK,mBAAmB;CACtB,GAAG,KAAK,kBAAkB;EAC1B;AAEF,KAAK,mBAAmB;CACtB,IAAI,WAAW;EACb,GAAG,KAAK,kBAAkB;EAC1B,GAAG,cAAc,GAAG;EACpB,GAAG,KAAK,OAAO,mBAAmB;EAClC,MAAM,WAAW,GAAG,cAAc,GAAG;;EAEvC;AAEF,SAAS,oBAAoB;CAC3B,IAAI,WAAW;EACb,GAAG,KAAK,kBAAkB;;EAE5B', + names: [], + sources: [ + '/test.js', + ], + version: 3, + sourcesContent: [ + 'import { test, vi } from \'vitest\'\n' + + '\n' + + '// correct\n' + + 'vi.mock(\'./hello-world-1\')\n' + + '\n' + + 'if (condition) {\n' + + ' vi.mock(\'./hello-world-2\')\n' + + '}\n' + + '\n' + + 'test(\'some test\', () => {\n' + + ' vi.mock(\'./hello-world-3\')\n' + + '})\n' + + '\n' + + 'test(\'some test\', () => {\n' + + ' if (condition) {\n' + + ' vi.mock(\'./hello-world-4\')\n' + + ' vi.hoisted(() => {})\n' + + ' vi.mock(import(\'./hello-world-5\'))\n' + + ' const variable = vi.hoisted(() => {})\n' + + ' }\n' + + '})\n' + + '\n' + + 'describe(\'some suite\', () => {\n' + + ' if (condition) {\n' + + ' vi.mock(\'./hello-world-6\')\n' + + ' }\n' + + '})\n', + ], + } + expect(() => hoistSimpleCode(code, { getMap: () => map })).toThrowErrorMatchingInlineSnapshot(` + [Error: 7 calls in "test.js" were defined outside of the module's top level scope: + + - vi.mock("./hello-world-2") at test.js:7:3 + - vi.mock("./hello-world-3") at test.js:11:3 + - vi.mock("./hello-world-4") at test.js:16:5 + - vi.hoisted() at test.js:17:5 + - vi.mock(import("./hello-world-5")) at test.js:18:5 + - vi.hoisted() at test.js:19:5 + - vi.mock("./hello-world-6") at test.js:25:5 + + Although they appear nested, they will be hoisted and executed before anything in this file. Move them to the top level to reflect their actual execution order. + See: https://vitest.dev/guide/mocking/modules#how-it-works] `) })