diff --git a/docs/config/index.md b/docs/config/index.md index 0ffdcd3707ed..c6099958ea49 100644 --- a/docs/config/index.md +++ b/docs/config/index.md @@ -954,7 +954,7 @@ Listen to port and serve API. When set to true, the default port is 51204 ### browser -- **Type:** `{ enabled?, name?, provider?, headless?, api? }` +- **Type:** `{ enabled?, name?, provider?, headless?, api?, slowHijackESM? }` - **Default:** `{ enabled: false, headless: process.env.CI, api: 63315 }` - **Version:** Since Vitest 0.29.4 - **CLI:** `--browser`, `--browser=`, `--browser.name=chrome --browser.headless` @@ -1026,6 +1026,22 @@ export interface BrowserProvider { This is an advanced API for library authors. If you just need to run tests in a browser, use the [browser](/config/#browser) option. ::: +### browser.slowHijackESM + + +#### slowHijackESM + +- **Type:** `boolean` +- **Default:** `true` +- **Version:** Since Vitest 0.31.0 + +When running tests in Node.js Vitest can use its own module resolution to easily mock modules with `vi.mock` syntax. However it's not so easy to replicate ES module resolution in browser, so we need to transform your source files before browser can consume it. + +This option has no effect on tests running inside Node.js. + +This options is enabled by default when running in the browser. If you don't rely on spying on ES modules with `vi.spyOn` and don't use `vi.mock`, you can disable this to get a slight boost to performance. + + ### clearMocks - **Type:** `boolean` diff --git a/packages/browser/package.json b/packages/browser/package.json index 55b2e0fc5266..7d97e046818f 100644 --- a/packages/browser/package.json +++ b/packages/browser/package.json @@ -39,17 +39,20 @@ "prepublishOnly": "pnpm build" }, "peerDependencies": { - "vitest": ">=0.29.4" + "vitest": ">=0.31.0" }, "dependencies": { "modern-node-polyfills": "^0.1.1", "sirv": "^2.0.2" }, "devDependencies": { + "@types/estree": "^1.0.1", "@types/ws": "^8.5.4", "@vitest/runner": "workspace:*", "@vitest/ui": "workspace:*", "@vitest/ws-client": "workspace:*", + "estree-walker": "^3.0.3", + "periscopic": "^3.1.0", "rollup": "3.20.2", "vitest": "workspace:*" } diff --git a/packages/vitest/src/node/esmInjector.ts b/packages/browser/src/node/esmInjector.ts similarity index 52% rename from packages/vitest/src/node/esmInjector.ts rename to packages/browser/src/node/esmInjector.ts index 7826c70aeaab..54e6d64c2954 100644 --- a/packages/vitest/src/node/esmInjector.ts +++ b/packages/browser/src/node/esmInjector.ts @@ -1,57 +1,14 @@ import MagicString from 'magic-string' import { extract_names as extractNames } from 'periscopic' -import type { CallExpression, Expression, Identifier, ImportDeclaration, VariableDeclaration } from 'estree' -import { findNodeAround, simple as simpleWalk } from 'acorn-walk' +import type { Expression, ImportDeclaration } from 'estree' import type { AcornNode } from 'rollup' import type { Node, Positioned } from './esmWalker' import { esmWalker, isInDestructuringAssignment, isNodeInPattern, isStaticProperty } from './esmWalker' -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` - -const API_NOT_FOUND_CHECK = '\nif (typeof globalThis.vi === "undefined" && typeof globalThis.vitest === "undefined") ' -+ `{ throw new Error(${JSON.stringify(API_NOT_FOUND_ERROR)}) }\n` - -function isIdentifier(node: any): node is Positioned { - return node.type === 'Identifier' -} - -function transformImportSpecifiers(node: ImportDeclaration, mode: 'object' | 'named' = 'object') { - const specifiers = node.specifiers - - if (specifiers.length === 1 && specifiers[0].type === 'ImportNamespaceSpecifier') - return specifiers[0].local.name - - const dynamicImports = node.specifiers.map((specifier) => { - if (specifier.type === 'ImportDefaultSpecifier') - return `default ${mode === 'object' ? ':' : 'as'} ${specifier.local.name}` - - if (specifier.type === 'ImportSpecifier') { - const local = specifier.local.name - const imported = specifier.imported.name - if (local === imported) - return local - return `${imported} ${mode === 'object' ? ':' : 'as'} ${local}` - } - - return null - }).filter(Boolean).join(', ') - - if (!dynamicImports.length) - return '' - - return `{ ${dynamicImports} }` -} - const viInjectedKey = '__vi_inject__' // const viImportMetaKey = '__vi_import_meta__' // to allow overwrite const viExportAllHelper = '__vi_export_all__' -const regexpHoistable = /^[ \t]*\b(vi|vitest)\s*\.\s*(mock|unmock|hoisted)\(/m - const skipHijack = [ '/@vite/client', '/@vite/env', @@ -72,10 +29,9 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin if (skipHijack.some(skip => id.match(skip))) return - const hasMocks = regexpHoistable.test(code) const hijackEsm = options.hijackESM ?? false - if (!hasMocks && !hijackEsm) + if (!hijackEsm) return const s = new MagicString(code) @@ -100,8 +56,6 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin const hoistIndex = 0 let hasInjected = false - let hoistedCode = '' - let hoistedVitestImports = '' // this will tranfrom import statements into dynamic ones, if there are imports // it will keep the import as is, if we don't need to mock anything @@ -110,27 +64,11 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin const transformImportDeclaration = (node: ImportDeclaration) => { const source = node.source.value as string - // if we don't hijack ESM and process this file, then we definetly have mocks, - // so we need to transform imports into dynamic ones, so "vi.mock" can be executed before - if (!hijackEsm || skipHijack.some(skip => source.match(skip))) { - const specifiers = transformImportSpecifiers(node) - const code = specifiers - ? `const ${specifiers} = await import('${source}')\n` - : `await import('${source}')\n` - return { code } - } + if (skipHijack.some(skip => source.match(skip))) + return null const importId = `__vi_esm_${uid++}__` const hasSpecifiers = node.specifiers.length > 0 - if (hasMocks) { - const code = hasSpecifiers - ? `const { ${viInjectedKey}: ${importId} } = await __vi_wrap_module__(import('${source}'))\n` - : `await __vi_wrap_module__(import('${source}'))\n` - return { - code, - id: importId, - } - } const code = hasSpecifiers ? `import { ${viInjectedKey} as ${importId} } from '${source}'\n` : `import '${source}'\n` @@ -141,19 +79,11 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin } function defineImport(node: ImportDeclaration) { - // always hoist vitest import to top of the file, so - // "vi" helpers can access it - if (node.source.value === 'vitest') { - const importId = `__vi_esm_${uid++}__` - const code = hijackEsm - ? `import { ${viInjectedKey} as ${importId} } from 'vitest'\nconst ${transformImportSpecifiers(node)} = ${importId};\n` - : `import ${transformImportSpecifiers(node, 'named')} from 'vitest'\n` - hoistedVitestImports += code - return - } - const { code, id } = transformImportDeclaration(node) - s.appendLeft(hoistIndex, code) - return id + const declaration = transformImportDeclaration(node) + if (!declaration) + return null + s.appendLeft(hoistIndex, declaration.code) + return declaration.id } function defineImportAll(source: string) { @@ -178,9 +108,9 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin // import * as ok from 'foo' --> ok -> __import_foo__ if (node.type === 'ImportDeclaration') { const importId = defineImport(node) - s.remove(node.start, node.end) - if (!hijackEsm || !importId) + if (!importId) continue + s.remove(node.start, node.end) for (const spec of node.specifiers) { if (spec.type === 'ImportSpecifier') { idToImportMap.set( @@ -201,9 +131,6 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin // 2. check all export statements and define exports for (const node of ast.body as Node[]) { - if (!hijackEsm) - break - // named exports if (node.type === 'ExportNamedDeclaration') { if (node.declaration) { @@ -298,115 +225,50 @@ export function injectVitestModule(code: string, id: string, parse: (code: strin } } - function CallExpression(node: Positioned) { - if ( - node.callee.type === 'MemberExpression' - && isIdentifier(node.callee.object) - && (node.callee.object.name === 'vi' || node.callee.object.name === 'vitest') - && isIdentifier(node.callee.property) - ) { - const methodName = node.callee.property.name - - if (methodName === 'mock' || methodName === 'unmock') { - hoistedCode += `${code.slice(node.start, node.end)}\n` - s.remove(node.start, node.end) - } - - if (methodName === 'hoisted') { - const declarationNode = findNodeAround(ast, node.start, 'VariableDeclaration')?.node as Positioned | undefined - const init = declarationNode?.declarations[0]?.init - const isViHoisted = (node: CallExpression) => { - return node.callee.type === 'MemberExpression' - && isIdentifier(node.callee.object) - && (node.callee.object.name === 'vi' || node.callee.object.name === 'vitest') - && isIdentifier(node.callee.property) - && node.callee.property.name === 'hoisted' - } - - const canMoveDeclaration = (init - && init.type === 'CallExpression' - && isViHoisted(init)) /* const v = vi.hoisted() */ - || (init - && init.type === 'AwaitExpression' - && init.argument.type === 'CallExpression' - && isViHoisted(init.argument)) /* const v = await vi.hoisted() */ - - if (canMoveDeclaration) { - // hoist "const variable = vi.hoisted(() => {})" - hoistedCode += `${code.slice(declarationNode.start, declarationNode.end)}\n` - s.remove(declarationNode.start, declarationNode.end) - } - else { - // hoist "vi.hoisted(() => {})" - hoistedCode += `${code.slice(node.start, node.end)}\n` - s.remove(node.start, node.end) - } - } - } - } - - // if we don't need to inject anything, skip the walking - if (hijackEsm) { - // 3. convert references to import bindings & import.meta references - esmWalker(ast, { - onCallExpression: CallExpression, - onIdentifier(id, parent, parentStack) { - const grandparent = parentStack[1] - const binding = idToImportMap.get(id.name) - if (!binding) - return - - if (isStaticProperty(parent) && parent.shorthand) { - // let binding used in a property shorthand - // { foo } -> { foo: __import_x__.foo } - // skip for destructuring patterns - if ( - !isNodeInPattern(parent) + // 3. convert references to import bindings & import.meta references + esmWalker(ast, { + onIdentifier(id, parent, parentStack) { + const grandparent = parentStack[1] + const binding = idToImportMap.get(id.name) + if (!binding) + return + + if (isStaticProperty(parent) && parent.shorthand) { + // let binding used in a property shorthand + // { foo } -> { foo: __import_x__.foo } + // skip for destructuring patterns + if ( + !isNodeInPattern(parent) || isInDestructuringAssignment(parent, parentStack) - ) - s.appendLeft(id.end, `: ${binding}`) - } - else if ( - (parent.type === 'PropertyDefinition' + ) + s.appendLeft(id.end, `: ${binding}`) + } + else if ( + (parent.type === 'PropertyDefinition' && grandparent?.type === 'ClassBody') || (parent.type === 'ClassDeclaration' && id === parent.superClass) - ) { - if (!declaredConst.has(id.name)) { - declaredConst.add(id.name) - // locate the top-most node containing the class declaration - const topNode = parentStack[parentStack.length - 2] - s.prependRight(topNode.start, `const ${id.name} = ${binding};\n`) - } - } - else { - s.update(id.start, id.end, binding) + ) { + if (!declaredConst.has(id.name)) { + declaredConst.add(id.name) + // locate the top-most node containing the class declaration + const topNode = parentStack[parentStack.length - 2] + s.prependRight(topNode.start, `const ${id.name} = ${binding};\n`) } - }, - // TODO: make env updatable - onImportMeta() { - // s.update(node.start, node.end, viImportMetaKey) - }, - onDynamicImport(node) { - const replace = '__vi_wrap_module__(import(' - s.overwrite(node.start, (node.source as Positioned).start, replace) - s.overwrite(node.end - 1, node.end, '))') - }, - }) - } - // we still need to hoist "vi" helper - else { - simpleWalk(ast, { - CallExpression: CallExpression as any, - }) - } - - if (hoistedCode || hoistedVitestImports) { - s.prepend( - hoistedVitestImports - + ((!hoistedVitestImports && hoistedCode) ? API_NOT_FOUND_CHECK : '') - + hoistedCode, - ) - } + } + else { + s.update(id.start, id.end, binding) + } + }, + // TODO: make env updatable + onImportMeta() { + // s.update(node.start, node.end, viImportMetaKey) + }, + onDynamicImport(node) { + const replace = '__vi_wrap_module__(import(' + s.overwrite(node.start, (node.source as Positioned).start, replace) + s.overwrite(node.end - 1, node.end, '))') + }, + }) if (hasInjected) { // make sure "__vi_injected__" is declared as soon as possible diff --git a/packages/vitest/src/node/esmWalker.ts b/packages/browser/src/node/esmWalker.ts similarity index 95% rename from packages/vitest/src/node/esmWalker.ts rename to packages/browser/src/node/esmWalker.ts index 6fc890359503..01e54ad1eea5 100644 --- a/packages/vitest/src/node/esmWalker.ts +++ b/packages/browser/src/node/esmWalker.ts @@ -1,5 +1,4 @@ import type { - CallExpression, Function as FunctionNode, Identifier, ImportExpression, @@ -23,7 +22,6 @@ interface Visitors { parent: Node, parentStack: Node[], ) => void - onCallExpression: (node: Positioned) => void onImportMeta: (node: Node) => void onDynamicImport: (node: Positioned) => void } @@ -42,7 +40,7 @@ export function isNodeInPattern(node: _Node): node is Property { */ export function esmWalker( root: Node, - { onIdentifier, onImportMeta, onDynamicImport, onCallExpression }: Visitors, + { onIdentifier, onImportMeta, onDynamicImport }: Visitors, ) { const parentStack: Node[] = [] const varKindStack: VariableDeclaration['kind'][] = [] @@ -117,9 +115,6 @@ export function esmWalker( else if (node.type === 'ImportExpression') onDynamicImport(node) - else if (node.type === 'CallExpression') - onCallExpression(node) - if (node.type === 'Identifier') { if ( !isInScope(node.name, parentStack) @@ -280,14 +275,16 @@ export function isFunctionNode(node: _Node): node is FunctionNode { return functionNodeTypeRE.test(node.type) } +const blockNodeTypeRE = /^BlockStatement$|^For(?:In|Of)?Statement$/ +function isBlock(node: _Node) { + return blockNodeTypeRE.test(node.type) +} + function findParentScope( parentStack: _Node[], isVar = false, ): _Node | undefined { - const predicate = isVar - ? isFunctionNode - : (node: _Node) => node.type === 'BlockStatement' - return parentStack.find(predicate) + return parentStack.find(isVar ? isFunctionNode : isBlock) } export function isInDestructuringAssignment( diff --git a/packages/browser/src/node/index.ts b/packages/browser/src/node/index.ts index d582f8908e0e..f820dcf93aa7 100644 --- a/packages/browser/src/node/index.ts +++ b/packages/browser/src/node/index.ts @@ -5,12 +5,14 @@ import { builtinModules } from 'node:module' import { polyfillPath } from 'modern-node-polyfills' import sirv from 'sirv' import type { Plugin } from 'vite' +import { injectVitestModule } from './esmInjector' const polyfills = [ 'util', ] -export default (base = '/'): Plugin[] => { +// don't expose type to not bundle it here +export default (project: any, base = '/'): Plugin[] => { const pkgRoot = resolve(fileURLToPath(import.meta.url), '../..') const distRoot = resolve(pkgRoot, 'dist') @@ -51,6 +53,16 @@ export default (base = '/'): Plugin[] => { return { id: await polyfillPath(id), moduleSideEffects: false } }, }, + { + name: 'vitest:browser:esm-injector', + enforce: 'post', + transform(source, id) { + return injectVitestModule(source, id, this.parse, { + hijackESM: project.config.browser.slowHijackESM ?? false, + cacheDir: project.server.config.cacheDir, + }) + }, + }, ] } diff --git a/packages/vitest/package.json b/packages/vitest/package.json index 0eafc11462b2..3ba792543692 100644 --- a/packages/vitest/package.json +++ b/packages/vitest/package.json @@ -148,7 +148,6 @@ "chai": "^4.3.7", "concordance": "^5.0.4", "debug": "^4.3.4", - "estree-walker": "^3.0.3", "local-pkg": "^0.4.3", "magic-string": "^0.30.0", "pathe": "^1.1.0", @@ -191,7 +190,6 @@ "micromatch": "^4.0.5", "mlly": "^1.2.0", "p-limit": "^4.0.0", - "periscopic": "^3.1.0", "pkg-types": "^1.0.2", "playwright": "^1.32.2", "pretty-format": "^27.5.1", diff --git a/packages/vitest/src/integrations/browser/server.ts b/packages/vitest/src/integrations/browser/server.ts index 0950f062a0bf..7dd2ac36b431 100644 --- a/packages/vitest/src/integrations/browser/server.ts +++ b/packages/vitest/src/integrations/browser/server.ts @@ -7,7 +7,7 @@ import { ensurePackageInstalled } from '../../node/pkg' import { resolveApiServerConfig } from '../../node/config' import { CoverageTransform } from '../../node/plugins/coverageTransform' import type { WorkspaceProject } from '../../node/workspace' -import { ESMTransformPlugin } from '../../node/plugins/esmTransform' +import { MocksPlugin } from '../../node/plugins/mocks' export async function createBrowserServer(project: WorkspaceProject, options: UserConfig) { const root = project.config.root @@ -32,9 +32,8 @@ export async function createBrowserServer(project: WorkspaceProject, options: Us }, }, plugins: [ - (await import('@vitest/browser')).default('/'), + (await import('@vitest/browser')).default(project, '/'), CoverageTransform(project.ctx), - ESMTransformPlugin(project), { enforce: 'post', name: 'vitest:browser:config', @@ -54,6 +53,7 @@ export async function createBrowserServer(project: WorkspaceProject, options: Us } }, }, + MocksPlugin(), ], }) diff --git a/packages/vitest/src/node/config.ts b/packages/vitest/src/node/config.ts index f3f99f874666..ae3cfe462329 100644 --- a/packages/vitest/src/node/config.ts +++ b/packages/vitest/src/node/config.ts @@ -272,14 +272,12 @@ export function resolveConfig( resolved.browser ??= {} as any resolved.browser.enabled ??= false resolved.browser.headless ??= isCI + resolved.browser.slowHijackESM ??= true resolved.browser.api = resolveApiServerConfig(resolved.browser) || { port: defaultBrowserPort, } - if (resolved.browser.enabled) - resolved.slowHijackESM ??= true - return resolved } diff --git a/packages/vitest/src/node/hoistMocks.ts b/packages/vitest/src/node/hoistMocks.ts new file mode 100644 index 000000000000..a3d9a2aed391 --- /dev/null +++ b/packages/vitest/src/node/hoistMocks.ts @@ -0,0 +1,184 @@ +import MagicString from 'magic-string' +import type { CallExpression, Identifier, ImportDeclaration, VariableDeclaration, Node as _Node } from 'estree' +import { findNodeAround, simple as simpleWalk } from 'acorn-walk' +import type { AcornNode } from 'rollup' + +export type Positioned = T & { + start: number + end: number +} + +export type Node = Positioned<_Node> + +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` + +const API_NOT_FOUND_CHECK = '\nif (typeof globalThis.vi === "undefined" && typeof globalThis.vitest === "undefined") ' ++ `{ throw new Error(${JSON.stringify(API_NOT_FOUND_ERROR)}) }\n` + +function isIdentifier(node: any): node is Positioned { + return node.type === 'Identifier' +} + +function transformImportSpecifiers(node: ImportDeclaration) { + const specifiers = node.specifiers + + if (specifiers.length === 1 && specifiers[0].type === 'ImportNamespaceSpecifier') + return specifiers[0].local.name + + const dynamicImports = node.specifiers.map((specifier) => { + if (specifier.type === 'ImportDefaultSpecifier') + return `default: ${specifier.local.name}` + + if (specifier.type === 'ImportSpecifier') { + const local = specifier.local.name + const imported = specifier.imported.name + if (local === imported) + return local + return `${imported}: ${local}` + } + + return null + }).filter(Boolean).join(', ') + + if (!dynamicImports.length) + return '' + + return `{ ${dynamicImports} }` +} + +const regexpHoistable = /^[ \t]*\b(vi|vitest)\s*\.\s*(mock|unmock|hoisted)\(/m +const hashbangRE = /^#!.*\n/ + +export function hoistMocks(code: string, id: string, parse: (code: string, options: any) => AcornNode) { + const hasMocks = regexpHoistable.test(code) + + if (!hasMocks) + return + + const s = new MagicString(code) + + let ast: any + try { + ast = parse(code, { + sourceType: 'module', + ecmaVersion: 'latest', + locations: true, + }) + } + catch (err) { + console.error(`Cannot parse ${id}:\n${(err as any).message}`) + return + } + + const hoistIndex = code.match(hashbangRE)?.[0].length ?? 0 + + let hoistedCode = '' + let hoistedVitestImports = '' + + // this will tranfrom import statements into dynamic ones, if there are imports + // it will keep the import as is, if we don't need to mock anything + // in browser environment it will wrap the module value with "vitest_wrap_module" function + // that returns a proxy to the module so that named exports can be mocked + const transformImportDeclaration = (node: ImportDeclaration) => { + const source = node.source.value as string + + // if we don't hijack ESM and process this file, then we definetly have mocks, + // so we need to transform imports into dynamic ones, so "vi.mock" can be executed before + const specifiers = transformImportSpecifiers(node) + const code = specifiers + ? `const ${specifiers} = await import('${source}')\n` + : `await import('${source}')\n` + return code + } + + function hoistImport(node: Positioned) { + // always hoist vitest import to top of the file, so + // "vi" helpers can access it + s.remove(node.start, node.end) + + if (node.source.value === 'vitest') { + const code = `const ${transformImportSpecifiers(node)} = await import('vitest')\n` + hoistedVitestImports += code + return + } + const code = transformImportDeclaration(node) + s.appendLeft(hoistIndex, code) + } + + // 1. check all import statements and record id -> importName map + for (const node of ast.body as Node[]) { + // import foo from 'foo' --> foo -> __import_foo__.default + // import { baz } from 'foo' --> baz -> __import_foo__.baz + // import * as ok from 'foo' --> ok -> __import_foo__ + if (node.type === 'ImportDeclaration') + hoistImport(node) + } + + simpleWalk(ast, { + CallExpression(_node) { + const node = _node as any as Positioned + if ( + node.callee.type === 'MemberExpression' + && isIdentifier(node.callee.object) + && (node.callee.object.name === 'vi' || node.callee.object.name === 'vitest') + && isIdentifier(node.callee.property) + ) { + const methodName = node.callee.property.name + + if (methodName === 'mock' || methodName === 'unmock') { + hoistedCode += `${code.slice(node.start, node.end)}\n` + s.remove(node.start, node.end) + } + + if (methodName === 'hoisted') { + const declarationNode = findNodeAround(ast, node.start, 'VariableDeclaration')?.node as Positioned | undefined + const init = declarationNode?.declarations[0]?.init + const isViHoisted = (node: CallExpression) => { + return node.callee.type === 'MemberExpression' + && isIdentifier(node.callee.object) + && (node.callee.object.name === 'vi' || node.callee.object.name === 'vitest') + && isIdentifier(node.callee.property) + && node.callee.property.name === 'hoisted' + } + + const canMoveDeclaration = (init + && init.type === 'CallExpression' + && isViHoisted(init)) /* const v = vi.hoisted() */ + || (init + && init.type === 'AwaitExpression' + && init.argument.type === 'CallExpression' + && isViHoisted(init.argument)) /* const v = await vi.hoisted() */ + + if (canMoveDeclaration) { + // hoist "const variable = vi.hoisted(() => {})" + hoistedCode += `${code.slice(declarationNode.start, declarationNode.end)}\n` + s.remove(declarationNode.start, declarationNode.end) + } + else { + // hoist "vi.hoisted(() => {})" + hoistedCode += `${code.slice(node.start, node.end)}\n` + s.remove(node.start, node.end) + } + } + } + }, + }) + + if (hoistedCode || hoistedVitestImports) { + s.prepend( + hoistedVitestImports + + ((!hoistedVitestImports && hoistedCode) ? API_NOT_FOUND_CHECK : '') + + hoistedCode, + ) + } + + return { + ast, + code: s.toString(), + map: s.generateMap({ hires: true, source: id }), + } +} diff --git a/packages/vitest/src/node/plugins/esmTransform.ts b/packages/vitest/src/node/plugins/esmTransform.ts deleted file mode 100644 index 8d72176910a6..000000000000 --- a/packages/vitest/src/node/plugins/esmTransform.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { Plugin } from 'vite' -import { injectVitestModule } from '../esmInjector' -import type { Vitest } from '../core' -import type { WorkspaceProject } from '../workspace' - -export function ESMTransformPlugin(ctx: WorkspaceProject | Vitest): Plugin { - return { - name: 'vitest:mocker-plugin', - enforce: 'post', - transform(source, id) { - return injectVitestModule(source, id, (code, options) => this.parse(code, options), { - hijackESM: (ctx.config.browser.enabled && ctx.config.slowHijackESM) ?? false, - cacheDir: ctx.server.config.cacheDir, - }) - }, - } -} diff --git a/packages/vitest/src/node/plugins/index.ts b/packages/vitest/src/node/plugins/index.ts index 2d8b41ce3244..effb776c795e 100644 --- a/packages/vitest/src/node/plugins/index.ts +++ b/packages/vitest/src/node/plugins/index.ts @@ -11,7 +11,7 @@ import { EnvReplacerPlugin } from './envReplacer' import { GlobalSetupPlugin } from './globalSetup' import { CSSEnablerPlugin } from './cssEnabler' import { CoverageTransform } from './coverageTransform' -import { ESMTransformPlugin } from './esmTransform' +import { MocksPlugin } from './mocks' export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('test')): Promise { const userConfig = deepMerge({}, options) as UserConfig @@ -243,7 +243,7 @@ export async function VitestPlugin(options: UserConfig = {}, ctx = new Vitest('t options.ui ? await UIPlugin() : null, - ESMTransformPlugin(ctx), + MocksPlugin(), ] .filter(notNullish) } diff --git a/packages/vitest/src/node/plugins/mocks.ts b/packages/vitest/src/node/plugins/mocks.ts new file mode 100644 index 000000000000..9628fe908606 --- /dev/null +++ b/packages/vitest/src/node/plugins/mocks.ts @@ -0,0 +1,12 @@ +import type { Plugin } from 'vite' +import { hoistMocks } from '../hoistMocks' + +export function MocksPlugin(): Plugin { + return { + name: 'vite:mocks', + enforce: 'post', + transform(code, id) { + return hoistMocks(code, id, this.parse) + }, + } +} diff --git a/packages/vitest/src/node/plugins/workspace.ts b/packages/vitest/src/node/plugins/workspace.ts index 29f5b1b32afd..bef905d8a90c 100644 --- a/packages/vitest/src/node/plugins/workspace.ts +++ b/packages/vitest/src/node/plugins/workspace.ts @@ -9,7 +9,7 @@ import { CoverageTransform } from './coverageTransform' import { CSSEnablerPlugin } from './cssEnabler' import { EnvReplacerPlugin } from './envReplacer' import { GlobalSetupPlugin } from './globalSetup' -import { ESMTransformPlugin } from './esmTransform' +import { MocksPlugin } from './mocks' interface WorkspaceOptions extends UserWorkspaceConfig { root?: string @@ -139,6 +139,6 @@ export function WorkspaceVitestPlugin(project: WorkspaceProject, options: Worksp ...CSSEnablerPlugin(project), CoverageTransform(project.ctx), GlobalSetupPlugin(project, project.ctx.logger), - ESMTransformPlugin(project), + MocksPlugin(), ] } diff --git a/packages/vitest/src/runtime/mocker.ts b/packages/vitest/src/runtime/mocker.ts index 0500a9026162..a3f617113b50 100644 --- a/packages/vitest/src/runtime/mocker.ts +++ b/packages/vitest/src/runtime/mocker.ts @@ -378,9 +378,4 @@ export class VitestMocker { public queueUnmock(id: string, importer: string) { VitestMocker.pendingIds.push({ type: 'unmock', id, importer }) } - - public async prepare() { - if (VitestMocker.pendingIds.length) - await this.resolveMocks() - } } diff --git a/packages/vitest/src/types/browser.ts b/packages/vitest/src/types/browser.ts index 2f1ede9b2ec2..9bfd32e464f6 100644 --- a/packages/vitest/src/types/browser.ts +++ b/packages/vitest/src/types/browser.ts @@ -51,6 +51,15 @@ export interface BrowserConfigOptions { * The default port is 63315. */ api?: ApiConfig | number + + /** + * Update ESM imports so they can be spied/stubbed with vi.spyOn. + * Enabled by default when running in browser. + * + * @default true + * @experimental + */ + slowHijackESM?: boolean } export interface ResolvedBrowserOptions extends BrowserConfigOptions { diff --git a/packages/vitest/src/types/config.ts b/packages/vitest/src/types/config.ts index 4493586825dd..54ec5e84e20f 100644 --- a/packages/vitest/src/types/config.ts +++ b/packages/vitest/src/types/config.ts @@ -424,15 +424,6 @@ export interface InlineConfig { */ uiBase?: string - /** - * Update ESM imports so they can be spied/stubbed with vi.spyOn. - * Enabled by default when running in browser. - * - * @default false - * @experimental - */ - slowHijackESM?: boolean - /** * Determine the transform method of modules */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c547a1e9bf61..4d0c985fbeac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -855,6 +855,9 @@ importers: specifier: ^2.0.2 version: 2.0.2 devDependencies: + '@types/estree': + specifier: ^1.0.1 + version: 1.0.1 '@types/ws': specifier: ^8.5.4 version: 8.5.4 @@ -867,6 +870,12 @@ importers: '@vitest/ws-client': specifier: workspace:* version: link:../ws-client + estree-walker: + specifier: ^3.0.3 + version: 3.0.3 + periscopic: + specifier: ^3.1.0 + version: 3.1.0 rollup: specifier: 3.20.2 version: 3.20.2 @@ -1203,9 +1212,6 @@ importers: debug: specifier: ^4.3.4 version: 4.3.4(supports-color@8.1.1) - estree-walker: - specifier: ^3.0.3 - version: 3.0.3 local-pkg: specifier: ^0.4.3 version: 0.4.3 @@ -1327,9 +1333,6 @@ importers: p-limit: specifier: ^4.0.0 version: 4.0.0 - periscopic: - specifier: ^3.1.0 - version: 3.1.0 pkg-types: specifier: ^1.0.2 version: 1.0.2 @@ -8214,12 +8217,13 @@ packages: /@types/estree@1.0.1: resolution: {integrity: sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==} + dev: true /@types/fs-extra@11.0.1: resolution: {integrity: sha512-MxObHvNl4A69ofaTRU8DFqvgzzv8s9yRtaPPm5gud9HDNvpB3GPQFvNuTWAI59B9huVGV5jXYJwbCsmBsOGYWA==} dependencies: '@types/jsonfile': 6.1.1 - '@types/node': 18.16.0 + '@types/node': 18.16.1 dev: true /@types/fs-extra@9.0.13: @@ -8375,10 +8379,6 @@ packages: /@types/node@18.15.11: resolution: {integrity: sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==} - /@types/node@18.16.0: - resolution: {integrity: sha512-BsAaKhB+7X+H4GnSjGhJG9Qi8Tw+inU9nJDwmD5CgOmBLEI6ArdhikpLX7DjbjDRDTbqZzU2LSQNZg8WGPiSZQ==} - dev: true - /@types/node@18.16.1: resolution: {integrity: sha512-DZxSZWXxFfOlx7k7Rv4LAyiMroaxa3Ly/7OOzZO8cBNho0YzAi4qlbrx8W27JGqG57IgR/6J7r+nOJWw6kcvZA==} dev: true @@ -8600,7 +8600,7 @@ packages: /@types/ws@8.5.4: resolution: {integrity: sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg==} dependencies: - '@types/node': 18.16.0 + '@types/node': 18.16.1 dev: true /@types/yargs-parser@21.0.0: @@ -14238,6 +14238,7 @@ packages: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} dependencies: '@types/estree': 1.0.1 + dev: true /esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} diff --git a/test/core/test/injector-esm.test.ts b/test/core/test/injector-esm.test.ts index 20d62f23a155..7d1fc5330426 100644 --- a/test/core/test/injector-esm.test.ts +++ b/test/core/test/injector-esm.test.ts @@ -1,5 +1,5 @@ import { Parser } from 'acorn' -import { injectVitestModule } from 'vitest/src/node/esmInjector' +import { injectVitestModule } from '@vitest/browser/src/node/esmInjector' import { expect, test } from 'vitest' import { transformWithEsbuild } from 'vite' @@ -756,6 +756,35 @@ export default (function getRandom() { `) }) +test('track scope in for loops', async () => { + expect( + injectSimpleCode(` +import { test } from './test.js' +for (const test of tests) { + console.log(test) +} +for (let test = 0; test < 10; test++) { + console.log(test) +} +for (const test in tests) { + console.log(test) +}`), + ).toMatchInlineSnapshot(` + "import { __vi_inject__ as __vi_esm_0__ } from './test.js' + + + for (const test of tests) { + console.log(test) + } + for (let test = 0; test < 10; test++) { + console.log(test) + } + for (const test in tests) { + console.log(test) + }" + `) +}) + // #8002 // test('with hashbang', async () => { // expect( diff --git a/test/core/test/injector-mock.test.ts b/test/core/test/injector-mock.test.ts index fb4bcc519c70..2ee3fb32a4e0 100644 --- a/test/core/test/injector-mock.test.ts +++ b/test/core/test/injector-mock.test.ts @@ -1,27 +1,17 @@ import { Parser } from 'acorn' -import { injectVitestModule } from 'vitest/src/node/esmInjector' +import { hoistMocks } from 'vitest/src/node/hoistMocks' import { expect, test } from 'vitest' function parse(code: string, options: any) { return Parser.parse(code, options) } -function injectSimpleCode(code: string) { - return injectVitestModule(code, '/test.js', parse, { - hijackESM: false, - cacheDir: '/tmp', - })?.code.trim() -} - -function injectHijackedCode(code: string) { - return injectVitestModule(code, '/test.js', parse, { - hijackESM: true, - cacheDir: '/tmp', - })?.code.trim() +function hoistSimpleCode(code: string) { + return hoistMocks(code, '/test.js', parse)?.code.trim() } test('hoists mock, unmock, hoisted', () => { - expect(injectSimpleCode(` + expect(hoistSimpleCode(` vi.mock('path', () => {}) vi.unmock('path') vi.hoisted(() => {}) @@ -34,33 +24,15 @@ test('hoists mock, unmock, hoisted', () => { }) test('always hoists import from vitest', () => { - expect(injectSimpleCode(` - import { vi } from 'vitest' - vi.mock('path', () => {}) - vi.unmock('path') - vi.hoisted(() => {}) - import { test } from 'vitest' - `)).toMatchInlineSnapshot(` - "import { vi } from 'vitest' - import { test } from 'vitest' - vi.mock('path', () => {}) - vi.unmock('path') - vi.hoisted(() => {})" - `) -}) - -test('always hoists mock, unmock, hoisted when modules are hijacked', () => { - expect(injectHijackedCode(` + expect(hoistSimpleCode(` import { vi } from 'vitest' vi.mock('path', () => {}) vi.unmock('path') vi.hoisted(() => {}) import { test } from 'vitest' `)).toMatchInlineSnapshot(` - "import { __vi_inject__ as __vi_esm_0__ } from 'vitest' - const { vi } = __vi_esm_0__; - import { __vi_inject__ as __vi_esm_1__ } from 'vitest' - const { test } = __vi_esm_1__; + "const { vi } = await import('vitest') + const { test } = await import('vitest') vi.mock('path', () => {}) vi.unmock('path') vi.hoisted(() => {})" @@ -68,7 +40,7 @@ test('always hoists mock, unmock, hoisted when modules are hijacked', () => { }) test('always hoists all imports but they are under mocks', () => { - expect(injectSimpleCode(` + expect(hoistSimpleCode(` import { vi } from 'vitest' import { someValue } from './path.js' import { someValue2 } from './path2.js' @@ -77,8 +49,8 @@ test('always hoists all imports but they are under mocks', () => { vi.hoisted(() => {}) import { test } from 'vitest' `)).toMatchInlineSnapshot(` - "import { vi } from 'vitest' - import { test } from 'vitest' + "const { vi } = await import('vitest') + const { test } = await import('vitest') vi.mock('path', () => {}) vi.unmock('path') vi.hoisted(() => {}) @@ -86,25 +58,3 @@ test('always hoists all imports but they are under mocks', () => { const { someValue2 } = await import('./path2.js')" `) }) - -test('always hoists all imports but they are under mocks when modules are hijacked', () => { - expect(injectHijackedCode(` - import { vi } from 'vitest' - import { someValue } from './path.js' - import { someValue2 } from './path2.js' - vi.mock('path', () => {}) - vi.unmock('path') - vi.hoisted(() => {}) - import { test } from 'vitest' - `)).toMatchInlineSnapshot(` - "import { __vi_inject__ as __vi_esm_0__ } from 'vitest' - const { vi } = __vi_esm_0__; - import { __vi_inject__ as __vi_esm_3__ } from 'vitest' - const { test } = __vi_esm_3__; - vi.mock('path', () => {}) - vi.unmock('path') - vi.hoisted(() => {}) - const { __vi_inject__: __vi_esm_1__ } = await __vi_wrap_module__(import('./path.js')) - const { __vi_inject__: __vi_esm_2__ } = await __vi_wrap_module__(import('./path2.js'))" - `) -})