diff --git a/CHANGELOG.md b/CHANGELOG.md index a456eb437b5d..36f4cb581d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ - `[jest-mock]` Improve `isMockFunction` to infer types of passed function ([#12442](https://github.com/facebook/jest/pull/12442)) - `[jest-resolve]` [**BREAKING**] Add support for `package.json` `exports` ([#11961](https://github.com/facebook/jest/pull/11961), [#12373](https://github.com/facebook/jest/pull/12373)) - `[jest-resolve, jest-runtime]` Add support for `data:` URI import and mock ([#12392](https://github.com/facebook/jest/pull/12392)) +- `[jest-resolve, jest-runtime]` Add support for async resolver ([#11540](https://github.com/facebook/jest/pull/11540)) - `[@jest/schemas]` New module for JSON schemas for Jest's config ([#12384](https://github.com/facebook/jest/pull/12384)) - `[jest-worker]` [**BREAKING**] Allow only absolute `workerPath` ([#12343](https://github.com/facebook/jest/pull/12343)) - `[pretty-format]` New `maxWidth` parameter ([#12402](https://github.com/facebook/jest/pull/12402)) diff --git a/docs/Configuration.md b/docs/Configuration.md index 11ff709bf7d3..1c4817388a0e 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -779,13 +779,18 @@ By default, each test file gets its own independent module registry. Enabling `r Default: `undefined` -This option allows the use of a custom resolver. This resolver must be a node module that exports a function expecting a string as the first argument for the path to resolve and an object with the following structure as the second argument: +This option allows the use of a custom resolver. This resolver must be a node module that exports _either_: + +1. a function expecting a string as the first argument for the path to resolve and an options object as the second argument. The function should either return a path to the module that should be resolved or throw an error if the module can't be found. _or_ +2. an object containing `async` and/or `sync` properties. The `sync` property should be a function with the shape explained above, and the `async` property should also be a function that accepts the same arguments, but returns a promise which resolves with the path to the module or rejects with an error. + +The options object provided to resolvers has the shape: ```json { "basedir": string, "conditions": [string], - "defaultResolver": "function(request, options)", + "defaultResolver": "function(request, options) -> string", "extensions": [string], "moduleDirectory": [string], "paths": [string], @@ -795,9 +800,7 @@ This option allows the use of a custom resolver. This resolver must be a node mo } ``` -The function should either return a path to the module that should be resolved or throw an error if the module can't be found. - -Note: the defaultResolver passed as an option is the Jest default resolver which might be useful when you write your custom one. It takes the same arguments as your custom one, e.g. `(request, options)`. +Note: the `defaultResolver` passed as an option is the Jest default resolver which might be useful when you write your custom one. It takes the same arguments as your custom synchronous one, e.g. `(request, options)` and returns a string or throws. For example, if you want to respect Browserify's [`"browser"` field](https://github.com/browserify/browserify-handbook/blob/master/readme.markdown#browser-field), you can use the following configuration: diff --git a/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap b/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap index d382ec9c3f30..9ef544f4584a 100644 --- a/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap +++ b/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap @@ -41,7 +41,7 @@ exports[`moduleNameMapper wrong array configuration 1`] = ` 12 | module.exports = () => 'test'; 13 | - at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:572:17) + at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:897:17) at Object.require (index.js:10:1)" `; @@ -70,6 +70,6 @@ exports[`moduleNameMapper wrong configuration 1`] = ` 12 | module.exports = () => 'test'; 13 | - at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:572:17) + at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:897:17) at Object.require (index.js:10:1)" `; diff --git a/e2e/__tests__/__snapshots__/resolveAsync.test.ts.snap b/e2e/__tests__/__snapshots__/resolveAsync.test.ts.snap new file mode 100644 index 000000000000..8b2c6acb57fd --- /dev/null +++ b/e2e/__tests__/__snapshots__/resolveAsync.test.ts.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`on node >=12.16.0 runs test with native ESM 1`] = ` +"Test Suites: 1 passed, 1 total +Tests: 1 passed, 1 total +Snapshots: 0 total +Time: <> +Ran all test suites." +`; diff --git a/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap b/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap index 00bb293f258e..8ee95a4ed134 100644 --- a/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap +++ b/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap @@ -37,6 +37,6 @@ exports[`show error message with matching files 1`] = ` | ^ 9 | - at Resolver.resolveModule (../../packages/jest-resolve/build/resolver.js:317:11) + at Resolver._throwModNotFoundError (../../packages/jest-resolve/build/resolver.js:491:11) at Object.require (index.js:8:18)" `; diff --git a/e2e/__tests__/resolveAsync.test.ts b/e2e/__tests__/resolveAsync.test.ts new file mode 100644 index 000000000000..a5588985f235 --- /dev/null +++ b/e2e/__tests__/resolveAsync.test.ts @@ -0,0 +1,25 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import {onNodeVersions} from '@jest/test-utils'; +import {extractSummary} from '../Utils'; +import runJest from '../runJest'; + +// The versions where vm.Module exists and commonjs with "exports" is not broken +onNodeVersions('>=12.16.0', () => { + test('runs test with native ESM', () => { + const {exitCode, stderr, stdout} = runJest('resolve-async', [], { + nodeOptions: '--experimental-vm-modules --no-warnings', + }); + + const {summary} = extractSummary(stderr); + + expect(summary).toMatchSnapshot(); + expect(stdout).toBe(''); + expect(exitCode).toBe(0); + }); +}); diff --git a/e2e/resolve-async/__tests__/resolveAsync.test.js b/e2e/resolve-async/__tests__/resolveAsync.test.js new file mode 100644 index 000000000000..4a7928b21a2b --- /dev/null +++ b/e2e/resolve-async/__tests__/resolveAsync.test.js @@ -0,0 +1,12 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import greeting from '../some-file'; + +test('async resolver resolves to correct file', () => { + expect(greeting).toEqual('Hello from mapped file!!'); +}); diff --git a/e2e/resolve-async/package.json b/e2e/resolve-async/package.json new file mode 100644 index 000000000000..50e870688cbf --- /dev/null +++ b/e2e/resolve-async/package.json @@ -0,0 +1,8 @@ +{ + "type": "module", + "jest": { + "resolver": "/resolver.cjs", + "transform": { + } + } +} diff --git a/e2e/resolve-async/resolver.cjs b/e2e/resolve-async/resolver.cjs new file mode 100644 index 000000000000..1c0af2d913fa --- /dev/null +++ b/e2e/resolve-async/resolver.cjs @@ -0,0 +1,24 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const {promisify} = require('util'); + +const wait = promisify(setTimeout); + +module.exports = { + async: async (request, opts) => { + await wait(500); + + if (request === '../some-file') { + request = '../some-other-file'; + } + + return opts.defaultResolver(request, opts); + }, +}; diff --git a/e2e/resolve-async/some-other-file.js b/e2e/resolve-async/some-other-file.js new file mode 100644 index 000000000000..bcd5de5faab8 --- /dev/null +++ b/e2e/resolve-async/some-other-file.js @@ -0,0 +1,8 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default 'Hello from mapped file!!'; diff --git a/e2e/resolve-get-paths/__tests__/resolveGetPaths.test.js b/e2e/resolve-get-paths/__tests__/resolveGetPaths.test.js index ee40aa4153a2..86aa66cce0de 100644 --- a/e2e/resolve-get-paths/__tests__/resolveGetPaths.test.js +++ b/e2e/resolve-get-paths/__tests__/resolveGetPaths.test.js @@ -4,7 +4,6 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -'use strict'; import {resolve} from 'path'; diff --git a/e2e/resolve-with-paths/__tests__/resolveWithPaths.test.js b/e2e/resolve-with-paths/__tests__/resolveWithPaths.test.js index f6891ea61742..5e7950a90503 100644 --- a/e2e/resolve-with-paths/__tests__/resolveWithPaths.test.js +++ b/e2e/resolve-with-paths/__tests__/resolveWithPaths.test.js @@ -4,7 +4,6 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -'use strict'; import {resolve} from 'path'; diff --git a/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/package.json b/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/package.json index fc9039934b2c..adce4c959e2b 100644 --- a/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/package.json +++ b/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/package.json @@ -8,7 +8,7 @@ "default": "./default.js" }, "./nested": "./nestedDefault.js", - "./deeplyNested" : { + "./deeplyNested": { "require": "./nestedRequire.js", "default": "./nestedDefault.js" }, diff --git a/packages/jest-resolve/src/__mocks__/package.json b/packages/jest-resolve/src/__mocks__/package.json index 2807645e6dca..16d6367d52d4 100644 --- a/packages/jest-resolve/src/__mocks__/package.json +++ b/packages/jest-resolve/src/__mocks__/package.json @@ -1,6 +1,5 @@ { "name": "__mocks__", "version": "1.0.0", - "dependencies": { - } + "dependencies": {} } diff --git a/packages/jest-resolve/src/__mocks__/userResolver.d.ts b/packages/jest-resolve/src/__mocks__/userResolver.d.ts index 155346eca132..8d6bd2c26306 100644 --- a/packages/jest-resolve/src/__mocks__/userResolver.d.ts +++ b/packages/jest-resolve/src/__mocks__/userResolver.d.ts @@ -5,12 +5,8 @@ * LICENSE file in the root directory of this source tree. */ -import defaultResolver from '../defaultResolver'; +import type {Resolver} from '../defaultResolver'; -// todo: can be replaced with jest.MockedFunction -declare const userResolver: jest.MockInstance< - ReturnType, - Parameters ->; +declare const userResolver: Resolver; export default userResolver; diff --git a/packages/jest-resolve/src/__mocks__/userResolverAsync.d.ts b/packages/jest-resolve/src/__mocks__/userResolverAsync.d.ts new file mode 100644 index 000000000000..8c23f0f74fda --- /dev/null +++ b/packages/jest-resolve/src/__mocks__/userResolverAsync.d.ts @@ -0,0 +1,13 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type {Resolver} from '../defaultResolver'; + +// todo: can be replaced with jest.MockedFunction +declare const userResolver: Resolver; + +export default userResolver; diff --git a/packages/jest-resolve/src/__mocks__/userResolverAsync.js b/packages/jest-resolve/src/__mocks__/userResolverAsync.js new file mode 100644 index 000000000000..d538df61a643 --- /dev/null +++ b/packages/jest-resolve/src/__mocks__/userResolverAsync.js @@ -0,0 +1,14 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +module.exports = { + async: function userResolver(path, options) { + return Promise.resolve('module'); + }, +}; diff --git a/packages/jest-resolve/src/__tests__/resolve.test.ts b/packages/jest-resolve/src/__tests__/resolve.test.ts index 4d0b1af072a6..52359c2c745d 100644 --- a/packages/jest-resolve/src/__tests__/resolve.test.ts +++ b/packages/jest-resolve/src/__tests__/resolve.test.ts @@ -11,29 +11,31 @@ import * as fs from 'graceful-fs'; import {sync as resolveSync} from 'resolve'; import {ModuleMap} from 'jest-haste-map'; import userResolver from '../__mocks__/userResolver'; +import userResolverAsync from '../__mocks__/userResolverAsync'; import defaultResolver from '../defaultResolver'; import nodeModulesPaths from '../nodeModulesPaths'; import Resolver from '../resolver'; import type {ResolverConfig} from '../types'; -jest.mock('../__mocks__/userResolver'); +jest.mock('../__mocks__/userResolver').mock('../__mocks__/userResolverAsync'); // Do not fully mock `resolve` because it is used by Jest. Doing it will crash -// in very strange ways. Instead just spy on the method `sync`. +// in very strange ways. Instead just spy on it and its `sync` method. jest.mock('resolve', () => { const originalModule = jest.requireActual('resolve'); - return { - ...originalModule, - sync: jest.spyOn(originalModule, 'sync'), - }; + + const m = jest.fn((...args) => originalModule(...args)); + Object.assign(m, originalModule); + m.sync = jest.spyOn(originalModule, 'sync'); + + return m; }); -const mockResolveSync = < - jest.Mock, Parameters> ->resolveSync; +const mockResolveSync = jest.mocked(resolveSync); beforeEach(() => { userResolver.mockClear(); + userResolverAsync.async.mockClear(); mockResolveSync.mockClear(); }); @@ -251,6 +253,64 @@ describe('findNodeModule', () => { }); }); +describe('findNodeModuleAsync', () => { + it('is possible to override the default resolver', async () => { + const cwd = process.cwd(); + const resolvedCwd = fs.realpathSync(cwd) || cwd; + const nodePaths = process.env.NODE_PATH + ? process.env.NODE_PATH.split(path.delimiter) + .filter(Boolean) + .map(p => path.resolve(resolvedCwd, p)) + : null; + + userResolverAsync.async.mockImplementation(() => Promise.resolve('module')); + + const newPath = await Resolver.findNodeModuleAsync('test', { + basedir: '/', + browser: true, + conditions: ['conditions, woooo'], + extensions: ['js'], + moduleDirectory: ['node_modules'], + paths: ['/something'], + resolver: require.resolve('../__mocks__/userResolverAsync'), + }); + + expect(newPath).toBe('module'); + expect(userResolverAsync.async.mock.calls[0][0]).toBe('test'); + expect(userResolverAsync.async.mock.calls[0][1]).toStrictEqual({ + basedir: '/', + browser: true, + conditions: ['conditions, woooo'], + defaultResolver, + extensions: ['js'], + moduleDirectory: ['node_modules'], + paths: (nodePaths || []).concat(['/something']), + rootDir: undefined, + }); + }); + + it('passes packageFilter to the resolve module when using the default resolver', async () => { + const packageFilter = jest.fn(); + + // A resolver that delegates to defaultResolver with a packageFilter implementation + userResolverAsync.async.mockImplementation((request, opts) => + Promise.resolve(opts.defaultResolver(request, {...opts, packageFilter})), + ); + + await Resolver.findNodeModuleAsync('test', { + basedir: '/', + resolver: require.resolve('../__mocks__/userResolverAsync'), + }); + + expect(mockResolveSync).toHaveBeenCalledWith( + 'test', + expect.objectContaining({ + packageFilter, + }), + ); + }); +}); + describe('resolveModule', () => { let moduleMap: ModuleMap; beforeEach(() => { @@ -341,6 +401,103 @@ describe('resolveModule', () => { }); }); +describe('resolveModuleAsync', () => { + let moduleMap: ModuleMap; + beforeEach(() => { + moduleMap = ModuleMap.create('/'); + }); + + it('is possible to resolve node modules', async () => { + const resolver = new Resolver(moduleMap, { + extensions: ['.js'], + } as ResolverConfig); + const src = require.resolve('../'); + const resolved = await resolver.resolveModuleAsync( + src, + './__mocks__/mockJsDependency', + ); + expect(resolved).toBe(require.resolve('../__mocks__/mockJsDependency.js')); + }); + + it('is possible to resolve node modules with custom extensions', async () => { + const resolver = new Resolver(moduleMap, { + extensions: ['.js', '.jsx'], + } as ResolverConfig); + const src = require.resolve('../'); + const resolvedJsx = await resolver.resolveModuleAsync( + src, + './__mocks__/mockJsxDependency', + ); + expect(resolvedJsx).toBe( + require.resolve('../__mocks__/mockJsxDependency.jsx'), + ); + }); + + it('is possible to resolve node modules with custom extensions and platforms', async () => { + const resolver = new Resolver(moduleMap, { + extensions: ['.js', '.jsx'], + platforms: ['native'], + } as ResolverConfig); + const src = require.resolve('../'); + const resolvedJsx = await resolver.resolveModuleAsync( + src, + './__mocks__/mockJsxDependency', + ); + expect(resolvedJsx).toBe( + require.resolve('../__mocks__/mockJsxDependency.native.jsx'), + ); + }); + + it('is possible to resolve node modules by resolving their realpath', async () => { + const resolver = new Resolver(moduleMap, { + extensions: ['.js'], + } as ResolverConfig); + const src = path.join( + path.resolve(__dirname, '../../src/__mocks__/bar/node_modules/'), + 'foo/index.js', + ); + const resolved = await resolver.resolveModuleAsync(src, 'dep'); + expect(resolved).toBe( + require.resolve('../../src/__mocks__/foo/node_modules/dep/index.js'), + ); + }); + + it('is possible to specify custom resolve paths', async () => { + const resolver = new Resolver(moduleMap, { + extensions: ['.js'], + } as ResolverConfig); + const src = require.resolve('../'); + const resolved = await resolver.resolveModuleAsync( + src, + 'mockJsDependency', + { + paths: [ + path.resolve(__dirname, '../../src/__tests__'), + path.resolve(__dirname, '../../src/__mocks__'), + ], + }, + ); + expect(resolved).toBe(require.resolve('../__mocks__/mockJsDependency.js')); + }); + + it('does not confuse directories with files', async () => { + const resolver = new Resolver(moduleMap, { + extensions: ['.js'], + } as ResolverConfig); + const mocksDirectory = path.resolve(__dirname, '../__mocks__'); + const fooSlashFoo = path.join(mocksDirectory, 'foo/foo.js'); + const fooSlashIndex = path.join(mocksDirectory, 'foo/index.js'); + + const resolvedWithSlash = await resolver.resolveModuleAsync( + fooSlashFoo, + './', + ); + const resolvedWithDot = await resolver.resolveModuleAsync(fooSlashFoo, '.'); + expect(resolvedWithSlash).toBe(fooSlashIndex); + expect(resolvedWithSlash).toBe(resolvedWithDot); + }); +}); + describe('getMockModule', () => { it('is possible to use custom resolver to resolve deps inside mock modules with moduleNameMapper', () => { userResolver.mockImplementation(() => 'module'); @@ -368,6 +525,34 @@ describe('getMockModule', () => { }); }); +describe('getMockModuleAsync', () => { + it('is possible to use custom resolver to resolve deps inside mock modules with moduleNameMapper', async () => { + userResolverAsync.async.mockImplementation(() => Promise.resolve('module')); + + const moduleMap = ModuleMap.create('/'); + const resolver = new Resolver(moduleMap, { + extensions: ['.js'], + moduleNameMapper: [ + { + moduleName: '$1', + regex: /(.*)/, + }, + ], + resolver: require.resolve('../__mocks__/userResolverAsync'), + } as ResolverConfig); + const src = require.resolve('../'); + + await resolver.resolveModuleAsync(src, 'dependentModule'); + + expect(userResolverAsync.async).toHaveBeenCalled(); + expect(userResolverAsync.async.mock.calls[0][0]).toBe('dependentModule'); + expect(userResolverAsync.async.mock.calls[0][1]).toHaveProperty( + 'basedir', + path.dirname(src), + ); + }); +}); + describe('nodeModulesPaths', () => { it('provides custom module paths after node_modules', () => { const src = require.resolve('../'); diff --git a/packages/jest-resolve/src/defaultResolver.ts b/packages/jest-resolve/src/defaultResolver.ts index 893a18b1b3bf..f20921cb6ff6 100644 --- a/packages/jest-resolve/src/defaultResolver.ts +++ b/packages/jest-resolve/src/defaultResolver.ts @@ -38,6 +38,14 @@ interface ResolverOptions { type UpstreamResolveOptionsWithConditions = UpstreamResolveOptions & Pick; +export type SyncResolver = (path: string, options: ResolverOptions) => string; +export type AsyncResolver = ( + path: string, + options: ResolverOptions, +) => Promise; + +export type Resolver = SyncResolver | AsyncResolver; + // https://github.com/facebook/jest/pull/10617 declare global { namespace NodeJS { @@ -47,10 +55,7 @@ declare global { } } -export default function defaultResolver( - path: string, - options: ResolverOptions, -): string { +const defaultResolver: SyncResolver = (path, options) => { // Yarn 2 adds support to `resolve` automatically so the pnpResolver is only // needed for Yarn 1 which implements version 1 of the pnp spec if (process.versions.pnp === '1') { @@ -77,7 +82,9 @@ export default function defaultResolver( // Dereference symlinks to ensure we don't create a separate // module instance depending on how it was referenced. return realpathSync(result); -} +}; + +export default defaultResolver; /* * helper functions diff --git a/packages/jest-resolve/src/resolver.ts b/packages/jest-resolve/src/resolver.ts index 8eddb1bbd75d..b0ec60847963 100644 --- a/packages/jest-resolve/src/resolver.ts +++ b/packages/jest-resolve/src/resolver.ts @@ -13,7 +13,11 @@ import slash = require('slash'); import type {IModuleMap} from 'jest-haste-map'; import {tryRealpath} from 'jest-util'; import ModuleNotFoundError from './ModuleNotFoundError'; -import defaultResolver from './defaultResolver'; +import defaultResolver, { + AsyncResolver, + Resolver as ResolverInterface, + SyncResolver, +} from './defaultResolver'; import {clearFsCache} from './fileWalkers'; import isBuiltinModule from './isBuiltinModule'; import nodeModulesPaths from './nodeModulesPaths'; @@ -106,9 +110,15 @@ export default class Resolver { path: string, options: FindNodeModuleConfig, ): string | null { - const resolver: typeof defaultResolver = options.resolver - ? require(options.resolver) - : defaultResolver; + const resolverModule = loadResolver(options.resolver); + let resolver: SyncResolver = defaultResolver; + + if (typeof resolverModule === 'function') { + resolver = resolverModule; + } else if (typeof resolverModule.sync === 'function') { + resolver = resolverModule.sync; + } + const paths = options.paths; try { @@ -130,6 +140,50 @@ export default class Resolver { return null; } + static async findNodeModuleAsync( + path: string, + options: FindNodeModuleConfig, + ): Promise { + const resolverModule = loadResolver(options.resolver); + let resolver: ResolverInterface = defaultResolver; + + if (typeof resolverModule === 'function') { + resolver = resolverModule; + } else if ( + typeof resolverModule.async === 'function' || + typeof resolverModule.sync === 'function' + ) { + const asyncOrSync = resolverModule.async || resolverModule.sync; + + if (asyncOrSync == null) { + throw new Error(`Unable to load resolver at ${options.resolver}`); + } + + resolver = asyncOrSync; + } + + const paths = options.paths; + + try { + const result = await resolver(path, { + basedir: options.basedir, + browser: options.browser, + conditions: options.conditions, + defaultResolver, + extensions: options.extensions, + moduleDirectory: options.moduleDirectory, + paths: paths ? (nodePaths || []).concat(paths) : nodePaths, + rootDir: options.rootDir, + }); + return result; + } catch (e: unknown) { + if (options.throwIfNotFound) { + throw e; + } + } + 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; @@ -138,24 +192,10 @@ export default class Resolver { moduleName: string, options?: ResolveModuleConfig, ): string | null { - const paths = options?.paths || this._options.modulePaths; - const moduleDirectory = this._options.moduleDirectories; - const stringifiedOptions = options ? JSON.stringify(options) : ''; - const key = dirname + path.delimiter + moduleName + stringifiedOptions; - const defaultPlatform = this._options.defaultPlatform; - const extensions = this._options.extensions.slice(); - let module; + const {extensions, key, moduleDirectory, paths, skipResolution} = + this._prepareForResolution(dirname, moduleName, options); - if (this._supportsNativePlatform) { - extensions.unshift( - ...this._options.extensions.map(ext => '.' + NATIVE_PLATFORM + ext), - ); - } - if (defaultPlatform) { - extensions.unshift( - ...this._options.extensions.map(ext => '.' + defaultPlatform + ext), - ); - } + let module; // 1. If we have already resolved this module for this directory name, // return a value from the cache. @@ -177,9 +217,6 @@ export default class Resolver { // 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: string, throwIfNotFound = false) => { if (this.isCoreModule(name)) { return name; @@ -208,23 +245,97 @@ export default class Resolver { // 4. Resolve "haste packages" which are `package.json` files outside of // `node_modules` folders anywhere in the file system. - 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 { + const hasteModulePath = this._getHasteModulePath(moduleName); + if (hasteModulePath) { // try resolving with custom resolver first to support extensions, // then fallback to require.resolve const resolvedModule = - resolveNodeModule(module) || require.resolve(module); + resolveNodeModule(hasteModulePath) || + require.resolve(hasteModulePath); this._moduleNameCache.set(key, resolvedModule); return resolvedModule; - } catch {} + } + } catch {} + + return null; + } + + async resolveModuleFromDirIfExistsAsync( + dirname: string, + 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: string, throwIfNotFound = false) => { + if (this.isCoreModule(name)) { + return name; + } + + return await Resolver.findNodeModuleAsync(name, { + basedir: dirname, + conditions: options?.conditions, + extensions, + moduleDirectory, + paths, + resolver: this._options.resolver, + 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; } @@ -242,6 +353,77 @@ export default class Resolver { // 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); + } + + async resolveModuleAsync( + from: string, + 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` only + // produces an error based on the dirname but we have the actual current + // module name available. + this._throwModNotFoundError(from, moduleName); + } + + /** + * _prepareForResolution is shared between the sync and async module resolution + * methods, to try to keep them as DRY as possible. + */ + private _prepareForResolution( + dirname: string, + moduleName: string, + options?: ResolveModuleConfig, + ) { + const paths = options?.paths || this._options.modulePaths; + const moduleDirectory = this._options.moduleDirectories; + const stringifiedOptions = options ? JSON.stringify(options) : ''; + const key = dirname + path.delimiter + moduleName + stringifiedOptions; + const defaultPlatform = this._options.defaultPlatform; + const extensions = this._options.extensions.slice(); + + if (this._supportsNativePlatform) { + extensions.unshift( + ...this._options.extensions.map(ext => '.' + NATIVE_PLATFORM + ext), + ); + } + if (defaultPlatform) { + extensions.unshift( + ...this._options.extensions.map(ext => '.' + defaultPlatform + ext), + ); + } + + const skipResolution = + options && options.skipNodeResolution && !moduleName.includes(path.sep); + + return {extensions, key, moduleDirectory, paths, skipResolution}; + } + + /** + * _getHasteModulePath attempts to return the path to a haste module. + */ + private _getHasteModulePath(moduleName: string) { + const parts = moduleName.split('/'); + const hastePackage = this.getPackage(parts.shift()!); + if (hastePackage) { + return path.join.apply(path, [path.dirname(hastePackage)].concat(parts)); + } + return null; + } + + private _throwModNotFoundError(from: string, moduleName: string): never { const relativePath = slash(path.relative(this._options.rootDir, from)) || '.'; @@ -251,6 +433,16 @@ export default class Resolver { ); } + private _getMapModuleName(matches: RegExpMatchArray | null) { + return matches + ? (moduleName: string) => + moduleName.replace( + /\$([0-9]+)/g, + (_, index) => matches[parseInt(index, 10)], + ) + : (moduleName: string) => moduleName; + } + private _isAliasModule(moduleName: string): boolean { const moduleNameMapper = this._options.moduleNameMapper; if (!moduleNameMapper) { @@ -306,6 +498,19 @@ export default class Resolver { return null; } + async getMockModuleAsync(from: string, 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; + } + getModulePaths(from: string): Array { const cachedModule = this._modulePathCache.get(from); if (cachedModule) { @@ -356,6 +561,42 @@ export default class Resolver { return id; } + async getModuleIDAsync( + virtualMocks: Map, + from: string, + moduleName = '', + options?: ResolveModuleConfig, + ): Promise { + const stringifiedOptions = options ? JSON.stringify(options) : ''; + const key = from + path.delimiter + moduleName + stringifiedOptions; + const cachedModuleID = this._moduleIDCache.get(key); + if (cachedModuleID) { + return cachedModuleID; + } + if (moduleName.startsWith('data:')) { + return moduleName; + } + + const moduleType = this._getModuleType(moduleName); + const absolutePath = await this._getAbsolutePathAsync( + virtualMocks, + from, + moduleName, + options, + ); + const mockPath = await this._getMockPathAsync(from, moduleName); + + const sep = path.delimiter; + const id = + moduleType + + sep + + (absolutePath ? absolutePath + sep : '') + + (mockPath ? mockPath + sep : ''); + + this._moduleIDCache.set(key, id); + return id; + } + private _getModuleType(moduleName: string): 'node' | 'user' { return this.isCoreModule(moduleName) ? 'node' : 'user'; } @@ -377,12 +618,47 @@ export default class Resolver { : this._getVirtualMockPath(virtualMocks, from, moduleName, options); } + private async _getAbsolutePathAsync( + virtualMocks: Map, + from: string, + moduleName: string, + options?: ResolveModuleConfig, + ): Promise { + if (this.isCoreModule(moduleName)) { + return moduleName; + } + if (moduleName.startsWith('data:')) { + return moduleName; + } + const isModuleResolved = await this._isModuleResolvedAsync( + from, + moduleName, + ); + return isModuleResolved + ? this.getModule(moduleName) + : await this._getVirtualMockPathAsync( + virtualMocks, + from, + moduleName, + options, + ); + } + private _getMockPath(from: string, moduleName: string): string | null { return !this.isCoreModule(moduleName) ? this.getMockModule(from, moduleName) : null; } + private async _getMockPathAsync( + from: string, + moduleName: string, + ): Promise { + return !this.isCoreModule(moduleName) + ? await this.getMockModuleAsync(from, moduleName) + : null; + } + private _getVirtualMockPath( virtualMocks: Map, from: string, @@ -397,32 +673,104 @@ export default class Resolver { : from; } + private async _getVirtualMockPathAsync( + virtualMocks: Map, + from: string, + moduleName: string, + options?: ResolveModuleConfig, + ): Promise { + const virtualMockPath = this.getModulePath(from, moduleName); + return virtualMocks.get(virtualMockPath) + ? virtualMockPath + : moduleName + ? await this.resolveModuleAsync(from, moduleName, options) + : from; + } + private _isModuleResolved(from: string, moduleName: string): boolean { return !!( this.getModule(moduleName) || this.getMockModule(from, moduleName) ); } + private async _isModuleResolvedAsync( + from: string, + moduleName: string, + ): Promise { + return !!( + this.getModule(moduleName) || + (await this.getMockModuleAsync(from, moduleName)) + ); + } + resolveStubModuleName(from: string, moduleName: string): string | 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 (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); - if (defaultPlatform) { - extensions.unshift( - ...this._options.extensions.map(ext => '.' + defaultPlatform + ext), - ); + module = + this.getModule(updatedName) || + Resolver.findNodeModule(updatedName, { + basedir: dirname, + extensions, + moduleDirectory, + paths, + resolver, + rootDir: this._options.rootDir, + }); + + if (module) { + break; + } + } + + if (!module) { + throw createNoMappedModuleFoundError( + moduleName, + mapModuleName, + mappedModuleName, + regex, + resolver, + ); + } + return module; + } + } } + return null; + } + + async resolveStubModuleNameAsync( + from: string, + moduleName: string, + ): Promise { + const dirname = path.dirname(from); + + const {extensions, moduleDirectory, paths} = this._prepareForResolution( + dirname, + moduleName, + ); + const moduleNameMapper = this._options.moduleNameMapper; + const resolver = this._options.resolver; if (moduleNameMapper) { for (const {moduleName: mappedModuleName, regex} of moduleNameMapper) { @@ -430,14 +778,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]; @@ -447,14 +788,14 @@ export default class Resolver { module = this.getModule(updatedName) || - Resolver.findNodeModule(updatedName, { + (await Resolver.findNodeModuleAsync(updatedName, { basedir: dirname, extensions, moduleDirectory, paths, resolver, rootDir: this._options.rootDir, - }); + })); if (module) { break; @@ -512,3 +853,36 @@ Please check your configuration for these entries: return error; }; + +type ResolverSyncObject = {sync: SyncResolver; async?: AsyncResolver}; +type ResolverAsyncObject = {sync?: SyncResolver; async: AsyncResolver}; +type ResolverObject = ResolverSyncObject | ResolverAsyncObject; + +function loadResolver( + resolver: string | undefined | null, +): SyncResolver | ResolverObject { + if (resolver == null) { + return defaultResolver; + } + + const loadedResolver = require(resolver); + + if (loadedResolver == null) { + throw new Error(`Resolver located at ${resolver} does not export anything`); + } + + if (typeof loadedResolver === 'function') { + return loadedResolver as SyncResolver; + } + + if ( + typeof loadedResolver === 'object' && + (loadedResolver.sync != null || loadedResolver.async != null) + ) { + return loadedResolver as ResolverObject; + } + + throw new Error( + `Resolver located at ${resolver} does not export a function or an object with "sync" and "async" props`, + ); +} diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 363eef8f90cb..b36b69d55569 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -63,7 +63,7 @@ export type {Context} from './types'; const esmIsAvailable = typeof SourceTextModule === 'function'; -const dataURIregex = +const dataURIRegex = /^data:(?text\/javascript|application\/json|application\/wasm)(?:;(?charset=utf-8|base64))?,(?.*)$/; interface JestGlobals extends Global.TestFrameworkGlobals { @@ -531,16 +531,17 @@ export default class Runtime { return module; } - private resolveModule( + private async resolveModule( specifier: string, referencingIdentifier: string, context: VMContext, - ): Promise | T | void { + ): Promise { if (this.isTornDown) { this._logFormattedReferenceError( 'You are trying to `import` a file after the Jest environment has been torn down.', ); process.exitCode = 1; + // @ts-expect-error - exiting return; } @@ -558,7 +559,7 @@ export default class Runtime { if (specifier.startsWith('data:')) { if ( - this._shouldMock( + await this._shouldMockModule( referencingIdentifier, specifier, this._explicitShouldMockModule, @@ -574,7 +575,7 @@ export default class Runtime { return fromCache; } - const match = specifier.match(dataURIregex); + const match = specifier.match(dataURIRegex); if (!match || !match.groups) { throw new Error('Invalid data URI'); @@ -643,7 +644,7 @@ export default class Runtime { const [path, query] = specifier.split('?'); if ( - this._shouldMock( + await this._shouldMockModule( referencingIdentifier, path, this._explicitShouldMockModule, @@ -653,7 +654,7 @@ export default class Runtime { return this.importMock(referencingIdentifier, path, context); } - const resolved = this._resolveModule(referencingIdentifier, path, { + const resolved = await this._resolveModule(referencingIdentifier, path, { conditions: this.esmConditions, }); @@ -713,7 +714,7 @@ export default class Runtime { const [path, query] = (moduleName ?? '').split('?'); - const modulePath = this._resolveModule(from, path, { + const modulePath = await this._resolveModule(from, path, { conditions: this.esmConditions, }); @@ -757,7 +758,7 @@ export default class Runtime { moduleName: string, context: VMContext, ): Promise { - const moduleID = this._resolver.getModuleID( + const moduleID = await this._resolver.getModuleIDAsync( this._virtualModuleMocks, from, moduleName, @@ -808,7 +809,7 @@ export default class Runtime { const namedExports = new Set(exports); reexports.forEach(reexport => { - const resolved = this._resolveModule(modulePath, reexport, { + const resolved = this._resolveCjsModule(modulePath, reexport, { conditions: this.esmConditions, }); @@ -861,18 +862,17 @@ export default class Runtime { } if (!modulePath) { - modulePath = this._resolveModule(from, moduleName, { + modulePath = this._resolveCjsModule(from, moduleName, { conditions: this.cjsConditions, }); } if (this.unstable_shouldLoadAsEsm(modulePath)) { // Node includes more info in the message - const error = new Error( + const error: NodeJS.ErrnoException = new Error( `Must use import to load ES Module: ${modulePath}`, ); - // @ts-expect-error: `code` is not defined error.code = 'ERR_REQUIRE_ESM'; throw error; @@ -977,7 +977,9 @@ export default class Runtime { let modulePath = this._resolver.getMockModule(from, moduleName) || - this._resolveModule(from, moduleName, {conditions: this.cjsConditions}); + this._resolveCjsModule(from, moduleName, { + conditions: this.cjsConditions, + }); let isManualMock = manualMockOrStub && @@ -1082,7 +1084,7 @@ export default class Runtime { try { if ( - this._shouldMock(from, moduleName, this._explicitShouldMock, { + this._shouldMockCjs(from, moduleName, this._explicitShouldMock, { conditions: this.cjsConditions, }) ) { @@ -1300,7 +1302,7 @@ export default class Runtime { this.isTornDown = true; } - private _resolveModule( + private _resolveCjsModule( from: string, to: string | undefined, options?: ResolveModuleConfig, @@ -1308,6 +1310,14 @@ export default class Runtime { return to ? this._resolver.resolveModule(from, to, options) : from; } + private _resolveModule( + from: string, + to: string | undefined, + options?: ResolveModuleConfig, + ) { + return to ? this._resolver.resolveModuleAsync(from, to, options) : from; + } + private _requireResolve( from: string, moduleName?: string, @@ -1353,7 +1363,7 @@ export default class Runtime { } try { - return this._resolveModule(from, moduleName, { + return this._resolveCjsModule(from, moduleName, { conditions: this.cjsConditions, }); } catch (err) { @@ -1720,7 +1730,9 @@ export default class Runtime { private _generateMock(from: string, moduleName: string) { const modulePath = this._resolver.resolveStubModuleName(from, moduleName) || - this._resolveModule(from, moduleName, {conditions: this.cjsConditions}); + this._resolveCjsModule(from, moduleName, { + conditions: this.cjsConditions, + }); if (!this._mockMetaDataCache.has(modulePath)) { // This allows us to handle circular dependencies while generating an // automock @@ -1760,7 +1772,7 @@ export default class Runtime { ); } - private _shouldMock( + private _shouldMockCjs( from: string, moduleName: string, explicitShouldMock: Map, @@ -1794,7 +1806,7 @@ export default class Runtime { let modulePath; try { - modulePath = this._resolveModule(from, moduleName, options); + modulePath = this._resolveCjsModule(from, moduleName, options); } catch (e) { const manualMock = this._resolver.getMockModule(from, moduleName); if (manualMock) { @@ -1831,6 +1843,80 @@ export default class Runtime { return true; } + private async _shouldMockModule( + from: string, + moduleName: string, + explicitShouldMock: Map, + options: ResolveModuleConfig, + ): Promise { + const moduleID = await this._resolver.getModuleIDAsync( + this._virtualMocks, + from, + moduleName, + options, + ); + const key = from + path.delimiter + moduleID; + + if (explicitShouldMock.has(moduleID)) { + // guaranteed by `has` above + return explicitShouldMock.get(moduleID)!; + } + + if ( + !this._shouldAutoMock || + this._resolver.isCoreModule(moduleName) || + this._shouldUnmockTransitiveDependenciesCache.get(key) + ) { + return false; + } + + if (this._shouldMockModuleCache.has(moduleID)) { + // guaranteed by `has` above + return this._shouldMockModuleCache.get(moduleID)!; + } + + let modulePath; + try { + modulePath = await this._resolveModule(from, moduleName, options); + } catch (e) { + const manualMock = await this._resolver.getMockModuleAsync( + from, + moduleName, + ); + if (manualMock) { + this._shouldMockModuleCache.set(moduleID, true); + return true; + } + throw e; + } + + if (this._unmockList && this._unmockList.test(modulePath)) { + this._shouldMockModuleCache.set(moduleID, false); + return false; + } + + // transitive unmocking for package managers that store flat packages (npm3) + const currentModuleID = await this._resolver.getModuleIDAsync( + this._virtualMocks, + from, + undefined, + options, + ); + if ( + this._transitiveShouldMock.get(currentModuleID) === false || + (from.includes(NODE_MODULES) && + modulePath.includes(NODE_MODULES) && + ((this._unmockList && this._unmockList.test(from)) || + explicitShouldMock.get(currentModuleID) === false)) + ) { + this._transitiveShouldMock.set(moduleID, false); + this._shouldUnmockTransitiveDependenciesCache.set(key, true); + return false; + } + this._shouldMockModuleCache.set(moduleID, true); + return true; + } + private _createRequireImplementation( from: InitialModule, options?: InternalModuleOptions,