From 29c49528c6616e5ba4b1af1e44a3298bdb54756b Mon Sep 17 00:00:00 2001 From: Vladimir Date: Mon, 20 Mar 2023 11:17:24 +0100 Subject: [PATCH] refactor: move module mocking transforms out of plugins (#2993) --- packages/vite-node/src/server.ts | 6 +- packages/vite-node/src/source-map.ts | 2 +- packages/vitest/package.json | 1 + packages/vitest/src/node/core.ts | 6 +- packages/vitest/src/node/mock.ts | 235 ++++++++++++++++++ packages/vitest/src/node/plugins/index.ts | 2 - packages/vitest/src/node/plugins/mock.ts | 136 ---------- packages/vitest/src/node/server.ts | 20 ++ pnpm-lock.yaml | 2 + .../fixtures/mocked-global.test.js | 7 + .../fixtures/mocked-imported.test.js | 7 + .../fixtures/mocked-imported.test.ts | 7 + test/stacktraces/fixtures/setup.js | 4 + test/stacktraces/fixtures/vite.config.ts | 1 + .../test/__snapshots__/runner.test.ts.snap | 33 +++ 15 files changed, 326 insertions(+), 143 deletions(-) create mode 100644 packages/vitest/src/node/mock.ts delete mode 100644 packages/vitest/src/node/plugins/mock.ts create mode 100644 packages/vitest/src/node/server.ts create mode 100644 test/stacktraces/fixtures/mocked-global.test.js create mode 100644 test/stacktraces/fixtures/mocked-imported.test.js create mode 100644 test/stacktraces/fixtures/mocked-imported.test.ts create mode 100644 test/stacktraces/fixtures/setup.js diff --git a/packages/vite-node/src/server.ts b/packages/vite-node/src/server.ts index 85f48282370f..b50b3cf049c8 100644 --- a/packages/vite-node/src/server.ts +++ b/packages/vite-node/src/server.ts @@ -191,6 +191,10 @@ export class ViteNodeServer { return result } + protected async processTransformResult(result: TransformResult) { + return withInlineSourcemap(result) + } + private async _transformRequest(id: string, customTransformMode?: 'web' | 'ssr') { debugRequest(id) @@ -217,7 +221,7 @@ export class ViteNodeServer { const sourcemap = this.options.sourcemap ?? 'inline' if (sourcemap === 'inline' && result && !id.includes('node_modules')) - withInlineSourcemap(result) + result = await this.processTransformResult(result) if (this.options.debug?.dumpModules) await this.debugger?.dumpFile(id, result) diff --git a/packages/vite-node/src/source-map.ts b/packages/vite-node/src/source-map.ts index 14c0415f299d..581c197f0d6d 100644 --- a/packages/vite-node/src/source-map.ts +++ b/packages/vite-node/src/source-map.ts @@ -13,7 +13,7 @@ const VITE_NODE_SOURCEMAPPING_SOURCE = '//# sourceMappingSource=vite-node' const VITE_NODE_SOURCEMAPPING_URL = `${SOURCEMAPPING_URL}=data:application/json;charset=utf-8` const VITE_NODE_SOURCEMAPPING_REGEXP = new RegExp(`//# ${VITE_NODE_SOURCEMAPPING_URL};base64,(.+)`) -export async function withInlineSourcemap(result: TransformResult) { +export function withInlineSourcemap(result: TransformResult) { const map = result.map let code = result.code diff --git a/packages/vitest/package.json b/packages/vitest/package.json index b470174b0f1c..1f11c5c3db9c 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -144,6 +144,7 @@ "why-is-node-running": "^2.2.2" }, "devDependencies": { + "@ampproject/remapping": "^2.2.0", "@antfu/install-pkg": "^0.1.1", "@edge-runtime/vm": "2.0.2", "@sinonjs/fake-timers": "^10.0.2", diff --git a/packages/vitest/src/node/core.ts b/packages/vitest/src/node/core.ts index 020c38f44de0..b8318932b778 100644 --- a/packages/vitest/src/node/core.ts +++ b/packages/vitest/src/node/core.ts @@ -5,7 +5,6 @@ import fg from 'fast-glob' import mm from 'micromatch' import c from 'picocolors' import { ViteNodeRunner } from 'vite-node/client' -import { ViteNodeServer } from 'vite-node/server' import type { ArgumentsType, CoverageProvider, OnServerRestartHandler, Reporter, ResolvedConfig, UserConfig, VitestRunMode } from '../types' import { SnapshotManager } from '../integrations/snapshot/manager' import { deepMerge, hasFailed, noop, slash, toArray } from '../utils' @@ -18,6 +17,7 @@ import { StateManager } from './state' import { resolveConfig } from './config' import { Logger } from './logger' import { VitestCache } from './cache' +import { VitestServer } from './server' const WATCHER_DEBOUNCE = 100 @@ -35,7 +35,7 @@ export class Vitest { pool: ProcessPool | undefined typechecker: Typechecker | undefined - vitenode: ViteNodeServer = undefined! + vitenode: VitestServer = undefined! invalidates: Set = new Set() changedTests: Set = new Set() @@ -76,7 +76,7 @@ export class Vitest { if (this.config.watch && this.mode !== 'typecheck') this.registerWatcher() - this.vitenode = new ViteNodeServer(server, this.config) + this.vitenode = new VitestServer(server, this.config) const node = this.vitenode this.runner = new ViteNodeRunner({ root: server.config.root, diff --git a/packages/vitest/src/node/mock.ts b/packages/vitest/src/node/mock.ts new file mode 100644 index 000000000000..5de2338a28cd --- /dev/null +++ b/packages/vitest/src/node/mock.ts @@ -0,0 +1,235 @@ +import MagicString from 'magic-string' +import type { DecodedSourceMap, RawSourceMap } from '@ampproject/remapping' +import type { SourceMap } from 'rollup' +import type { TransformResult } from 'vite' +import remapping from '@ampproject/remapping' +import { getCallLastIndex } from '../utils' + +const hoistRegexp = /^[ \t]*\b(?:__vite_ssr_import_\d+__\.)?((?:vitest|vi)\s*.\s*(mock|unmock)\(["`'\s]+(.*[@\w_-]+)["`'\s]+)[),]{1};?/gm + +const API_NOT_FOUND_ERROR = `There are some problems in resolving the mocks API. +You may encounter this issue when importing the mocks API from another module other than 'vitest'. + +To fix this issue you can either: +- import the mocks API directly from 'vitest' +- enable the 'globals' options` + +export function hoistModuleMocks(mod: TransformResult, vitestPath: string): TransformResult { + if (!mod.code) + return mod + const m = hoistCodeMocks(mod.code) + + if (m) { + const vitestRegexp = new RegExp(`const __vite_ssr_import_\\d+__ = await __vite_ssr_import__\\("(?:\/@fs\/?)?(?:${vitestPath}|vitest)"\\);`, 'gm') + // hoist vitest imports in case it was used inside vi.mock factory #425 + const vitestImports = mod.code.matchAll(vitestRegexp) + let found = false + + for (const match of vitestImports) { + const indexStart = match.index! + const indexEnd = match[0].length + indexStart + m.remove(indexStart, indexEnd) + m.prepend(`${match[0]}\n`) + found = true + } + + // if no vitest import found, check if the mock API is reachable after the hoisting + if (!found) { + m.prepend('if (typeof globalThis.vi === "undefined" && typeof globalThis.vitest === "undefined") ' + + `{ throw new Error(${JSON.stringify(API_NOT_FOUND_ERROR)}) }\n`) + } + + return { + ...mod, + code: m.toString(), + map: mod.map + ? combineSourcemaps( + mod.map.file, + [ + { + ...m.generateMap({ hires: true }), + sourcesContent: mod.map.sourcesContent, + } as RawSourceMap, + mod.map as RawSourceMap, + ], + ) as SourceMap + : null, + } + } + + return mod +} + +function hoistCodeMocks(code: string) { + let m: MagicString | undefined + const mocks = code.matchAll(hoistRegexp) + + for (const mockResult of mocks) { + const lastIndex = getMockLastIndex(code.slice(mockResult.index!)) + + if (lastIndex === null) + continue + + const startIndex = mockResult.index! + + const { insideComment, insideString } = getIndexStatus(code, startIndex) + + if (insideComment || insideString) + continue + + const endIndex = startIndex + lastIndex + + m ??= new MagicString(code) + + m.prepend(`${m.slice(startIndex, endIndex)}\n`) + m.remove(startIndex, endIndex) + } + + return m +} + +function escapeToLinuxLikePath(path: string) { + if (/^[A-Z]:/.test(path)) + return path.replace(/^([A-Z]):\//, '/windows/$1/') + + if (/^\/[^/]/.test(path)) + return `/linux${path}` + + return path +} + +function unescapeToLinuxLikePath(path: string) { + if (path.startsWith('/linux/')) + return path.slice('/linux'.length) + + if (path.startsWith('/windows/')) + return path.replace(/^\/windows\/([A-Z])\//, '$1:/') + + return path +} + +// based on https://github.com/vitejs/vite/blob/6b40f03574cd71a17cbe564bc63adebb156ff06e/packages/vite/src/node/utils.ts#L727 +const nullSourceMap: RawSourceMap = { + names: [], + sources: [], + mappings: '', + version: 3, +} +export function combineSourcemaps( + filename: string, + sourcemapList: Array, + excludeContent = true, +): RawSourceMap { + if ( + sourcemapList.length === 0 + || sourcemapList.every(m => m.sources.length === 0) + ) + return { ...nullSourceMap } + + // hack for parse broken with normalized absolute paths on windows (C:/path/to/something). + // escape them to linux like paths + // also avoid mutation here to prevent breaking plugin's using cache to generate sourcemaps like vue (see #7442) + sourcemapList = sourcemapList.map((sourcemap) => { + const newSourcemaps = { ...sourcemap } + newSourcemaps.sources = sourcemap.sources.map(source => + source ? escapeToLinuxLikePath(source) : null, + ) + if (sourcemap.sourceRoot) + newSourcemaps.sourceRoot = escapeToLinuxLikePath(sourcemap.sourceRoot) + + return newSourcemaps + }) + const escapedFilename = escapeToLinuxLikePath(filename) + + // We don't declare type here so we can convert/fake/map as RawSourceMap + let map // : SourceMap + let mapIndex = 1 + const useArrayInterface + = sourcemapList.slice(0, -1).find(m => m.sources.length !== 1) === undefined + if (useArrayInterface) { + map = remapping(sourcemapList, () => null, excludeContent) + } + else { + map = remapping( + sourcemapList[0], + (sourcefile) => { + if (sourcefile === escapedFilename && sourcemapList[mapIndex]) + return sourcemapList[mapIndex++] + + else + return null + }, + excludeContent, + ) + } + if (!map.file) + delete map.file + + // unescape the previous hack + map.sources = map.sources.map(source => + source ? unescapeToLinuxLikePath(source) : source, + ) + map.file = filename + + return map as RawSourceMap +} + +function getMockLastIndex(code: string): number | null { + const index = getCallLastIndex(code) + if (index === null) + return null + return code[index + 1] === ';' ? index + 2 : index + 1 +} + +function getIndexStatus(code: string, from: number) { + let index = 0 + let commentStarted = false + let commentEnded = true + let multilineCommentStarted = false + let multilineCommentEnded = true + let inString: string | null = null + let beforeChar: string | null = null + + while (index <= from) { + const char = code[index] + const sub = code[index] + code[index + 1] + + if (!inString) { + if (sub === '/*') { + multilineCommentStarted = true + multilineCommentEnded = false + } + if (sub === '*/' && multilineCommentStarted) { + multilineCommentStarted = false + multilineCommentEnded = true + } + if (sub === '//') { + commentStarted = true + commentEnded = false + } + if ((char === '\n' || sub === '\r\n') && commentStarted) { + commentStarted = false + commentEnded = true + } + } + + if (!multilineCommentStarted && !commentStarted) { + const isCharString = char === '"' || char === '\'' || char === '`' + + if (isCharString && beforeChar !== '\\') { + if (inString === char) + inString = null + else if (!inString) + inString = char + } + } + + beforeChar = char + index++ + } + + return { + insideComment: !multilineCommentEnded || !commentEnded, + insideString: inString !== null, + } +} diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts index 083af605840d..3754f1aeafac 100644 --- a/packages/vitest/src/node/plugins/index.ts +++ b/packages/vitest/src/node/plugins/index.ts @@ -12,7 +12,6 @@ import { Vitest } from '../core' import { generateScopedClassName } from '../../integrations/css/css-modules' import { EnvReplacerPlugin } from './envReplacer' import { GlobalSetupPlugin } from './globalSetup' -import { MocksPlugin } from './mock' import { CSSEnablerPlugin } from './cssEnabler' import { CoverageTransform } from './coverageTransform' @@ -244,7 +243,6 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t }, }, EnvReplacerPlugin(), - MocksPlugin(), GlobalSetupPlugin(ctx), ...(options.browser ? await BrowserPlugin() diff --git a/packages/vitest/src/node/plugins/mock.ts b/packages/vitest/src/node/plugins/mock.ts deleted file mode 100644 index d5392ddd6f52..000000000000 --- a/packages/vitest/src/node/plugins/mock.ts +++ /dev/null @@ -1,136 +0,0 @@ -import type { Plugin } from 'vite' -import MagicString from 'magic-string' -import { getCallLastIndex } from '../../utils' - -const hoistRegexp = /^[ \t]*\b((?:vitest|vi)\s*.\s*(mock|unmock)\(["`'\s]+(.*[@\w_-]+)["`'\s]+)[),]{1};?/gm -const vitestRegexp = /import {[^}]*}.*(?=["'`]vitest["`']).*/gm - -export function hoistMocks(code: string) { - let m: MagicString | undefined - const mocks = code.matchAll(hoistRegexp) - - for (const mockResult of mocks) { - const lastIndex = getMockLastIndex(code.slice(mockResult.index!)) - - if (lastIndex === null) - continue - - const startIndex = mockResult.index! - - const { insideComment, insideString } = getIndexStatus(code, startIndex) - - if (insideComment || insideString) - continue - - const endIndex = startIndex + lastIndex - - m ??= new MagicString(code) - - m.prepend(`${m.slice(startIndex, endIndex)}\n`) - m.remove(startIndex, endIndex) - } - - return m -} - -const API_NOT_FOUND_ERROR = `There are some problems in resolving the mocks API. -You may encounter this issue when importing the mocks API from another module other than 'vitest'. - -To fix this issue you can either: -- import the mocks API directly from 'vitest' -- enable the 'globals' options` - -export const MocksPlugin = (): Plugin => { - return { - name: 'vitest:mock-plugin', - enforce: 'post', - async transform(code) { - const m = hoistMocks(code) - - if (m) { - // hoist vitest imports in case it was used inside vi.mock factory #425 - const vitestImports = code.matchAll(vitestRegexp) - let found = false - - for (const match of vitestImports) { - const indexStart = match.index! - const indexEnd = match[0].length + indexStart - m.remove(indexStart, indexEnd) - m.prepend(`${match[0]}\n`) - found = true - } - - // if no vitest import found, check if the mock API is reachable after the hoisting - if (!found) { - m.prepend('if (typeof globalThis.vi === "undefined" && typeof globalThis.vitest === "undefined") ' - + `{ throw new Error(${JSON.stringify(API_NOT_FOUND_ERROR)}) }\n`) - } - - return { - code: m.toString(), - map: m.generateMap({ hires: true }), - } - } - }, - } -} - -function getMockLastIndex(code: string): number | null { - const index = getCallLastIndex(code) - if (index === null) - return null - return code[index + 1] === ';' ? index + 2 : index + 1 -} - -function getIndexStatus(code: string, from: number) { - let index = 0 - let commentStarted = false - let commentEnded = true - let multilineCommentStarted = false - let multilineCommentEnded = true - let inString: string | null = null - let beforeChar: string | null = null - - while (index <= from) { - const char = code[index] - const sub = code[index] + code[index + 1] - - if (!inString) { - if (sub === '/*') { - multilineCommentStarted = true - multilineCommentEnded = false - } - if (sub === '*/' && multilineCommentStarted) { - multilineCommentStarted = false - multilineCommentEnded = true - } - if (sub === '//') { - commentStarted = true - commentEnded = false - } - if ((char === '\n' || sub === '\r\n') && commentStarted) { - commentStarted = false - commentEnded = true - } - } - - if (!multilineCommentStarted && !commentStarted) { - const isCharString = char === '"' || char === '\'' || char === '`' - - if (isCharString && beforeChar !== '\\') { - if (inString === char) - inString = null - else if (!inString) - inString = char - } - } - - beforeChar = char - index++ - } - - return { - insideComment: !multilineCommentEnded || !commentEnded, - insideString: inString !== null, - } -} diff --git a/packages/vitest/src/node/server.ts b/packages/vitest/src/node/server.ts new file mode 100644 index 000000000000..42d596893354 --- /dev/null +++ b/packages/vitest/src/node/server.ts @@ -0,0 +1,20 @@ +import type { TransformResult } from 'vite' +import { ViteNodeServer } from 'vite-node/server' +import { hoistModuleMocks } from './mock' + +export class VitestServer extends ViteNodeServer { + private _vitestPath?: string + + private async getVitestPath() { + if (!this._vitestPath) { + const { id } = await this.resolveId('vitest') || { id: 'vitest' } + this._vitestPath = id + } + return this._vitestPath + } + + protected async processTransformResult(result: TransformResult): Promise { + const vitestId = await this.getVitestPath() + return super.processTransformResult(hoistModuleMocks(result, vitestId)) + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a375a100ede1..8841ebeb4c34 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -836,6 +836,7 @@ importers: packages/vitest: specifiers: + '@ampproject/remapping': ^2.2.0 '@antfu/install-pkg': ^0.1.1 '@edge-runtime/vm': 2.0.2 '@sinonjs/fake-timers': ^10.0.2 @@ -923,6 +924,7 @@ importers: vite-node: link:../vite-node why-is-node-running: 2.2.2 devDependencies: + '@ampproject/remapping': 2.2.0 '@antfu/install-pkg': 0.1.1 '@edge-runtime/vm': 2.0.2 '@sinonjs/fake-timers': 10.0.2 diff --git a/test/stacktraces/fixtures/mocked-global.test.js b/test/stacktraces/fixtures/mocked-global.test.js new file mode 100644 index 000000000000..98d8bf2c312b --- /dev/null +++ b/test/stacktraces/fixtures/mocked-global.test.js @@ -0,0 +1,7 @@ +/* eslint-disable no-undef */ + +vi.mock('./path') + +test('failed', () => { + expect(1).toBe(2) +}) diff --git a/test/stacktraces/fixtures/mocked-imported.test.js b/test/stacktraces/fixtures/mocked-imported.test.js new file mode 100644 index 000000000000..93ccc523e1ea --- /dev/null +++ b/test/stacktraces/fixtures/mocked-imported.test.js @@ -0,0 +1,7 @@ +import { expect, test, vi } from 'vitest' + +vi.mock('./path') + +test('failed', () => { + expect(1).toBe(2) +}) diff --git a/test/stacktraces/fixtures/mocked-imported.test.ts b/test/stacktraces/fixtures/mocked-imported.test.ts new file mode 100644 index 000000000000..93ccc523e1ea --- /dev/null +++ b/test/stacktraces/fixtures/mocked-imported.test.ts @@ -0,0 +1,7 @@ +import { expect, test, vi } from 'vitest' + +vi.mock('./path') + +test('failed', () => { + expect(1).toBe(2) +}) diff --git a/test/stacktraces/fixtures/setup.js b/test/stacktraces/fixtures/setup.js new file mode 100644 index 000000000000..fa02e2b657b2 --- /dev/null +++ b/test/stacktraces/fixtures/setup.js @@ -0,0 +1,4 @@ +import { expect, test, vi } from 'vitest' +globalThis.vi = vi +globalThis.test = test +globalThis.expect = expect diff --git a/test/stacktraces/fixtures/vite.config.ts b/test/stacktraces/fixtures/vite.config.ts index 6b86dab182fc..c43832978185 100644 --- a/test/stacktraces/fixtures/vite.config.ts +++ b/test/stacktraces/fixtures/vite.config.ts @@ -44,5 +44,6 @@ export default defineConfig({ threads: false, isolate: false, include: ['**/*.{test,spec}.{imba,js,mjs,cjs,ts,mts,cts,jsx,tsx}'], + setupFiles: ['./setup.js'], }, }) diff --git a/test/stacktraces/test/__snapshots__/runner.test.ts.snap b/test/stacktraces/test/__snapshots__/runner.test.ts.snap index eb54c3593d60..646d97ab90b9 100644 --- a/test/stacktraces/test/__snapshots__/runner.test.ts.snap +++ b/test/stacktraces/test/__snapshots__/runner.test.ts.snap @@ -44,6 +44,39 @@ exports[`stacktraces should respect sourcemaps > add-in-js.test.js > add-in-js.t " `; +exports[`stacktraces should respect sourcemaps > mocked-global.test.js > mocked-global.test.js 1`] = ` +" ❯ mocked-global.test.js:6:13 + 4| + 5| test('failed', () => { + 6| expect(1).toBe(2) + | ^ + 7| }) + 8| +" +`; + +exports[`stacktraces should respect sourcemaps > mocked-imported.test.js > mocked-imported.test.js 1`] = ` +" ❯ mocked-imported.test.js:6:13 + 4| + 5| test('failed', () => { + 6| expect(1).toBe(2) + | ^ + 7| }) + 8| +" +`; + +exports[`stacktraces should respect sourcemaps > mocked-imported.test.ts > mocked-imported.test.ts 1`] = ` +" ❯ mocked-imported.test.ts:6:13 + 4| + 5| test('failed', () => { + 6| expect(1).toBe(2) + | ^ + 7| }) + 8| +" +`; + exports[`stacktraces should respect sourcemaps > reset-modules.test.ts > reset-modules.test.ts 1`] = ` " ❯ reset-modules.test.ts:16:26 14| expect(2 + 1).eq(3)