From 523cb92259c0ffff2dd399950d23a89c91946d8c Mon Sep 17 00:00:00 2001 From: Ian VanSchooten Date: Tue, 8 Jun 2021 15:12:57 -0400 Subject: [PATCH] Create ResolverAsync class --- packages/jest-resolve/src/resolver.ts | 639 ++++++++++++++++++++------ packages/jest-resolve/src/types.ts | 1 + 2 files changed, 489 insertions(+), 151 deletions(-) diff --git a/packages/jest-resolve/src/resolver.ts b/packages/jest-resolve/src/resolver.ts index a492cf198978..5480aac4308a 100644 --- a/packages/jest-resolve/src/resolver.ts +++ b/packages/jest-resolve/src/resolver.ts @@ -14,7 +14,10 @@ import type {Config} from '@jest/types'; import type {IModuleMap} from 'jest-haste-map'; import {tryRealpath} from 'jest-util'; import ModuleNotFoundError from './ModuleNotFoundError'; -import defaultResolver, {clearDefaultResolverCache} from './defaultResolver'; +import defaultResolver, { + clearDefaultResolverCache, + defaultResolverAsync, +} from './defaultResolver'; import isBuiltinModule from './isBuiltinModule'; import nodeModulesPaths from './nodeModulesPaths'; import shouldLoadAsEsm, {clearCachedLookups} from './shouldLoadAsEsm'; @@ -27,6 +30,7 @@ type FindNodeModuleConfig = { moduleDirectory?: Array; paths?: Array; resolver?: Config.Path | null; + asyncResolver?: Config.Path | null; rootDir?: Config.Path; throwIfNotFound?: boolean; }; @@ -48,11 +52,11 @@ const nodePaths = NODE_PATH .map(p => path.resolve(resolvedCwd, p)) : undefined; -export default class Resolver { - private readonly _options: ResolverConfig; - private readonly _moduleMap: IModuleMap; - private readonly _moduleIDCache: Map; - private readonly _moduleNameCache: Map; +class BaseResolver { + protected readonly _options: ResolverConfig; + protected readonly _moduleMap: IModuleMap; + protected readonly _moduleIDCache: Map; + protected readonly _moduleNameCache: Map; private readonly _modulePathCache: Map>; private readonly _supportsNativePlatform: boolean; @@ -66,7 +70,6 @@ export default class Resolver { moduleNameMapper: options.moduleNameMapper, modulePaths: options.modulePaths, platforms: options.platforms, - resolver: options.resolver, rootDir: options.rootDir, }; this._supportsNativePlatform = options.platforms @@ -100,47 +103,32 @@ export default class Resolver { clearCachedLookups(); } - static findNodeModule( - path: Config.Path, - options: FindNodeModuleConfig, - ): Config.Path | null { - const resolver: typeof defaultResolver = options.resolver - ? require(options.resolver) - : defaultResolver; - const paths = options.paths; + // unstable as it should be replaced by https://github.com/nodejs/modules/issues/393, and we don't want people to use it + static unstable_shouldLoadAsEsm = shouldLoadAsEsm; - try { - return resolver(path, { - basedir: options.basedir, - browser: options.browser, - defaultResolver, - extensions: options.extensions, - moduleDirectory: options.moduleDirectory, - paths: paths ? (nodePaths || []).concat(paths) : nodePaths, - rootDir: options.rootDir, - }); - } catch (e) { - if (options.throwIfNotFound) { - throw e; - } + private _isAliasModule(moduleName: string): boolean { + const moduleNameMapper = this._options.moduleNameMapper; + if (!moduleNameMapper) { + return false; } - return null; - } - // unstable as it should be replaced by https://github.com/nodejs/modules/issues/393, and we don't want people to use it - static unstable_shouldLoadAsEsm = shouldLoadAsEsm; + return moduleNameMapper.some(({regex}) => regex.test(moduleName)); + } - resolveModuleFromDirIfExists( + /** + * _prepareForResolution is shared between the sync and async module resolution + * methods, to try to keep them as DRY as possible. + */ + protected _prepareForResolution( dirname: Config.Path, moduleName: string, options?: ResolveModuleConfig, - ): Config.Path | null { + ) { const paths = (options && options.paths) || this._options.modulePaths; const moduleDirectory = this._options.moduleDirectories; const key = dirname + path.delimiter + moduleName; const defaultPlatform = this._options.defaultPlatform; const extensions = this._options.extensions.slice(); - let module; if (this._supportsNativePlatform) { extensions.unshift( @@ -153,85 +141,28 @@ export default class Resolver { ); } - // 1. If we have already resolved this module for this directory name, - // return a value from the cache. - const cacheResult = this._moduleNameCache.get(key); - if (cacheResult) { - return cacheResult; - } - - // 2. Check if the module is a haste module. - module = this.getModule(moduleName); - if (module) { - this._moduleNameCache.set(key, module); - return module; - } - - // 3. Check if the module is a node module and resolve it based on - // the node module resolution algorithm. If skipNodeResolution is given we - // ignore all modules that look like node modules (ie. are not relative - // requires). This enables us to speed up resolution when we build a - // dependency graph because we don't have to look at modules that may not - // exist and aren't mocked. const skipResolution = options && options.skipNodeResolution && !moduleName.includes(path.sep); - const resolveNodeModule = (name: Config.Path, throwIfNotFound = false) => - Resolver.findNodeModule(name, { - basedir: dirname, - extensions, - moduleDirectory, - paths, - resolver: this._options.resolver, - rootDir: this._options.rootDir, - throwIfNotFound, - }); - - if (!skipResolution) { - module = resolveNodeModule(moduleName, Boolean(process.versions.pnp)); - - if (module) { - this._moduleNameCache.set(key, module); - return module; - } - } + return {extensions, key, moduleDirectory, paths, skipResolution}; + } - // 4. Resolve "haste packages" which are `package.json` files outside of - // `node_modules` folders anywhere in the file system. + /** + * _getHasteModulePath attempts to return the path to a haste module. + */ + protected _getHasteModulePath(moduleName: string) { const parts = moduleName.split('/'); const hastePackage = this.getPackage(parts.shift()!); if (hastePackage) { - try { - const module = path.join.apply( - path, - [path.dirname(hastePackage)].concat(parts), - ); - // try resolving with custom resolver first to support extensions, - // then fallback to require.resolve - const resolvedModule = - resolveNodeModule(module) || require.resolve(module); - this._moduleNameCache.set(key, resolvedModule); - return resolvedModule; - } catch {} + return path.join.apply(path, [path.dirname(hastePackage)].concat(parts)); } - return null; } - resolveModule( + protected _throwModNotFoundError( from: Config.Path, moduleName: string, - options?: ResolveModuleConfig, - ): Config.Path { - const dirname = path.dirname(from); - const module = - this.resolveStubModuleName(from, moduleName) || - this.resolveModuleFromDirIfExists(dirname, moduleName, options); - if (module) return module; - - // 5. Throw an error if the module could not be found. `resolve.sync` only - // produces an error based on the dirname but we have the actual current - // module name available. + ): never { const relativePath = slash(path.relative(this._options.rootDir, from)) || '.'; @@ -241,13 +172,22 @@ export default class Resolver { ); } - private _isAliasModule(moduleName: string): boolean { - const moduleNameMapper = this._options.moduleNameMapper; - if (!moduleNameMapper) { - return false; - } + protected _getModuleType(moduleName: string): 'node' | 'user' { + return this.isCoreModule(moduleName) ? 'node' : 'user'; + } - return moduleNameMapper.some(({regex}) => regex.test(moduleName)); + protected _getMapModuleName(matches: RegExpMatchArray | null) { + return matches + ? (moduleName: string) => + moduleName.replace( + /\$([0-9]+)/g, + (_, index) => matches[parseInt(index, 10)], + ) + : (moduleName: string) => moduleName; + } + + setResolver(resolver?: Config.Path | null) { + this._options.resolver = resolver; } isCoreModule(moduleName: string): boolean { @@ -281,19 +221,6 @@ export default class Resolver { ); } - getMockModule(from: Config.Path, name: string): Config.Path | null { - const mock = this._moduleMap.getMockModule(name); - if (mock) { - return mock; - } else { - const moduleName = this.resolveStubModuleName(from, name); - if (moduleName) { - return this.getModule(moduleName) || moduleName; - } - } - return null; - } - getModulePaths(from: Config.Path): Array { const cachedModule = this._modulePathCache.get(from); if (cachedModule) { @@ -309,12 +236,143 @@ export default class Resolver { this._modulePathCache.set(from, paths); return paths; } +} - getModuleID( +export class ResolverAsync extends BaseResolver { + constructor(moduleMap: IModuleMap, options: ResolverConfig) { + super(moduleMap, options); + this.setResolver(options.asyncResolver); + } + + private async _getAbsolutePathAsync( + virtualMocks: Map, + from: Config.Path, + moduleName: string, + ): Promise { + if (this.isCoreModule(moduleName)) { + return moduleName; + } + const isModuleResolved = await this._isModuleResolvedAsync( + from, + moduleName, + ); + return isModuleResolved + ? this.getModule(moduleName) + : await this._getVirtualMockPathAsync(virtualMocks, from, moduleName); + } + + private async _getMockPathAsync( + from: Config.Path, + moduleName: string, + ): Promise { + return !this.isCoreModule(moduleName) + ? await this.getMockModuleAsync(from, moduleName) + : null; + } + + private async _getVirtualMockPathAsync( + virtualMocks: Map, + from: Config.Path, + moduleName: string, + ): Promise { + const virtualMockPath = this.getModulePath(from, moduleName); + return virtualMocks.get(virtualMockPath) + ? virtualMockPath + : moduleName + ? await this.resolveModuleAsync(from, moduleName) + : from; + } + + private async _isModuleResolvedAsync( + from: Config.Path, + moduleName: string, + ): Promise { + return !!( + this.getModule(moduleName) || + (await this.getMockModuleAsync(from, moduleName)) + ); + } + + async resolveStubModuleNameAsync( + from: Config.Path, + moduleName: string, + ): Promise { + const dirname = path.dirname(from); + + const {extensions, moduleDirectory, paths} = this._prepareForResolution( + dirname, + moduleName, + ); + const moduleNameMapper = this._options.moduleNameMapper; + const asyncResolver = this._options.asyncResolver; + + if (moduleNameMapper) { + for (const {moduleName: mappedModuleName, regex} of moduleNameMapper) { + if (regex.test(moduleName)) { + // Note: once a moduleNameMapper matches the name, it must result + // in a module, or else an error is thrown. + const matches = moduleName.match(regex); + const mapModuleName = this._getMapModuleName(matches); + const possibleModuleNames = Array.isArray(mappedModuleName) + ? mappedModuleName + : [mappedModuleName]; + let module: string | null = null; + for (const possibleModuleName of possibleModuleNames) { + const updatedName = mapModuleName(possibleModuleName); + + module = + this.getModule(updatedName) || + (await ResolverAsync.findNodeModuleAsync(updatedName, { + asyncResolver, + basedir: dirname, + extensions, + moduleDirectory, + paths, + rootDir: this._options.rootDir, + })); + + if (module) { + break; + } + } + + if (!module) { + throw createNoMappedModuleFoundError( + moduleName, + mapModuleName, + mappedModuleName, + regex, + asyncResolver, + ); + } + return module; + } + } + } + return null; + } + + async getMockModuleAsync( + from: Config.Path, + name: string, + ): Promise { + const mock = this._moduleMap.getMockModule(name); + if (mock) { + return mock; + } else { + const moduleName = await this.resolveStubModuleNameAsync(from, name); + if (moduleName) { + return this.getModule(moduleName) || moduleName; + } + } + return null; + } + + async getModuleIDAsync( virtualMocks: Map, from: Config.Path, _moduleName?: string, - ): string { + ): Promise { const moduleName = _moduleName || ''; const key = from + path.delimiter + moduleName; @@ -324,8 +382,12 @@ export default class Resolver { } const moduleType = this._getModuleType(moduleName); - const absolutePath = this._getAbsolutePath(virtualMocks, from, moduleName); - const mockPath = this._getMockPath(from, moduleName); + const absolutePath = await this._getAbsolutePathAsync( + virtualMocks, + from, + moduleName, + ); + const mockPath = await this._getMockPathAsync(from, moduleName); const sep = path.delimiter; const id = @@ -338,8 +400,142 @@ export default class Resolver { return id; } - private _getModuleType(moduleName: string): 'node' | 'user' { - return this.isCoreModule(moduleName) ? 'node' : 'user'; + static async findNodeModuleAsync( + path: Config.Path, + options: FindNodeModuleConfig, + ): Promise { + const resolver: typeof defaultResolverAsync = options.asyncResolver + ? require(options.asyncResolver) + : defaultResolverAsync; + const paths = options.paths; + + try { + const result = await resolver(path, { + basedir: options.basedir, + browser: options.browser, + defaultResolver, + extensions: options.extensions, + moduleDirectory: options.moduleDirectory, + paths: paths ? (nodePaths || []).concat(paths) : nodePaths, + rootDir: options.rootDir, + }); + return result.path; + } catch (e) { + if (options.throwIfNotFound) { + throw e; + } + } + return null; + } + + async resolveModuleFromDirIfExistsAsync( + dirname: Config.Path, + moduleName: string, + options?: ResolveModuleConfig, + ): Promise { + const { + extensions, + key, + moduleDirectory, + paths, + skipResolution, + } = this._prepareForResolution(dirname, moduleName, options); + + let module; + + // 1. If we have already resolved this module for this directory name, + // return a value from the cache. + const cacheResult = this._moduleNameCache.get(key); + if (cacheResult) { + return cacheResult; + } + + // 2. Check if the module is a haste module. + module = this.getModule(moduleName); + if (module) { + this._moduleNameCache.set(key, module); + return module; + } + + // 3. Check if the module is a node module and resolve it based on + // the node module resolution algorithm. If skipNodeResolution is given we + // ignore all modules that look like node modules (ie. are not relative + // requires). This enables us to speed up resolution when we build a + // dependency graph because we don't have to look at modules that may not + // exist and aren't mocked. + const resolveNodeModule = async ( + name: Config.Path, + throwIfNotFound = false, + ) => + await ResolverAsync.findNodeModuleAsync(name, { + asyncResolver: this._options.asyncResolver, + basedir: dirname, + extensions, + moduleDirectory, + paths, + rootDir: this._options.rootDir, + throwIfNotFound, + }); + + if (!skipResolution) { + module = await resolveNodeModule( + moduleName, + Boolean(process.versions.pnp), + ); + + if (module) { + this._moduleNameCache.set(key, module); + return module; + } + } + + // 4. Resolve "haste packages" which are `package.json` files outside of + // `node_modules` folders anywhere in the file system. + try { + const hasteModulePath = this._getHasteModulePath(moduleName); + if (hasteModulePath) { + // try resolving with custom resolver first to support extensions, + // then fallback to require.resolve + const resolvedModule = + (await resolveNodeModule(hasteModulePath)) || + // QUESTION: should this be async? + require.resolve(hasteModulePath); + this._moduleNameCache.set(key, resolvedModule); + return resolvedModule; + } + } catch {} + + return null; + } + + /* eslint-disable-next-line consistent-return */ + async resolveModuleAsync( + from: Config.Path, + moduleName: string, + options?: ResolveModuleConfig, + ): Promise { + const dirname = path.dirname(from); + const module = + (await this.resolveStubModuleNameAsync(from, moduleName)) || + (await this.resolveModuleFromDirIfExistsAsync( + dirname, + moduleName, + options, + )); + + if (module) return module; + + // 5. Throw an error if the module could not be found. `resolve.sync` only + // produces an error based on the dirname but we have the actual current + // module name available. + this._throwModNotFoundError(from, moduleName); + } +} + +export default class Resolver extends BaseResolver { + constructor(moduleMap: IModuleMap, options: ResolverConfig) { + super(moduleMap, options); + this.setResolver(options.resolver); } private _getAbsolutePath( @@ -388,24 +584,13 @@ export default class Resolver { moduleName: string, ): Config.Path | null { const dirname = path.dirname(from); - const paths = this._options.modulePaths; - const extensions = this._options.extensions.slice(); - const moduleDirectory = this._options.moduleDirectories; + + const {extensions, moduleDirectory, paths} = this._prepareForResolution( + dirname, + moduleName, + ); const moduleNameMapper = this._options.moduleNameMapper; const resolver = this._options.resolver; - const defaultPlatform = this._options.defaultPlatform; - - if (this._supportsNativePlatform) { - extensions.unshift( - ...this._options.extensions.map(ext => '.' + NATIVE_PLATFORM + ext), - ); - } - - if (defaultPlatform) { - extensions.unshift( - ...this._options.extensions.map(ext => '.' + defaultPlatform + ext), - ); - } if (moduleNameMapper) { for (const {moduleName: mappedModuleName, regex} of moduleNameMapper) { @@ -413,14 +598,7 @@ export default class Resolver { // Note: once a moduleNameMapper matches the name, it must result // in a module, or else an error is thrown. const matches = moduleName.match(regex); - const mapModuleName = matches - ? (moduleName: string) => - moduleName.replace( - /\$([0-9]+)/g, - (_, index) => matches[parseInt(index, 10)], - ) - : (moduleName: string) => moduleName; - + const mapModuleName = this._getMapModuleName(matches); const possibleModuleNames = Array.isArray(mappedModuleName) ? mappedModuleName : [mappedModuleName]; @@ -459,6 +637,165 @@ export default class Resolver { } return null; } + + getMockModule(from: Config.Path, name: string): Config.Path | null { + const mock = this._moduleMap.getMockModule(name); + if (mock) { + return mock; + } else { + const moduleName = this.resolveStubModuleName(from, name); + if (moduleName) { + return this.getModule(moduleName) || moduleName; + } + } + return null; + } + + getModuleID( + virtualMocks: Map, + from: Config.Path, + _moduleName?: string, + ): string { + const moduleName = _moduleName || ''; + + const key = from + path.delimiter + moduleName; + const cachedModuleID = this._moduleIDCache.get(key); + if (cachedModuleID) { + return cachedModuleID; + } + + const moduleType = this._getModuleType(moduleName); + const absolutePath = this._getAbsolutePath(virtualMocks, from, moduleName); + const mockPath = this._getMockPath(from, moduleName); + + const sep = path.delimiter; + const id = + moduleType + + sep + + (absolutePath ? absolutePath + sep : '') + + (mockPath ? mockPath + sep : ''); + + this._moduleIDCache.set(key, id); + return id; + } + + static findNodeModule( + path: Config.Path, + options: FindNodeModuleConfig, + ): Config.Path | null { + const resolver: typeof defaultResolver = options.resolver + ? require(options.resolver) + : defaultResolver; + const paths = options.paths; + + try { + return resolver(path, { + basedir: options.basedir, + browser: options.browser, + defaultResolver, + extensions: options.extensions, + moduleDirectory: options.moduleDirectory, + paths: paths ? (nodePaths || []).concat(paths) : nodePaths, + rootDir: options.rootDir, + }); + } catch (e) { + if (options.throwIfNotFound) { + throw e; + } + } + return null; + } + + resolveModuleFromDirIfExists( + dirname: Config.Path, + moduleName: string, + options?: ResolveModuleConfig, + ): Config.Path | null { + const { + extensions, + key, + moduleDirectory, + paths, + skipResolution, + } = this._prepareForResolution(dirname, moduleName, options); + + let module; + + // 1. If we have already resolved this module for this directory name, + // return a value from the cache. + const cacheResult = this._moduleNameCache.get(key); + if (cacheResult) { + return cacheResult; + } + + // 2. Check if the module is a haste module. + module = this.getModule(moduleName); + if (module) { + this._moduleNameCache.set(key, module); + return module; + } + + // 3. Check if the module is a node module and resolve it based on + // the node module resolution algorithm. If skipNodeResolution is given we + // ignore all modules that look like node modules (ie. are not relative + // requires). This enables us to speed up resolution when we build a + // dependency graph because we don't have to look at modules that may not + // exist and aren't mocked. + const resolveNodeModule = (name: Config.Path, throwIfNotFound = false) => + Resolver.findNodeModule(name, { + basedir: dirname, + extensions, + moduleDirectory, + paths, + resolver: this._options.resolver, + rootDir: this._options.rootDir, + throwIfNotFound, + }); + + if (!skipResolution) { + module = resolveNodeModule(moduleName, Boolean(process.versions.pnp)); + + if (module) { + this._moduleNameCache.set(key, module); + return module; + } + } + + // 4. Resolve "haste packages" which are `package.json` files outside of + // `node_modules` folders anywhere in the file system. + try { + const hasteModulePath = this._getHasteModulePath(moduleName); + if (hasteModulePath) { + // try resolving with custom resolver first to support extensions, + // then fallback to require.resolve + const resolvedModule = + resolveNodeModule(hasteModulePath) || + require.resolve(hasteModulePath); + this._moduleNameCache.set(key, resolvedModule); + return resolvedModule; + } + } catch {} + + return null; + } + + /* eslint-disable-next-line consistent-return */ + resolveModule( + from: Config.Path, + moduleName: string, + options?: ResolveModuleConfig, + ): Config.Path { + const dirname = path.dirname(from); + const module = + this.resolveStubModuleName(from, moduleName) || + this.resolveModuleFromDirIfExists(dirname, moduleName, options); + if (module) return module; + + // 5. Throw an error if the module could not be found. `resolve.sync` only + // produces an error based on the dirname but we have the actual current + // module name available. + this._throwModNotFoundError(from, moduleName); + } } const createNoMappedModuleFoundError = ( diff --git a/packages/jest-resolve/src/types.ts b/packages/jest-resolve/src/types.ts index 0535e021c0cb..58501784b335 100644 --- a/packages/jest-resolve/src/types.ts +++ b/packages/jest-resolve/src/types.ts @@ -16,6 +16,7 @@ export type ResolverConfig = { modulePaths?: Array; platforms?: Array; resolver?: Config.Path | null; + asyncResolver?: Config.Path | null; rootDir: Config.Path; };