From 72e429adbe363bf75585ec90ea22259e85fa7cd4 Mon Sep 17 00:00:00 2001 From: Vladimir Sheremet Date: Thu, 8 Dec 2022 11:39:18 +0100 Subject: [PATCH] feat!: rewrite how vite-node resolves id --- examples/mocks/test/axios-not-mocked.test.ts | 2 +- packages/coverage-c8/src/provider.ts | 2 +- packages/vite-node/src/client.ts | 122 ++++++++----------- packages/vitest/src/integrations/vi.ts | 24 +--- packages/vitest/src/runtime/execute.ts | 31 +++-- packages/vitest/src/runtime/mocker.ts | 75 ++++++------ packages/vitest/src/utils/import.ts | 21 ++++ packages/vitest/src/utils/index.ts | 1 + packages/web-worker/src/pure.ts | 31 +++-- 9 files changed, 151 insertions(+), 158 deletions(-) create mode 100644 packages/vitest/src/utils/import.ts 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/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/src/client.ts b/packages/vite-node/src/client.ts index 50c058328a5c..e1364e1201eb 100644 --- a/packages/vite-node/src/client.ts +++ b/packages/vite-node/src/client.ts @@ -145,11 +145,14 @@ export class ViteNodeRunner { } async executeFile(file: string) { - return await this.cachedRequest(`/@fs/${slash(resolve(file))}`, []) + const id = slash(resolve(file)) + const url = `/@fs/${slash(resolve(file))}` + return await this.cachedRequest(id, url, []) } async executeId(id: string) { - return await this.cachedRequest(id, []) + const url = await this.resolveUrl(id) + return await this.cachedRequest(id, url, []) } getSourceMap(id: string) { @@ -157,27 +160,25 @@ export class ViteNodeRunner { } /** @internal */ - async cachedRequest(rawId: string, callstack: string[]) { - const id = normalizeRequestId(rawId, this.options.base) - const fsPath = toFilePath(id, this.root) - - const mod = this.moduleCache.get(fsPath) + async cachedRequest(rawId: string, url: string, callstack: string[]) { const importee = callstack[callstack.length - 1] + const mod = this.moduleCache.get(url) + if (!mod.importers) mod.importers = new Set() if (importee) mod.importers.add(importee) // the callstack reference itself circularly - if (callstack.includes(fsPath) && mod.exports) + if (callstack.includes(url) && mod.exports) return mod.exports // cached module if (mod.promise) return mod.promise - const promise = this.directRequest(id, fsPath, callstack) + const promise = this.directRequest(rawId, url, callstack) Object.assign(mod, { promise, evaluated: false }) promise.finally(() => { @@ -187,79 +188,60 @@ export class ViteNodeRunner { return await promise } + async resolveUrl(url: string, importee?: string) { + url = normalizeRequestId(url, this.options.base) + if (!this.options.resolveId) + return toFilePath(url, this.root) + if (importee && url[0] !== '.') + importee = undefined + const resolved = await this.options.resolveId(url, importee) + const resolvedId = resolved?.id || url + return normalizeRequestId(resolvedId, this.options.base) + } + /** @internal */ - async directRequest(id: string, fsPath: string, _callstack: string[]) { - const callstack = [..._callstack, fsPath] + async dependencyRequest(id: string, url: string, callstack: string[]) { + const getStack = () => { + return `stack:\n${[...callstack, url].reverse().map(p => `- ${p}`).join('\n')}` + } - let mod = this.moduleCache.get(fsPath) + let debugTimer: any + if (this.debug) + debugTimer = setTimeout(() => console.warn(() => `module ${url} 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(url)) { + const depExports = this.moduleCache.get(url)?.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, url, 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 callstack = [..._callstack, fsPath] - return [dep, undefined] - } + const mod = this.moduleCache.get(fsPath) - const [dep, resolvedId] = await resolveId(id, 2) + const request = async (dep: string) => { + const depFsPath = await this.resolveUrl(dep, fsPath) + return this.dependencyRequest(dep, 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(fsPath) if (externalize) { debugNative(externalize) @@ -269,10 +251,9 @@ export class ViteNodeRunner { } if (transformed == null) - throw new Error(`[vite-node] Failed to load ${id}`) + throw new Error(`[vite-node] Failed to load "${id}" imported from ${callstack[callstack.length - 2]}`) - const file = cleanUrl(resolvedId || fsPath) - // console.log('file', file) + const file = cleanUrl(fsPath) // disambiguate the `:/` on windows: see nodejs/node#31710 const url = pathToFileURL(file).href const meta = { url } @@ -338,7 +319,6 @@ 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), 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..dcf131a32256 100644 --- a/packages/vitest/src/runtime/execute.ts +++ b/packages/vitest/src/runtime/execute.ts @@ -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(url: string, importee?: string): Promise { + if (importee && importee.startsWith('mock:')) + importee = importee.slice(5) + return super.resolveUrl(url, importee) + } + + async dependencyRequest(id: string, url: string, callstack: string[]): Promise { + const mocked = await this.mocker.requestWithMock(url, callstack) - const mocker = new VitestMocker(this.options, this.moduleCache, request) + if (typeof mocked === 'string') + return super.dependencyRequest(id, mocked, callstack) + if (mocked && typeof mocked === 'object') + return mocked + return super.cachedRequest(id, url, 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..96f732f2c231 100644 --- a/packages/vitest/src/runtime/mocker.ts +++ b/packages/vitest/src/runtime/mocker.ts @@ -1,13 +1,12 @@ 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 { normalizeRequestId } from 'vite-node/utils' import c from 'picocolors' -import { getAllMockableProperties, getType, getWorkerState, mergeSlashes, slash } from '../utils' +import { getAllMockableProperties, getType, getWorkerState, mergeSlashes } 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 +36,29 @@ 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 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 { @@ -81,13 +77,13 @@ export class VitestMocker { } private async resolvePath(id: string, importer: string) { - const path = await this.options.resolveId!(id, importer) + const path = await this.runner.resolveUrl(id, 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 = path.includes('/node_modules/') ? id : null return { - path: normalizeRequestId(path?.id || id), + path: normalizeRequestId(path), external, } } @@ -160,7 +156,7 @@ export class VitestMocker { return moduleExports } - private getMockPath(dep: string) { + public getMockPath(dep: string) { return `mock:${dep}` } @@ -169,7 +165,7 @@ export class VitestMocker { } public normalizePath(path: string) { - return pathFromRoot(this.root, normalizeRequestId(path, this.base)) + return path } public getFsPath(path: string, external: string | null) { @@ -327,50 +323,51 @@ 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(id: string, importee: string): Promise { + const { path } = await this.resolvePath(id, importee) + const result = await this.runner.cachedRequest(id, path, [importee]) return result as T } - public async importMock(id: string, importer: string): Promise { - const { path, external } = await this.resolvePath(id, importer) + public async importMock(id: string, importee: string): Promise { + const { path, external } = await this.resolvePath(id, importee) - const fsPath = this.getFsPath(path, external) - const normalizedId = this.normalizePath(fsPath) + const normalizedId = this.normalizePath(path) let mock = this.getDependencyMock(normalizedId) if (mock === undefined) - mock = this.resolveMockPath(fsPath, external) + mock = this.resolveMockPath(path, external) if (mock === null) { await this.ensureSpy() - const mod = await this.request(fsPath) + const mod = await this.runner.cachedRequest(id, path, [importee]) return this.mockObject(mod) } if (typeof mock === 'function') - return this.callFunctionMock(fsPath, mock) - return this.requestWithMock(mock) + return this.callFunctionMock(path, mock) + return this.runner.dependencyRequest(id, mock, [importee]) } private async ensureSpy() { if (VitestMocker.spyModule) return - VitestMocker.spyModule = await this.request(`/@fs/${slash(resolve(distDir, 'spy.js'))}`) as typeof import('../integrations/spy') + const spyModulePath = resolve(distDir, 'spy.js') + VitestMocker.spyModule = await this.runner.executeFile(spyModulePath) } - public async requestWithMock(dep: string) { + public async requestWithMock(url: string, callstack: string[]) { + if (callstack.some(id => id.includes('spy.js'))) + return url + await Promise.all([ this.ensureSpy(), 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,7 +378,7 @@ 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 } @@ -393,8 +390,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/web-worker/src/pure.ts b/packages/web-worker/src/pure.ts index da2c6d591ffc..9d4fbe95ddc2 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,21 +124,21 @@ export function defineWebWorker() { const id = (url instanceof URL ? url.toString() : url).replace(/^file:\/+/, '/') - const fsPath = toFilePath(id, config.root) - - runner.executeFile(fsPath) - .then(() => { - // worker should be new every time, invalidate its sub dependency - moduleCache.invalidateSubDepTree([fsPath, `mock:${fsPath}`]) - const q = this.messageQueue - this.messageQueue = null - if (q) - q.forEach(this.postMessage, this) - }).catch((e) => { - this.outside.emit('error', e) - this.onerror?.(e) - console.error(e) - }) + runner.resolveUrl(id).then((fsPath) => { + runner.executeId(fsPath) + .then(() => { + // worker should be new every time, invalidate its sub dependency + moduleCache.invalidateSubDepTree([fsPath, runner.mocker.getMockPath(fsPath)]) + const q = this.messageQueue + this.messageQueue = null + if (q) + q.forEach(this.postMessage, this) + }).catch((e) => { + this.outside.emit('error', e) + this.onerror?.(e) + console.error(e) + }) + }) } dispatchEvent(event: Event) {