diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d115bde1cf7..c1613581893c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Features +- `[jest-resolver, jest-runtime]` Pass `conditions` to custom resolvers to enable them to implement support for package.json `exports` field ([#11859](https://github.com/facebook/jest/pull/11859)) + ### Fixes - `[@jest/reporters]` Use async transform if available to transform files with no coverage ([#11852](https://github.com/facebook/jest/pull/11852)) diff --git a/docs/Configuration.md b/docs/Configuration.md index de70e3cdbebd..aa8edd6e5b1d 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -788,11 +788,13 @@ This option allows the use of a custom resolver. This resolver must be a node mo ```json { "basedir": string, + "conditions": [string], "defaultResolver": "function(request, options)", "extensions": [string], "moduleDirectory": [string], "paths": [string], "packageFilter": "function(pkg, pkgdir)", + "pathFilter": "function(pkg, path, relativePath)", "rootDir": [string] } ``` @@ -849,6 +851,8 @@ module.exports = (request, options) => { }; ``` +While Jest does not support [package `exports`](https://nodejs.org/api/packages.html#packages_package_entry_points) (beyond `main`), Jest will provide `conditions` as an option when calling custom resolvers, which can be used to implement support for `exports` in userland. Jest will pass `['import', 'default']` when running a test in ESM mode, and `['require', 'default']` when running with CJS. + ### `restoreMocks` \[boolean] Default: `false` diff --git a/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap b/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap index 4431860f7880..c73d54ca2d2e 100644 --- a/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap +++ b/e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap @@ -41,7 +41,7 @@ FAIL __tests__/index.js 12 | module.exports = () => 'test'; 13 | - at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:566:17) + at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:577:17) at Object.require (index.js:10:1) `; @@ -70,6 +70,6 @@ FAIL __tests__/index.js 12 | module.exports = () => 'test'; 13 | - at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:566:17) + at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:577:17) at Object.require (index.js:10:1) `; diff --git a/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap b/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap index 10321533f870..0abfba5f3a54 100644 --- a/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap +++ b/e2e/__tests__/__snapshots__/resolveNoFileExtensions.test.ts.snap @@ -37,6 +37,6 @@ FAIL __tests__/test.js | ^ 9 | - at Resolver.resolveModule (../../packages/jest-resolve/build/resolver.js:318:11) + at Resolver.resolveModule (../../packages/jest-resolve/build/resolver.js:322:11) at Object.require (index.js:8:18) `; diff --git a/e2e/__tests__/resolveConditions.test.ts b/e2e/__tests__/resolveConditions.test.ts new file mode 100644 index 000000000000..01b43d6ea0e4 --- /dev/null +++ b/e2e/__tests__/resolveConditions.test.ts @@ -0,0 +1,35 @@ +/** + * 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 {resolve} from 'path'; +import {onNodeVersions} from '@jest/test-utils'; +import {runYarnInstall} from '../Utils'; +import runJest from '../runJest'; + +const dir = resolve(__dirname, '..', 'resolve-conditions'); + +beforeAll(() => { + runYarnInstall(dir); +}); + +// The versions where vm.Module exists and commonjs with "exports" is not broken +onNodeVersions('^12.16.0 || >=13.7.0', () => { + test('resolves package exports correctly with custom resolver', () => { + // run multiple times to ensure there are no caching errors + for (let i = 0; i < 5; i++) { + const {exitCode} = runJest(dir, [], { + nodeOptions: '--experimental-vm-modules', + }); + try { + expect(exitCode).toBe(0); + } catch (error) { + console.log(`Test failed on iteration ${i + 1}`); + throw error; + } + } + }); +}); diff --git a/e2e/resolve-conditions/__tests__/resolveCjs.test.cjs b/e2e/resolve-conditions/__tests__/resolveCjs.test.cjs new file mode 100644 index 000000000000..5a9ba6b23b88 --- /dev/null +++ b/e2e/resolve-conditions/__tests__/resolveCjs.test.cjs @@ -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. + */ + +const {fn} = require('../fake-dep'); + +test('returns correct message', () => { + expect(fn()).toEqual('hello from CJS'); +}); diff --git a/e2e/resolve-conditions/__tests__/resolveEsm.test.mjs b/e2e/resolve-conditions/__tests__/resolveEsm.test.mjs new file mode 100644 index 000000000000..d46c700f2cf2 --- /dev/null +++ b/e2e/resolve-conditions/__tests__/resolveEsm.test.mjs @@ -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 {fn} from '../fake-dep'; + +test('returns correct message', () => { + expect(fn()).toEqual('hello from ESM'); +}); diff --git a/e2e/resolve-conditions/fake-dep/module.cjs b/e2e/resolve-conditions/fake-dep/module.cjs new file mode 100644 index 000000000000..ef70308ccc34 --- /dev/null +++ b/e2e/resolve-conditions/fake-dep/module.cjs @@ -0,0 +1,10 @@ +/** + * 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. + */ + +module.exports = { + fn: () => 'hello from CJS', +}; diff --git a/e2e/resolve-conditions/fake-dep/module.mjs b/e2e/resolve-conditions/fake-dep/module.mjs new file mode 100644 index 000000000000..45d72be15345 --- /dev/null +++ b/e2e/resolve-conditions/fake-dep/module.mjs @@ -0,0 +1,10 @@ +/** + * 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 function fn() { + return 'hello from ESM'; +} diff --git a/e2e/resolve-conditions/fake-dep/package.json b/e2e/resolve-conditions/fake-dep/package.json new file mode 100644 index 000000000000..fdccd90403cf --- /dev/null +++ b/e2e/resolve-conditions/fake-dep/package.json @@ -0,0 +1,10 @@ +{ + "name": "fake-dep", + "version": "1.0.0", + "exports": { + ".": { + "import": "./module.mjs", + "require": "./module.cjs" + } + } +} diff --git a/e2e/resolve-conditions/package.json b/e2e/resolve-conditions/package.json new file mode 100644 index 000000000000..987f46111b8a --- /dev/null +++ b/e2e/resolve-conditions/package.json @@ -0,0 +1,18 @@ +{ + "jest": { + "moduleFileExtensions": [ + "js", + "cjs", + "mjs", + "json" + ], + "resolver": "/resolver.js", + "testMatch": [ + "/**/*.test.*" + ], + "transform": {} + }, + "dependencies": { + "resolve.exports": "^1.0.2" + } +} diff --git a/e2e/resolve-conditions/resolver.js b/e2e/resolve-conditions/resolver.js new file mode 100644 index 000000000000..5f27fa6a1300 --- /dev/null +++ b/e2e/resolve-conditions/resolver.js @@ -0,0 +1,34 @@ +/** + * 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 {resolve: resolveExports} = require('resolve.exports'); + +module.exports = (path, options) => { + return options.defaultResolver(path, { + ...options, + pathFilter: options.conditions + ? createPathFilter(options.conditions) + : undefined, + }); +}; + +function createPathFilter(conditions) { + return function pathFilter(pkg, _path, relativePath) { + // this `index` thing can backfire, but `resolve` adds it: https://github.com/browserify/resolve/blob/f1b51848ecb7f56f77bfb823511d032489a13eab/lib/sync.js#L192 + const path = relativePath === 'index' ? '.' : relativePath; + + return ( + resolveExports(pkg, path, { + conditions, + // `resolve.exports adds `import` unless `require` is `false`, so let's add this ugly thing + require: !conditions.includes('import'), + }) || relativePath + ); + }; +} diff --git a/e2e/resolve-conditions/yarn.lock b/e2e/resolve-conditions/yarn.lock new file mode 100644 index 000000000000..b98bb300b2b9 --- /dev/null +++ b/e2e/resolve-conditions/yarn.lock @@ -0,0 +1,21 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 4 + cacheKey: 7 + +"resolve.exports@npm:^1.0.2": + version: 1.0.2 + resolution: "resolve.exports@npm:1.0.2" + checksum: 012a46e3ae41c53762abf5b50ea1b4adf2de617bbea1dbc7bf6e609c1ceaedee7782acbc92d443951d5dd0c3a8fb1090ce73285a9ccc24b530e33b5e09ae196f + languageName: node + linkType: hard + +"root-workspace-0b6124@workspace:.": + version: 0.0.0-use.local + resolution: "root-workspace-0b6124@workspace:." + dependencies: + resolve.exports: ^1.0.2 + languageName: unknown + linkType: soft diff --git a/packages/jest-resolve/src/__tests__/resolve.test.ts b/packages/jest-resolve/src/__tests__/resolve.test.ts index 56d1640b3e7c..cfbe99bc841d 100644 --- a/packages/jest-resolve/src/__tests__/resolve.test.ts +++ b/packages/jest-resolve/src/__tests__/resolve.test.ts @@ -105,6 +105,7 @@ describe('findNodeModule', () => { const newPath = Resolver.findNodeModule('test', { basedir: '/', browser: true, + conditions: ['conditions, woooo'], extensions: ['js'], moduleDirectory: ['node_modules'], paths: ['/something'], @@ -116,6 +117,7 @@ describe('findNodeModule', () => { expect(userResolver.mock.calls[0][1]).toStrictEqual({ basedir: '/', browser: true, + conditions: ['conditions, woooo'], defaultResolver, extensions: ['js'], moduleDirectory: ['node_modules'], diff --git a/packages/jest-resolve/src/defaultResolver.ts b/packages/jest-resolve/src/defaultResolver.ts index 7b3dfe17a3bd..942b6db0b1cb 100644 --- a/packages/jest-resolve/src/defaultResolver.ts +++ b/packages/jest-resolve/src/defaultResolver.ts @@ -19,7 +19,16 @@ type ResolverOptions = { moduleDirectory?: Array; paths?: Array; rootDir?: Config.Path; - packageFilter?: (pkg: any, pkgfile: string) => any; + packageFilter?: ( + pkg: Record, + pkgfile: string, + ) => Record; + pathFilter?: ( + pkg: Record, + path: string, + relativePath: string, + ) => string; + conditions?: Array; }; // https://github.com/facebook/jest/pull/10617 @@ -48,6 +57,7 @@ export default function defaultResolver( isFile, moduleDirectory: options.moduleDirectory, packageFilter: options.packageFilter, + pathFilter: options.pathFilter, paths: options.paths, preserveSymlinks: false, readPackageSync, diff --git a/packages/jest-resolve/src/resolver.ts b/packages/jest-resolve/src/resolver.ts index b532c73ba410..315537ae4fcd 100644 --- a/packages/jest-resolve/src/resolver.ts +++ b/packages/jest-resolve/src/resolver.ts @@ -23,6 +23,7 @@ import type {ResolverConfig} from './types'; type FindNodeModuleConfig = { basedir: Config.Path; browser?: boolean; + conditions?: Array; extensions?: Array; moduleDirectory?: Array; paths?: Array; @@ -32,6 +33,7 @@ type FindNodeModuleConfig = { }; export type ResolveModuleConfig = { + conditions?: Array; skipNodeResolution?: boolean; paths?: Array; }; @@ -113,6 +115,7 @@ export default class Resolver { return resolver(path, { basedir: options.basedir, browser: options.browser, + conditions: options.conditions, defaultResolver, extensions: options.extensions, moduleDirectory: options.moduleDirectory, @@ -137,7 +140,8 @@ export default class Resolver { ): Config.Path | null { const paths = options?.paths || this._options.modulePaths; const moduleDirectory = this._options.moduleDirectories; - const key = dirname + path.delimiter + moduleName; + 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; @@ -183,6 +187,7 @@ export default class Resolver { return Resolver.findNodeModule(name, { basedir: dirname, + conditions: options?.conditions, extensions, moduleDirectory, paths, @@ -321,15 +326,22 @@ export default class Resolver { virtualMocks: Map, from: Config.Path, moduleName = '', + options?: ResolveModuleConfig, ): string { - const key = from + path.delimiter + moduleName; + const stringifiedOptions = options ? JSON.stringify(options) : ''; + const key = from + path.delimiter + moduleName + stringifiedOptions; const cachedModuleID = this._moduleIDCache.get(key); if (cachedModuleID) { return cachedModuleID; } const moduleType = this._getModuleType(moduleName); - const absolutePath = this._getAbsolutePath(virtualMocks, from, moduleName); + const absolutePath = this._getAbsolutePath( + virtualMocks, + from, + moduleName, + options, + ); const mockPath = this._getMockPath(from, moduleName); const sep = path.delimiter; @@ -337,7 +349,8 @@ export default class Resolver { moduleType + sep + (absolutePath ? absolutePath + sep : '') + - (mockPath ? mockPath + sep : ''); + (mockPath ? mockPath + sep : '') + + (stringifiedOptions ? stringifiedOptions + sep : ''); this._moduleIDCache.set(key, id); return id; @@ -351,13 +364,14 @@ export default class Resolver { virtualMocks: Map, from: Config.Path, moduleName: string, + options?: ResolveModuleConfig, ): Config.Path | null { if (this.isCoreModule(moduleName)) { return moduleName; } return this._isModuleResolved(from, moduleName) ? this.getModule(moduleName) - : this._getVirtualMockPath(virtualMocks, from, moduleName); + : this._getVirtualMockPath(virtualMocks, from, moduleName, options); } private _getMockPath( @@ -373,12 +387,13 @@ export default class Resolver { virtualMocks: Map, from: Config.Path, moduleName: string, + options?: ResolveModuleConfig, ): Config.Path { const virtualMockPath = this.getModulePath(from, moduleName); return virtualMocks.get(virtualMockPath) ? virtualMockPath : moduleName - ? this.resolveModule(from, moduleName) + ? this.resolveModule(from, moduleName, options) : from; } diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 340501e0b44b..d52f55e31669 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -48,7 +48,7 @@ import HasteMap from 'jest-haste-map'; import {formatStackTrace, separateMessageFromStack} from 'jest-message-util'; import type {MockFunctionMetadata, ModuleMocker} from 'jest-mock'; import {escapePathForRegex} from 'jest-regex-util'; -import Resolver from 'jest-resolve'; +import Resolver, {ResolveModuleConfig} from 'jest-resolve'; import Snapshot = require('jest-snapshot'); import {createDirectory, deepCyclicCopy} from 'jest-util'; import { @@ -173,6 +173,10 @@ const supportsNodeColonModulePrefixInImport = (() => { return stdout === 'true'; })(); +// consider `node` & `browser` condition as well - maybe switching on `global.window` in the test env? +const esmConditions = ['import', 'default']; +const cjsConditions = ['require', 'default']; + export default class Runtime { private readonly _cacheFS: Map; private readonly _config: Config.ProjectConfig; @@ -294,6 +298,13 @@ export default class Runtime { const moduleID = this._resolver.getModuleID( this._virtualMocks, filePath, + undefined, + // shouldn't really matter, but in theory this will make sure the caching is correct + { + conditions: this.unstable_shouldLoadAsEsm(filePath) + ? esmConditions + : cjsConditions, + }, ); this._transitiveShouldMock.set(moduleID, false); } @@ -536,12 +547,15 @@ export default class Runtime { referencingIdentifier, path, this._explicitShouldMockModule, + {conditions: esmConditions}, ) ) { return this.importMock(referencingIdentifier, path, context); } - const resolved = this._resolveModule(referencingIdentifier, path); + const resolved = this._resolveModule(referencingIdentifier, path, { + conditions: esmConditions, + }); if ( this._resolver.isCoreModule(resolved) || @@ -589,7 +603,9 @@ export default class Runtime { const [path, query] = (moduleName ?? '').split('?'); - const modulePath = this._resolveModule(from, path); + const modulePath = this._resolveModule(from, path, { + conditions: esmConditions, + }); const module = await this.loadEsmModule(modulePath, query); @@ -639,6 +655,7 @@ export default class Runtime { this._virtualModuleMocks, from, moduleName, + {conditions: esmConditions}, ); if (this._moduleMockRegistry.has(moduleID)) { @@ -685,7 +702,9 @@ export default class Runtime { const namedExports = new Set(exports); reexports.forEach(reexport => { - const resolved = this._resolveModule(modulePath, reexport); + const resolved = this._resolveModule(modulePath, reexport, { + conditions: esmConditions, + }); const exports = this.getExportsOfCjs(resolved); @@ -703,10 +722,12 @@ export default class Runtime { options?: InternalModuleOptions, isRequireActual = false, ): T { + const isInternal = options?.isInternalModule ?? false; const moduleID = this._resolver.getModuleID( this._virtualMocks, from, moduleName, + isInternal ? undefined : {conditions: cjsConditions}, ); let modulePath: string | undefined; @@ -734,7 +755,11 @@ export default class Runtime { } if (!modulePath) { - modulePath = this._resolveModule(from, moduleName); + modulePath = this._resolveModule( + from, + moduleName, + isInternal ? undefined : {conditions: cjsConditions}, + ); } if (this.unstable_shouldLoadAsEsm(modulePath)) { @@ -751,7 +776,7 @@ export default class Runtime { let moduleRegistry; - if (options?.isInternalModule) { + if (isInternal) { moduleRegistry = this._internalModuleRegistry; } else if (this._isolatedModuleRegistry) { moduleRegistry = this._isolatedModuleRegistry; @@ -826,6 +851,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, + {conditions: cjsConditions}, ); const mockRegistry = this._isolatedMockRegistry || this._mockRegistry; @@ -845,7 +871,7 @@ export default class Runtime { let modulePath = this._resolver.getMockModule(from, moduleName) || - this._resolveModule(from, moduleName); + this._resolveModule(from, moduleName, {conditions: cjsConditions}); let isManualMock = manualMockOrStub && @@ -949,7 +975,11 @@ export default class Runtime { } try { - if (this._shouldMock(from, moduleName, this._explicitShouldMock)) { + if ( + this._shouldMock(from, moduleName, this._explicitShouldMock, { + conditions: cjsConditions, + }) + ) { return this.requireMock(from, moduleName); } else { return this.requireModule(from, moduleName); @@ -1092,6 +1122,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, + {conditions: cjsConditions}, ); this._explicitShouldMock.set(moduleID, true); this._mockFactories.set(moduleID, mockFactory); @@ -1112,6 +1143,7 @@ export default class Runtime { this._virtualModuleMocks, from, moduleName, + {conditions: esmConditions}, ); this._explicitShouldMockModule.set(moduleID, true); this._moduleMockFactories.set(moduleID, mockFactory); @@ -1160,8 +1192,12 @@ export default class Runtime { this._moduleImplementation = undefined; } - private _resolveModule(from: Config.Path, to?: string) { - return to ? this._resolver.resolveModule(from, to) : from; + private _resolveModule( + from: Config.Path, + to: string | undefined, + options?: ResolveModuleConfig, + ) { + return to ? this._resolver.resolveModule(from, to, options) : from; } private _requireResolve( @@ -1184,7 +1220,7 @@ export default class Runtime { absolutePath, moduleName, // required to also resolve files without leading './' directly in the path - {paths: [absolutePath]}, + {conditions: cjsConditions, paths: [absolutePath]}, ); if (module) { return module; @@ -1198,7 +1234,7 @@ export default class Runtime { ); } try { - return this._resolveModule(from, moduleName); + return this._resolveModule(from, moduleName, {conditions: cjsConditions}); } catch (err) { const module = this._resolver.getMockModule(from, moduleName); @@ -1553,7 +1589,7 @@ export default class Runtime { private _generateMock(from: Config.Path, moduleName: string) { const modulePath = this._resolver.resolveStubModuleName(from, moduleName) || - this._resolveModule(from, moduleName); + this._resolveModule(from, moduleName, {conditions: cjsConditions}); if (!this._mockMetaDataCache.has(modulePath)) { // This allows us to handle circular dependencies while generating an // automock @@ -1597,11 +1633,13 @@ export default class Runtime { from: Config.Path, moduleName: string, explicitShouldMock: Map, + options: ResolveModuleConfig, ): boolean { const moduleID = this._resolver.getModuleID( this._virtualMocks, from, moduleName, + options, ); const key = from + path.delimiter + moduleID; @@ -1625,7 +1663,7 @@ export default class Runtime { let modulePath; try { - modulePath = this._resolveModule(from, moduleName); + modulePath = this._resolveModule(from, moduleName, options); } catch (e) { const manualMock = this._resolver.getMockModule(from, moduleName); if (manualMock) { @@ -1644,6 +1682,8 @@ export default class Runtime { const currentModuleID = this._resolver.getModuleID( this._virtualMocks, from, + undefined, + options, ); if ( this._transitiveShouldMock.get(currentModuleID) === false || @@ -1732,6 +1772,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, + {conditions: cjsConditions}, ); this._explicitShouldMock.set(moduleID, false); return jestObject; @@ -1741,6 +1782,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, + {conditions: cjsConditions}, ); this._explicitShouldMock.set(moduleID, false); this._transitiveShouldMock.set(moduleID, false); @@ -1755,6 +1797,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, + {conditions: cjsConditions}, ); this._explicitShouldMock.set(moduleID, true); return jestObject;