diff --git a/examples/mocks/src/A.ts b/examples/mocks/src/A.ts new file mode 100644 index 000000000000..b4a6e8241fc4 --- /dev/null +++ b/examples/mocks/src/A.ts @@ -0,0 +1,5 @@ +import { funcB } from './B' + +export function funcA() { + return funcB +} diff --git a/examples/mocks/src/B.ts b/examples/mocks/src/B.ts new file mode 100644 index 000000000000..30d04569ef70 --- /dev/null +++ b/examples/mocks/src/B.ts @@ -0,0 +1,5 @@ +import { funcA } from './A' + +export function funcB() { + return funcA +} diff --git a/examples/mocks/src/main.js b/examples/mocks/src/main.js new file mode 100644 index 000000000000..be77a608ffc2 --- /dev/null +++ b/examples/mocks/src/main.js @@ -0,0 +1,5 @@ +import { funcA } from './A' + +export function main() { + return funcA() +} diff --git a/examples/mocks/test/axios-not-mocked.test.ts b/examples/mocks/test/axios-not-mocked.test.ts index bef3540bc909..3ec868be557e 100644 --- a/examples/mocks/test/axios-not-mocked.test.ts +++ b/examples/mocks/test/axios-not-mocked.test.ts @@ -1,7 +1,7 @@ import axios from 'axios' test('mocked axios', async () => { - const { default: ax } = await vi.importMock('axios') + const { default: ax } = await vi.importMock('axios') await ax.get('string') diff --git a/examples/mocks/test/circular.spec.ts b/examples/mocks/test/circular.spec.ts new file mode 100644 index 000000000000..a2f6440fd49b --- /dev/null +++ b/examples/mocks/test/circular.spec.ts @@ -0,0 +1,11 @@ +import { expect, test, vi } from 'vitest' +import { main } from '../src/main.js' + +vi.mock('../src/A', async () => ({ + ...(await vi.importActual('../src/A')), + funcA: () => 'mockedA', +})) + +test('main', () => { + expect(main()).toBe('mockedA') +}) diff --git a/examples/mocks/test/error-mock.spec.ts b/examples/mocks/test/error-mock.spec.ts index 4ccbf610ce7f..7edbfb3adf03 100644 --- a/examples/mocks/test/error-mock.spec.ts +++ b/examples/mocks/test/error-mock.spec.ts @@ -4,5 +4,5 @@ vi.mock('../src/default', () => { test('when using top level variable, gives helpful message', async () => { await expect(() => import('../src/default').then(m => m.default)).rejects - .toThrowErrorMatchingInlineSnapshot('"[vitest] There was an error, when mocking a module. If you are using vi.mock, make sure you are not using top level variables inside, since this call is hoisted. Read more: https://vitest.dev/api/#vi-mock"') + .toThrowErrorMatchingInlineSnapshot('"[vitest] There was an error, when mocking a module. If you are using \\"vi.mock\\" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. Read more: https://vitest.dev/api/#vi-mock"') }) diff --git a/examples/mocks/test/factory.test.ts b/examples/mocks/test/factory.test.ts index 6027470dfdf9..2ff5bc5d3581 100644 --- a/examples/mocks/test/factory.test.ts +++ b/examples/mocks/test/factory.test.ts @@ -57,7 +57,7 @@ describe('mocking with factory', () => { it('non-object return on factory gives error', async () => { await expect(() => import('../src/default').then(m => m.default)).rejects - .toThrowError('[vitest] vi.mock(path: string, factory?: () => unknown) is not returning an object. Did you mean to return an object with a "default" key?') + .toThrowError('[vitest] vi.mock("../src/default.ts", factory?: () => unknown) is not returning an object. Did you mean to return an object with a "default" key?') }) test('defined exports on mock', async () => { diff --git a/packages/coverage-c8/src/provider.ts b/packages/coverage-c8/src/provider.ts index 771fa4995331..dc8621acaa7d 100644 --- a/packages/coverage-c8/src/provider.ts +++ b/packages/coverage-c8/src/provider.ts @@ -85,7 +85,7 @@ export class C8CoverageProvider implements CoverageProvider { // This is a magic number. It corresponds to the amount of code // that we add in packages/vite-node/src/client.ts:114 (vm.runInThisContext) // TODO: Include our transformations in sourcemaps - const offset = 224 + const offset = 203 report._getSourceMap = (coverage: Profiler.ScriptCoverage) => { const path = _url.pathToFileURL(coverage.url.split('?')[0]).href diff --git a/packages/vite-node/package.json b/packages/vite-node/package.json index bc59bf35c684..b60181c184a4 100644 --- a/packages/vite-node/package.json +++ b/packages/vite-node/package.json @@ -70,7 +70,7 @@ }, "scripts": { "build": "rimraf dist && rollup -c", - "dev": "rollup -c --watch --watch.include=src -m inline", + "dev": "rollup -c --watch --watch.include 'src/**' -m inline", "prepublishOnly": "pnpm build", "typecheck": "tsc --noEmit" }, diff --git a/packages/vite-node/src/client.ts b/packages/vite-node/src/client.ts index fff7fcffedb9..4a19239b680a 100644 --- a/packages/vite-node/src/client.ts +++ b/packages/vite-node/src/client.ts @@ -1,10 +1,12 @@ import { createRequire } from 'module' +// we need native dirname, because windows __dirname has \\ +// eslint-disable-next-line no-restricted-imports +import { dirname } from 'path' import { fileURLToPath, pathToFileURL } from 'url' import vm from 'vm' -import { dirname, extname, isAbsolute, resolve } from 'pathe' -import { isNodeBuiltin } from 'mlly' +import { resolve } from 'pathe' import createDebug from 'debug' -import { cleanUrl, isPrimitive, normalizeModuleId, normalizeRequestId, slash, toFilePath } from './utils' +import { VALID_ID_PREFIX, cleanUrl, isInternalRequest, isPrimitive, normalizeModuleId, normalizeRequestId, slash, toFilePath } from './utils' import type { HotContext, ModuleCache, ViteNodeRunnerOptions } from './types' import { extractSourceMap } from './source-map' @@ -145,11 +147,13 @@ export class ViteNodeRunner { } async executeFile(file: string) { - return await this.cachedRequest(`/@fs/${slash(resolve(file))}`, []) + const url = `/@fs/${slash(resolve(file))}` + return await this.cachedRequest(url, url, []) } - async executeId(id: string) { - return await this.cachedRequest(id, []) + async executeId(rawId: string) { + const [id, url] = await this.resolveUrl(rawId) + return await this.cachedRequest(id, url, []) } getSourceMap(id: string) { @@ -157,12 +161,10 @@ export class ViteNodeRunner { } /** @internal */ - async cachedRequest(rawId: string, callstack: string[]) { - const id = normalizeRequestId(rawId, this.options.base) - const fsPath = toFilePath(id, this.root) + async cachedRequest(id: string, fsPath: string, callstack: string[]) { + const importee = callstack[callstack.length - 1] const mod = this.moduleCache.get(fsPath) - const importee = callstack[callstack.length - 1] if (!mod.importers) mod.importers = new Set() @@ -188,79 +190,68 @@ export class ViteNodeRunner { } } + async resolveUrl(id: string, importee?: string): Promise<[url: string, fsPath: string]> { + if (isInternalRequest(id)) + return [id, id] + // we don't pass down importee here, because otherwise Vite doesn't resolve it correctly + if (importee && id.startsWith(VALID_ID_PREFIX)) + importee = undefined + id = normalizeRequestId(id, this.options.base) + if (!this.options.resolveId) + return [id, toFilePath(id, this.root)] + const resolved = await this.options.resolveId(id, importee) + const resolvedId = resolved + ? normalizeRequestId(resolved.id, this.options.base) + : id + // to be compatible with dependencies that do not resolve id + const fsPath = resolved ? resolvedId : toFilePath(id, this.root) + return [resolvedId, fsPath] + } + /** @internal */ - async directRequest(id: string, fsPath: string, _callstack: string[]) { - const callstack = [..._callstack, fsPath] + async dependencyRequest(id: string, fsPath: string, callstack: string[]) { + const getStack = () => { + return `stack:\n${[...callstack, fsPath].reverse().map(p => `- ${p}`).join('\n')}` + } - let mod = this.moduleCache.get(fsPath) + let debugTimer: any + if (this.debug) + debugTimer = setTimeout(() => console.warn(() => `module ${fsPath} takes over 2s to load.\n${getStack()}`), 2000) - const request = async (dep: string) => { - const depFsPath = toFilePath(normalizeRequestId(dep, this.options.base), this.root) - const getStack = () => { - return `stack:\n${[...callstack, depFsPath].reverse().map(p => `- ${p}`).join('\n')}` + try { + if (callstack.includes(fsPath)) { + const depExports = this.moduleCache.get(fsPath)?.exports + if (depExports) + return depExports + throw new Error(`[vite-node] Failed to resolve circular dependency, ${getStack()}`) } - let debugTimer: any - if (this.debug) - debugTimer = setTimeout(() => console.warn(() => `module ${depFsPath} takes over 2s to load.\n${getStack()}`), 2000) - - try { - if (callstack.includes(depFsPath)) { - const depExports = this.moduleCache.get(depFsPath)?.exports - if (depExports) - return depExports - throw new Error(`[vite-node] Failed to resolve circular dependency, ${getStack()}`) - } - - return await this.cachedRequest(dep, callstack) - } - finally { - if (debugTimer) - clearTimeout(debugTimer) - } + return await this.cachedRequest(id, fsPath, callstack) + } + finally { + if (debugTimer) + clearTimeout(debugTimer) } + } - Object.defineProperty(request, 'callstack', { get: () => callstack }) - - const resolveId = async (dep: string, callstackPosition = 1): Promise<[dep: string, id: string | undefined]> => { - if (this.options.resolveId && this.shouldResolveId(dep)) { - let importer: string | undefined = callstack[callstack.length - callstackPosition] - if (importer && !dep.startsWith('.')) - importer = undefined - if (importer && importer.startsWith('mock:')) - importer = importer.slice(5) - const resolved = await this.options.resolveId(normalizeRequestId(dep), importer) - return [dep, resolved?.id] - } + /** @internal */ + async directRequest(id: string, fsPath: string, _callstack: string[]) { + const moduleId = normalizeModuleId(fsPath) + const callstack = [..._callstack, moduleId] - return [dep, undefined] - } + const mod = this.moduleCache.get(fsPath) - const [dep, resolvedId] = await resolveId(id, 2) + const request = async (dep: string) => { + const [id, depFsPath] = await this.resolveUrl(dep, fsPath) + return this.dependencyRequest(id, depFsPath, callstack) + } const requestStubs = this.options.requestStubs || DEFAULT_REQUEST_STUBS if (id in requestStubs) return requestStubs[id] // eslint-disable-next-line prefer-const - let { code: transformed, externalize } = await this.options.fetchModule(resolvedId || dep) - - // in case we resolved fsPath incorrectly, Vite will return the correct file path - // in that case we need to update cache, so we don't have the same module as different exports - // but we ignore fsPath that has custom query, because it might need to be different - if (resolvedId && !fsPath.includes('?') && fsPath !== resolvedId) { - if (this.moduleCache.has(resolvedId)) { - mod = this.moduleCache.get(resolvedId) - this.moduleCache.set(fsPath, mod) - if (mod.promise) - return mod.promise - if (mod.exports) - return mod.exports - } - else { - this.moduleCache.set(resolvedId, mod) - } - } + let { code: transformed, externalize } = await this.options.fetchModule(id) if (externalize) { debugNative(externalize) @@ -270,13 +261,12 @@ export class ViteNodeRunner { } if (transformed == null) - throw new Error(`[vite-node] Failed to load ${id}`) - - const file = cleanUrl(resolvedId || fsPath) + throw new Error(`[vite-node] Failed to load "${id}" imported from ${callstack[callstack.length - 2]}`) + const modulePath = cleanUrl(moduleId) // disambiguate the `:/` on windows: see nodejs/node#31710 - const url = pathToFileURL(file).href - const meta = { url } + const href = pathToFileURL(modulePath).href + const meta = { url: href } const exports = Object.create(null) Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module', @@ -305,7 +295,7 @@ export class ViteNodeRunner { }) Object.assign(mod, { code: transformed, exports }) - const __filename = fileURLToPath(url) + const __filename = fileURLToPath(href) const moduleProxy = { set exports(value) { exportAll(cjsExports, value) @@ -339,10 +329,9 @@ export class ViteNodeRunner { __vite_ssr_exports__: exports, __vite_ssr_exportAll__: (obj: any) => exportAll(exports, obj), __vite_ssr_import_meta__: meta, - __vitest_resolve_id__: resolveId, // cjs compact - require: createRequire(url), + require: createRequire(href), exports: cjsExports, module: moduleProxy, __filename, @@ -373,13 +362,6 @@ export class ViteNodeRunner { return context } - shouldResolveId(dep: string) { - if (isNodeBuiltin(dep) || dep in (this.options.requestStubs || DEFAULT_REQUEST_STUBS) || dep.startsWith('/@vite')) - return false - - return !isAbsolute(dep) || !extname(dep) - } - /** * Define if a module should be interop-ed * This function mostly for the ability to override by subclass diff --git a/packages/vite-node/src/utils.ts b/packages/vite-node/src/utils.ts index 7107d1c77567..b4d0f77c68df 100644 --- a/packages/vite-node/src/utils.ts +++ b/packages/vite-node/src/utils.ts @@ -1,7 +1,6 @@ import { fileURLToPath, pathToFileURL } from 'url' import { existsSync } from 'fs' -import { relative, resolve } from 'pathe' -import { isNodeBuiltin } from 'mlly' +import { resolve } from 'pathe' import type { Arrayable, Nullable } from './types' export const isWindows = process.platform === 'win32' @@ -14,6 +13,8 @@ export function mergeSlashes(str: string) { return str.replace(/\/\//g, '/') } +export const VALID_ID_PREFIX = '/@id/' + export function normalizeRequestId(id: string, base?: string): string { if (base && id.startsWith(base)) id = `/${id.slice(base.length)}` @@ -40,10 +41,14 @@ export const hashRE = /#.*$/s export const cleanUrl = (url: string): string => url.replace(hashRE, '').replace(queryRE, '') +export const isInternalRequest = (id: string): boolean => { + return id.startsWith('/@vite/') +} + export function normalizeModuleId(id: string) { return id .replace(/\\/g, '/') - .replace(/^\/@fs\//, '/') + .replace(/^\/@fs\//, isWindows ? '' : '/') .replace(/^file:\//, '/') .replace(/^\/+/, '/') } @@ -52,24 +57,6 @@ export function isPrimitive(v: any) { return v !== Object(v) } -export function pathFromRoot(root: string, filename: string) { - if (isNodeBuiltin(filename)) - return filename - - // don't replace with "/" on windows, "/C:/foo" is not a valid path - filename = filename.replace(/^\/@fs\//, isWindows ? '' : '/') - - if (!filename.startsWith(root)) - return filename - - const relativePath = relative(root, filename) - - const segments = relativePath.split('/') - const startIndex = segments.findIndex(segment => segment !== '..' && segment !== '.') - - return `/${segments.slice(startIndex).join('/')}` -} - export function toFilePath(id: string, root: string): string { let absolute = (() => { if (id.startsWith('/@fs/')) diff --git a/packages/vitest/src/integrations/vi.ts b/packages/vitest/src/integrations/vi.ts index 8d24ce2319d9..12cb89f21097 100644 --- a/packages/vitest/src/integrations/vi.ts +++ b/packages/vitest/src/integrations/vi.ts @@ -2,7 +2,7 @@ import type { FakeTimerInstallOpts } from '@sinonjs/fake-timers' import { parseStacktrace } from '../utils/source-map' import type { VitestMocker } from '../runtime/mocker' import type { ResolvedConfig, RuntimeConfig } from '../types' -import { getWorkerState, resetModules, setTimeout } from '../utils' +import { getWorkerState, resetModules, waitForImportsToResolve } from '../utils' import { FakeTimers } from './mock/timers' import type { EnhancedSpy, MaybeMocked, MaybeMockedDeep, MaybePartiallyMocked, MaybePartiallyMockedDeep } from './spy' import { fn, isMockFunction, spies, spyOn } from './spy' @@ -245,26 +245,12 @@ class VitestUtils { } /** - * Wait for all imports to load. - * Useful, if you have a synchronous call that starts - * importing a module that you cannot wait otherwise. + * Wait for all imports to load. Useful, if you have a synchronous call that starts + * importing a module that you cannot await otherwise. + * Will also wait for new imports, started during the wait. */ public async dynamicImportSettled() { - const state = getWorkerState() - const promises: Promise[] = [] - for (const mod of state.moduleCache.values()) { - if (mod.promise && !mod.evaluated) - promises.push(mod.promise) - } - if (!promises.length) - return - await Promise.allSettled(promises) - // wait until the end of the loop, so `.then` on modules is called, - // like in import('./example').then(...) - // also call dynamicImportSettled again in case new imports were added - await new Promise(resolve => setTimeout(resolve, 1)) - .then(() => Promise.resolve()) - .then(() => this.dynamicImportSettled()) + return waitForImportsToResolve() } private _config: null | ResolvedConfig = null diff --git a/packages/vitest/src/runtime/execute.ts b/packages/vitest/src/runtime/execute.ts index d2ac52d50d4f..e126bfea4764 100644 --- a/packages/vitest/src/runtime/execute.ts +++ b/packages/vitest/src/runtime/execute.ts @@ -12,8 +12,8 @@ export interface ExecuteOptions extends ViteNodeRunnerOptions { export async function executeInViteNode(options: ExecuteOptions & { files: string[] }) { const runner = new VitestRunner(options) - // provide the vite define variable in this context await runner.executeId('/@vite/env') + await runner.mocker.initializeSpyModule() const result: any[] = [] for (const file of options.files) @@ -23,20 +23,31 @@ export async function executeInViteNode(options: ExecuteOptions & { files: strin } export class VitestRunner extends ViteNodeRunner { + public mocker: VitestMocker + constructor(public options: ExecuteOptions) { super(options) + + this.mocker = new VitestMocker(this) } - prepareContext(context: Record) { - const request = context.__vite_ssr_import__ - const resolveId = context.__vitest_resolve_id__ - const resolveUrl = async (dep: string) => { - const [id, resolvedId] = await resolveId(dep) - return resolvedId || id - } + async resolveUrl(id: string, importee?: string) { + if (importee && importee.startsWith('mock:')) + importee = importee.slice(5) + return super.resolveUrl(id, importee) + } + + async dependencyRequest(id: string, fsPath: string, callstack: string[]): Promise { + const mocked = await this.mocker.requestWithMock(fsPath, callstack) - const mocker = new VitestMocker(this.options, this.moduleCache, request) + if (typeof mocked === 'string') + return super.dependencyRequest(mocked, mocked, callstack) + if (mocked && typeof mocked === 'object') + return mocked + return super.dependencyRequest(id, fsPath, callstack) + } + prepareContext(context: Record) { const workerState = getWorkerState() // support `import.meta.vitest` for test entry @@ -46,9 +57,7 @@ export class VitestRunner extends ViteNodeRunner { } return Object.assign(context, { - __vite_ssr_import__: async (dep: string) => mocker.requestWithMock(await resolveUrl(dep)), - __vite_ssr_dynamic_import__: async (dep: string) => mocker.requestWithMock(await resolveUrl(dep)), - __vitest_mocker__: mocker, + __vitest_mocker__: this.mocker, }) } } diff --git a/packages/vitest/src/runtime/mocker.ts b/packages/vitest/src/runtime/mocker.ts index 8b00af40197d..ac124cdb5a6a 100644 --- a/packages/vitest/src/runtime/mocker.ts +++ b/packages/vitest/src/runtime/mocker.ts @@ -1,13 +1,11 @@ import { existsSync, readdirSync } from 'fs' import { isNodeBuiltin } from 'mlly' -import { basename, dirname, extname, join, resolve } from 'pathe' -import { normalizeRequestId, pathFromRoot } from 'vite-node/utils' -import type { ModuleCacheMap } from 'vite-node/client' +import { basename, dirname, extname, isAbsolute, join, resolve } from 'pathe' import c from 'picocolors' -import { getAllMockableProperties, getType, getWorkerState, mergeSlashes, slash } from '../utils' +import { getAllMockableProperties, getType, getWorkerState } from '../utils' import { distDir } from '../constants' import type { PendingSuiteMock } from '../types/mocker' -import type { ExecuteOptions } from './execute' +import type { VitestRunner } from './execute' class RefTracker { private idMap = new Map() @@ -37,32 +35,30 @@ function isSpecialProp(prop: Key, parentType: string) { && ['arguments', 'callee', 'caller', 'length', 'name'].includes(prop) } -interface ViteRunnerRequest { - (dep: string): any - callstack: string[] -} - export class VitestMocker { private static pendingIds: PendingSuiteMock[] = [] + private static spyModulePath = resolve(distDir, 'spy.js') private static spyModule?: typeof import('../integrations/spy') private resolveCache = new Map>() constructor( - public options: ExecuteOptions, - private moduleCache: ModuleCacheMap, - private request: ViteRunnerRequest, + public runner: VitestRunner, ) {} private get root() { - return this.options.root + return this.runner.options.root } private get base() { - return this.options.base + return this.runner.options.base } private get mockMap() { - return this.options.mockMap + return this.runner.options.mockMap + } + + private get moduleCache() { + return this.runner.moduleCache } public getSuiteFilepath(): string { @@ -80,25 +76,26 @@ export class VitestMocker { } } - private async resolvePath(id: string, importer: string) { - const path = await this.options.resolveId!(id, importer) + private async resolvePath(rawId: string, importer: string) { + const [id, fsPath] = await this.runner.resolveUrl(rawId, importer) // external is node_module or unresolved module // for example, some people mock "vscode" and don't have it installed - const external = path == null || path.id.includes('/node_modules/') ? id : null + const external = !isAbsolute(fsPath) || fsPath.includes('/node_modules/') ? rawId : null return { - path: normalizeRequestId(path?.id || id), + id, + fsPath, external, } } private async resolveMocks() { await Promise.all(VitestMocker.pendingIds.map(async (mock) => { - const { path, external } = await this.resolvePath(mock.id, mock.importer) + const { fsPath, external } = await this.resolvePath(mock.id, mock.importer) if (mock.type === 'unmock') - this.unmockPath(path) + this.unmockPath(fsPath) if (mock.type === 'mock') - this.mockPath(mock.id, path, external, mock.factory) + this.mockPath(mock.id, fsPath, external, mock.factory) })) VitestMocker.pendingIds = [] @@ -115,18 +112,18 @@ export class VitestMocker { catch (err) { const vitestError = new Error( '[vitest] There was an error, when mocking a module. ' - + 'If you are using vi.mock, make sure you are not using top level variables inside, since this call is hoisted. ' + + 'If you are using "vi.mock" factory, make sure there are no top level variables inside, since this call is hoisted to top of the file. ' + 'Read more: https://vitest.dev/api/#vi-mock') vitestError.cause = err throw vitestError } - if (exports === null || typeof exports !== 'object') - throw new Error('[vitest] vi.mock(path: string, factory?: () => unknown) is not returning an object. Did you mean to return an object with a "default" key?') - - const filepath = dep.slice('mock:'.length) + const filepath = dep.slice(5) const mockpath = this.resolveCache.get(this.getSuiteFilepath())?.[filepath] || filepath + if (exports === null || typeof exports !== 'object') + throw new Error(`[vitest] vi.mock("${mockpath}", factory?: () => unknown) is not returning an object. Did you mean to return an object with a "default" key?`) + const moduleExports = new Proxy(exports, { get(target, prop) { const val = target[prop] @@ -145,7 +142,7 @@ export class VitestMocker { const actual = await vi.importActual("${mockpath}") return { ...actual, - // your mock + // your mocked methods }, })`)}\n`, ) @@ -160,7 +157,7 @@ export class VitestMocker { return moduleExports } - private getMockPath(dep: string) { + public getMockPath(dep: string) { return `mock:${dep}` } @@ -169,18 +166,11 @@ export class VitestMocker { } public normalizePath(path: string) { - return pathFromRoot(this.root, normalizeRequestId(path, this.base)) - } - - public getFsPath(path: string, external: string | null) { - if (external) - return mergeSlashes(`/@fs/${path}`) - - return normalizeRequestId(path, this.base) + return this.moduleCache.normalizePath(path) } public resolveMockPath(mockPath: string, external: string | null) { - const path = normalizeRequestId(external || mockPath) + const path = external || mockPath // it's a node_module alias // all mocks should be inside /__mocks__ @@ -327,17 +317,15 @@ export class VitestMocker { this.resolveCache.set(suitefile, resolves) } - public async importActual(id: string, importer: string): Promise { - const { path, external } = await this.resolvePath(id, importer) - const fsPath = this.getFsPath(path, external) - const result = await this.request(fsPath) + public async importActual(rawId: string, importee: string): Promise { + const { id, fsPath } = await this.resolvePath(rawId, importee) + const result = await this.runner.cachedRequest(id, fsPath, [importee]) return result as T } - public async importMock(id: string, importer: string): Promise { - const { path, external } = await this.resolvePath(id, importer) + public async importMock(rawId: string, importee: string): Promise { + const { id, fsPath, external } = await this.resolvePath(rawId, importee) - const fsPath = this.getFsPath(path, external) const normalizedId = this.normalizePath(fsPath) let mock = this.getDependencyMock(normalizedId) @@ -345,32 +333,28 @@ export class VitestMocker { mock = this.resolveMockPath(fsPath, external) if (mock === null) { - await this.ensureSpy() - const mod = await this.request(fsPath) + const mod = await this.runner.cachedRequest(id, fsPath, [importee]) return this.mockObject(mod) } if (typeof mock === 'function') return this.callFunctionMock(fsPath, mock) - return this.requestWithMock(mock) + return this.runner.dependencyRequest(mock, mock, [importee]) } - private async ensureSpy() { + public async initializeSpyModule() { if (VitestMocker.spyModule) return - VitestMocker.spyModule = await this.request(`/@fs/${slash(resolve(distDir, 'spy.js'))}`) as typeof import('../integrations/spy') + VitestMocker.spyModule = await this.runner.executeId(VitestMocker.spyModulePath) } - public async requestWithMock(dep: string) { - await Promise.all([ - this.ensureSpy(), - this.resolveMocks(), - ]) + public async requestWithMock(url: string, callstack: string[]) { + if (VitestMocker.pendingIds.length) + await this.resolveMocks() - const id = this.normalizePath(dep) + const id = this.normalizePath(url) const mock = this.getDependencyMock(id) - const callstack = this.request.callstack const mockPath = this.getMockPath(id) if (mock === null) { @@ -381,11 +365,11 @@ export class VitestMocker { const exports = {} // Assign the empty exports object early to allow for cycles to work. The object will be filled by mockObject() this.moduleCache.set(mockPath, { exports }) - const mod = await this.request(dep) + const mod = await this.runner.directRequest(url, url, []) this.mockObject(mod, exports) return exports } - if (typeof mock === 'function' && !callstack.includes(mockPath)) { + if (typeof mock === 'function' && !callstack.includes(mockPath) && !callstack.includes(url)) { callstack.push(mockPath) const result = await this.callFunctionMock(mockPath, mock) const indexMock = callstack.indexOf(mockPath) @@ -393,8 +377,8 @@ export class VitestMocker { return result } if (typeof mock === 'string' && !callstack.includes(mock)) - dep = mock - return this.request(dep) + url = mock + return url } public queueMock(id: string, importer: string, factory?: () => unknown) { diff --git a/packages/vitest/src/utils/import.ts b/packages/vitest/src/utils/import.ts new file mode 100644 index 000000000000..2c964054d032 --- /dev/null +++ b/packages/vitest/src/utils/import.ts @@ -0,0 +1,21 @@ +import { getWorkerState } from './global' +import { setTimeout } from './timers' + +export async function waitForImportsToResolve(tries = 0) { + await new Promise(resolve => setTimeout(resolve, 0)) + const state = getWorkerState() + const promises: Promise[] = [] + for (const mod of state.moduleCache.values()) { + if (mod.promise && !mod.evaluated) + promises.push(mod.promise) + } + if (!promises.length && tries >= 3) + return + await Promise.allSettled(promises) + // wait until the end of the loop, so `.then` on modules is called, + // like in import('./example').then(...) + // also call dynamicImportSettled again in case new imports were added + await new Promise(resolve => setTimeout(resolve, 1)) + .then(() => Promise.resolve()) + .then(() => waitForImportsToResolve(tries + 1)) +} diff --git a/packages/vitest/src/utils/index.ts b/packages/vitest/src/utils/index.ts index 7db9215e170b..5f9927ae72a0 100644 --- a/packages/vitest/src/utils/index.ts +++ b/packages/vitest/src/utils/index.ts @@ -14,6 +14,7 @@ export * from './tasks' export * from './base' export * from './global' export * from './timers' +export * from './import' export * from './env' export const isWindows = isNode && process.platform === 'win32' diff --git a/packages/vitest/src/utils/source-map.ts b/packages/vitest/src/utils/source-map.ts index 9c7584169854..e987e3d44ad1 100644 --- a/packages/vitest/src/utils/source-map.ts +++ b/packages/vitest/src/utils/source-map.ts @@ -1,14 +1,15 @@ +import { resolve } from 'pathe' import type { ErrorWithDiff, ParsedStack, Position } from '../types' -import { notNullish, slash } from './base' +import { notNullish } from './base' export const lineSplitRE = /\r?\n/ const stackIgnorePatterns = [ 'node:internal', '/vitest/dist/', - '/vite-node/dist', - '/vite-node/src', '/vitest/src/', + '/vite-node/dist/', + '/vite-node/src/', '/node_modules/chai/', '/node_modules/tinypool/', '/node_modules/tinyspy/', @@ -71,12 +72,15 @@ export function parseStacktrace(e: ErrorWithDiff, full = false): ParsedStack[] { if (file.startsWith('file://')) file = file.slice(7) + // normalize Windows path (\ -> /) + file = resolve(file) + if (!full && stackIgnorePatterns.some(p => file && file.includes(p))) return null return { method, - file: slash(file), + file, line: parseInt(lineNumber), column: parseInt(columnNumber), } diff --git a/packages/web-worker/src/pure.ts b/packages/web-worker/src/pure.ts index da2c6d591ffc..1416c95be0f1 100644 --- a/packages/web-worker/src/pure.ts +++ b/packages/web-worker/src/pure.ts @@ -1,7 +1,6 @@ /* eslint-disable no-restricted-imports */ import { VitestRunner } from 'vitest/node' import type { WorkerGlobalState } from 'vitest' -import { toFilePath } from 'vite-node/utils' function getWorkerState(): WorkerGlobalState { // @ts-expect-error untyped global @@ -125,12 +124,10 @@ export function defineWebWorker() { const id = (url instanceof URL ? url.toString() : url).replace(/^file:\/+/, '/') - const fsPath = toFilePath(id, config.root) - - runner.executeFile(fsPath) - .then(() => { + runner.resolveUrl(id).then(([, fsPath]) => { + runner.executeFile(fsPath).then(() => { // worker should be new every time, invalidate its sub dependency - moduleCache.invalidateSubDepTree([fsPath, `mock:${fsPath}`]) + moduleCache.invalidateSubDepTree([fsPath, runner.mocker.getMockPath(fsPath)]) const q = this.messageQueue this.messageQueue = null if (q) @@ -140,6 +137,7 @@ export function defineWebWorker() { this.onerror?.(e) console.error(e) }) + }) } dispatchEvent(event: Event) { diff --git a/test/core/src/aliased-mod.ts b/test/core/src/aliased-mod.ts index 2f33bf831eb7..e1b171255c0c 100644 --- a/test/core/src/aliased-mod.ts +++ b/test/core/src/aliased-mod.ts @@ -1 +1,7 @@ export const isAliased = true + +export const getPaths = () => ({ + __filename, + __dirname, + url: import.meta.url, +}) diff --git a/test/core/test/file-path.test.ts b/test/core/test/file-path.test.ts index 4f4271ce2a9e..a848dcafd527 100644 --- a/test/core/test/file-path.test.ts +++ b/test/core/test/file-path.test.ts @@ -1,12 +1,81 @@ import { existsSync } from 'fs' import { describe, expect, it, vi } from 'vitest' import { isWindows, slash, toFilePath } from '../../../packages/vite-node/src/utils' +// @ts-expect-error aliased to ../src/aliased-mod.ts +import { getPaths as getAbsoluteAliasedPaths } from '$/aliased-mod' +// @ts-expect-error aliased to ../src/aliased-mod.ts +import { getPaths as getRelativeAliasedPath } from '@/aliased-mod' vi.mock('fs') +describe('test aliased paths', () => { + it('expect functions to be part of the same module', () => { + expect(getAbsoluteAliasedPaths).toBe(getRelativeAliasedPath) + }) + + it.runIf(!isWindows)('paths on unix', () => { + const paths = getAbsoluteAliasedPaths() + expect(paths.url).toMatch(/\/aliased-mod.ts$/) + expect(paths.__filename).toMatch(/\/aliased-mod.ts$/) + expect(paths.__dirname).toMatch(/\/core\/src$/) + }) + + it.runIf(isWindows)('paths on windows', () => { + const paths = getAbsoluteAliasedPaths() + expect(paths.url).toMatch(/\/aliased-mod.ts$/) + expect(paths.__filename).toMatch(/\\aliased-mod.ts$/) + expect(paths.__dirname).toMatch(/\\core\\src$/) + }) +}) + +describe('current url', () => { + describe.runIf(!isWindows)('unix', () => { + it('__filename', () => { + expect(__filename.startsWith('file://')).toBe(false) + expect(__filename.endsWith('test/core/test/file-path.test.ts')).toBe(true) + }) + + it('__dirname', () => { + expect(__dirname.startsWith('file://')).toBe(false) + expect(__dirname.endsWith('test/core/test')).toBe(true) + }) + + it('import.meta.url', () => { + expect(import.meta.url.startsWith('file://')).toBe(true) + expect(import.meta.url.endsWith('test/core/test/file-path.test.ts')).toBe(true) + }) + }) + + describe.runIf(isWindows)('windows', () => { + // consistently inconsistent with Node, CJS has \, ESM has / + const cwd = process.cwd() + const windowsDrive = `${cwd[0].toUpperCase()}:\\` + const drivePosix = `${cwd[0].toUpperCase()}:/` + + it('__filename', () => { + expect(__filename.startsWith('file://')).toBe(false) + expect(__filename.startsWith(windowsDrive + windowsDrive)).toBe(false) + expect(__filename.startsWith(windowsDrive)).toBe(true) + expect(__filename.endsWith('\\test\\core\\test\\file-path.test.ts')).toBe(true) + }) + + it('__dirname', () => { + expect(__dirname.startsWith('file://')).toBe(false) + expect(__dirname.startsWith(windowsDrive + windowsDrive)).toBe(false) + expect(__dirname.startsWith(windowsDrive)).toBe(true) + expect(__dirname.endsWith('\\test\\core\\test')).toBe(true) + }) + + it('import.meta.url', () => { + expect(import.meta.url.startsWith(`file:///${drivePosix}`)).toBe(true) + expect(import.meta.url.endsWith('test/core/test/file-path.test.ts')).toBe(true) + }) + }) +}) + describe('toFilePath', () => { // the following tests will work incorrectly on unix systems - if (isWindows) { + describe.runIf(isWindows)('windows', () => { it('windows', () => { const root = 'C:/path/to/project' const id = '/node_modules/pkg/file.js' @@ -30,10 +99,10 @@ describe('toFilePath', () => { expect(slash(filePath)).toEqual(expected) }) - } + }) // the following tests will work incorrectly on windows systems - if (!isWindows) { + describe.runIf(!isWindows)('unix', () => { it('unix', () => { const root = '/path/to/project' const id = '/node_modules/pkg/file.js' @@ -116,5 +185,5 @@ describe('toFilePath', () => { expect(slash(filePath)).toEqual(id) }) - } + }) }) diff --git a/test/core/vitest.config.ts b/test/core/vitest.config.ts index 1b28a90cef9a..b5cc98d3ddb4 100644 --- a/test/core/vitest.config.ts +++ b/test/core/vitest.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ resolve: { alias: [ { find: '@', replacement: resolve(__dirname, 'src') }, + { find: '$', replacement: 'src' }, ], }, test: { diff --git a/test/coverage-test/coverage-report-tests/__snapshots__/c8.report.test.ts.snap b/test/coverage-test/coverage-report-tests/__snapshots__/c8.report.test.ts.snap index 1d61157475ac..bf75cc04c8a3 100644 --- a/test/coverage-test/coverage-report-tests/__snapshots__/c8.report.test.ts.snap +++ b/test/coverage-test/coverage-report-tests/__snapshots__/c8.report.test.ts.snap @@ -386,7 +386,7 @@ exports[`c8 json report 1`] = ` "line": 8, "loc": { "end": { - "column": 13, + "column": 25, "line": 8, }, "start": { @@ -397,7 +397,7 @@ exports[`c8 json report 1`] = ` "locations": [ { "end": { - "column": 13, + "column": 25, "line": 8, }, "start": { @@ -839,7 +839,7 @@ exports[`c8 json report 1`] = ` "line": 7, "loc": { "end": { - "column": 56, + "column": 55, "line": 7, }, "start": { @@ -850,7 +850,7 @@ exports[`c8 json report 1`] = ` "locations": [ { "end": { - "column": 56, + "column": 55, "line": 7, }, "start": { @@ -2220,8 +2220,8 @@ exports[`c8 json report 1`] = ` "line": 16, "loc": { "end": { - "column": 9, - "line": 18, + "column": 51, + "line": 16, }, "start": { "column": 4, @@ -2231,8 +2231,8 @@ exports[`c8 json report 1`] = ` "locations": [ { "end": { - "column": 9, - "line": 18, + "column": 51, + "line": 16, }, "start": { "column": 4, @@ -2583,7 +2583,7 @@ exports[`c8 json report 1`] = ` "13": 1, "14": 1, "15": 1, - "16": 0, + "16": 1, "17": 1, "18": 1, "19": 1, diff --git a/test/global-setup/test/global-setup.test.ts b/test/global-setup/test/global-setup.test.ts index 9a8b50514d28..2913a0153714 100644 --- a/test/global-setup/test/global-setup.test.ts +++ b/test/global-setup/test/global-setup.test.ts @@ -18,7 +18,7 @@ afterEach(async () => { }) test('server running', async () => { - const res = await (await fetch('http://localhost:9876')).text() + const res = await (await fetch('http://0.0.0.0:9876')).text() expect(res).toBe('Hello Vitest\n') }) diff --git a/test/global-setup/vitest.config.ts b/test/global-setup/vitest.config.ts index 288960df206a..738bf88c358c 100644 --- a/test/global-setup/vitest.config.ts +++ b/test/global-setup/vitest.config.ts @@ -1,17 +1,19 @@ import { defineConfig } from 'vite' export default defineConfig({ - plugins: [{ - name: 'a-vitest-plugin-that-changes-config', - config: () => ({ - test: { - setupFiles: [ - './setupFiles/add-something-to-global.ts', - 'setupFiles/without-relative-path-prefix.ts', - ], - }, - }), - }], + plugins: [ + { + name: 'a-vitest-plugin-that-changes-config', + config: () => ({ + test: { + setupFiles: [ + './setupFiles/add-something-to-global.ts', + 'setupFiles/without-relative-path-prefix.ts', + ], + }, + }), + }, + ], test: { globals: true, globalSetup: [ diff --git a/test/vite-node/test/utils.test.ts b/test/vite-node/test/utils.test.ts deleted file mode 100644 index 18a37bac95db..000000000000 --- a/test/vite-node/test/utils.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, test } from 'vitest' -import { pathFromRoot } from 'vite-node/utils' - -describe('vite-node utils', () => { - test('usual path from root returns correct path', () => { - const root = '/Users/name/project' - const filename = '/Users/name/project/test.ts' - expect(pathFromRoot(root, filename)).toBe('/test.ts') - }) - - test('correct path when file and directory share a name', () => { - const root = '/Users/name/project/test' - const filename = '/Users/name/project/test/test/test.ts' - expect(pathFromRoot(root, filename)).toBe('/test/test.ts') - }) - - test('correct path for node builtins', () => { - const root = '/Users/name/project' - const filename = 'fs' - expect(pathFromRoot(root, filename)).toBe('fs') - }) - - test('correct path when relative path has back symbols', () => { - const root = '/Users/name/project' - const filename = '/Users/name/project/../test/test.ts' - expect(pathFromRoot(root, filename)).toBe('/test/test.ts') - }) - - test('correct path when name has a dot at the start', () => { - const root = '/Users/name/project' - const filename = '/Users/name/project/.test.ts' - expect(pathFromRoot(root, filename)).toBe('/.test.ts') - }) - - test('correct path when subfolder has a dot at the start', () => { - const root = '/Users/name/project' - const filename = '/Users/name/project/../.test/test.ts' - expect(pathFromRoot(root, filename)).toBe('/.test/test.ts') - }) -})