diff --git a/CHANGELOG.md b/CHANGELOG.md index c1613581893c..1c28418eca50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### 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)) +- `[jest-runtime]` Allow custom envs to specify `exportConditions` which is passed together with Jest's own conditions to custom resolvers ([#11863](https://github.com/facebook/jest/pull/11863)) ### Fixes diff --git a/docs/Configuration.md b/docs/Configuration.md index aa8edd6e5b1d..384e60723092 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -851,7 +851,7 @@ 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. +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. Additionally, custom test environments can specify an `exportConditions` method which returns an array of conditions that will be passed along with Jest's defaults. ### `restoreMocks` \[boolean] diff --git a/e2e/resolve-conditions/__tests__/browser.test.mjs b/e2e/resolve-conditions/__tests__/browser.test.mjs new file mode 100644 index 000000000000..12b397aa5afc --- /dev/null +++ b/e2e/resolve-conditions/__tests__/browser.test.mjs @@ -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. + * + * @jest-environment /browser-env.js + */ + +import {fn} from '../fake-dual-dep'; + +test('returns correct message', () => { + expect(fn()).toEqual('hello from browser'); +}); diff --git a/e2e/resolve-conditions/__tests__/node.test.mjs b/e2e/resolve-conditions/__tests__/node.test.mjs new file mode 100644 index 000000000000..fb86c2a8eeac --- /dev/null +++ b/e2e/resolve-conditions/__tests__/node.test.mjs @@ -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. + * + * @jest-environment /node-env.js + */ + +import {fn} from '../fake-dual-dep'; + +test('returns correct message', () => { + expect(fn()).toEqual('hello from node'); +}); diff --git a/e2e/resolve-conditions/browser-env.js b/e2e/resolve-conditions/browser-env.js new file mode 100644 index 000000000000..86b2dfd19f47 --- /dev/null +++ b/e2e/resolve-conditions/browser-env.js @@ -0,0 +1,16 @@ +/** + * 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 BrowserEnv = require('jest-environment-jsdom'); + +module.exports = class BrowserEnvWithConditions extends BrowserEnv { + exportConditions() { + return ['browser']; + } +}; diff --git a/e2e/resolve-conditions/fake-dual-dep/browser.mjs b/e2e/resolve-conditions/fake-dual-dep/browser.mjs new file mode 100644 index 000000000000..560c80e0150c --- /dev/null +++ b/e2e/resolve-conditions/fake-dual-dep/browser.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 browser'; +} diff --git a/e2e/resolve-conditions/fake-dual-dep/node.mjs b/e2e/resolve-conditions/fake-dual-dep/node.mjs new file mode 100644 index 000000000000..830245ec4fd8 --- /dev/null +++ b/e2e/resolve-conditions/fake-dual-dep/node.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 node'; +} diff --git a/e2e/resolve-conditions/fake-dual-dep/package.json b/e2e/resolve-conditions/fake-dual-dep/package.json new file mode 100644 index 000000000000..68c7da0cdd1c --- /dev/null +++ b/e2e/resolve-conditions/fake-dual-dep/package.json @@ -0,0 +1,10 @@ +{ + "name": "fake-dual-dep", + "version": "1.0.0", + "exports": { + ".": { + "node": "./node.mjs", + "browser": "./browser.mjs" + } + } +} diff --git a/e2e/resolve-conditions/node-env.js b/e2e/resolve-conditions/node-env.js new file mode 100644 index 000000000000..fa756d251ea1 --- /dev/null +++ b/e2e/resolve-conditions/node-env.js @@ -0,0 +1,16 @@ +/** + * 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 NodeEnv = require('jest-environment-node'); + +module.exports = class NodeEnvWithConditions extends NodeEnv { + exportConditions() { + return ['node']; + } +}; diff --git a/e2e/resolve-conditions/resolver.js b/e2e/resolve-conditions/resolver.js index 5f27fa6a1300..98c8aed1bf67 100644 --- a/e2e/resolve-conditions/resolver.js +++ b/e2e/resolve-conditions/resolver.js @@ -25,9 +25,11 @@ function createPathFilter(conditions) { return ( resolveExports(pkg, path, { + // `resolve.exports adds `node` unless `browser` is `false`, so let's add this ugly thing + browser: conditions.includes('browser'), conditions, // `resolve.exports adds `import` unless `require` is `false`, so let's add this ugly thing - require: !conditions.includes('import'), + require: conditions.includes('require'), }) || relativePath ); }; diff --git a/packages/jest-environment-node/src/index.ts b/packages/jest-environment-node/src/index.ts index 26f336b0cd52..2f3a433ac391 100644 --- a/packages/jest-environment-node/src/index.ts +++ b/packages/jest-environment-node/src/index.ts @@ -18,7 +18,7 @@ type Timer = { unref: () => Timer; }; -class NodeEnvironment implements JestEnvironment { +class NodeEnvironment implements JestEnvironment { context: Context | null; fakeTimers: LegacyFakeTimers | null; fakeTimersModern: ModernFakeTimers | null; diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index 72d1fa37aeb6..42cb7f8927c1 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -42,6 +42,7 @@ export declare class JestEnvironment { setup(): Promise; teardown(): Promise; handleTestEvent?: Circus.EventHandler; + exportConditions?: () => Array; } export type Module = NodeModule; diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index d52f55e31669..c3ae4051bca4 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -173,10 +173,6 @@ 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; @@ -228,6 +224,8 @@ export default class Runtime { private _moduleImplementation?: typeof nativeModule.Module; private readonly jestObjectCaches: Map; private jestGlobals?: JestGlobals; + private readonly esmConditions: Array; + private readonly cjsConditions: Array; constructor( config: Config.ProjectConfig, @@ -292,6 +290,15 @@ export default class Runtime { unmockRegExpCache.set(config, this._unmockList); } + const envExportConditions = this._environment.exportConditions?.() ?? []; + + this.esmConditions = Array.from( + new Set(['import', 'default', ...envExportConditions]), + ); + this.cjsConditions = Array.from( + new Set(['require', 'default', ...envExportConditions]), + ); + if (config.automock) { config.setupFiles.forEach(filePath => { if (filePath.includes(NODE_MODULES)) { @@ -302,8 +309,8 @@ export default class Runtime { // shouldn't really matter, but in theory this will make sure the caching is correct { conditions: this.unstable_shouldLoadAsEsm(filePath) - ? esmConditions - : cjsConditions, + ? this.esmConditions + : this.cjsConditions, }, ); this._transitiveShouldMock.set(moduleID, false); @@ -547,14 +554,14 @@ export default class Runtime { referencingIdentifier, path, this._explicitShouldMockModule, - {conditions: esmConditions}, + {conditions: this.esmConditions}, ) ) { return this.importMock(referencingIdentifier, path, context); } const resolved = this._resolveModule(referencingIdentifier, path, { - conditions: esmConditions, + conditions: this.esmConditions, }); if ( @@ -604,7 +611,7 @@ export default class Runtime { const [path, query] = (moduleName ?? '').split('?'); const modulePath = this._resolveModule(from, path, { - conditions: esmConditions, + conditions: this.esmConditions, }); const module = await this.loadEsmModule(modulePath, query); @@ -655,7 +662,7 @@ export default class Runtime { this._virtualModuleMocks, from, moduleName, - {conditions: esmConditions}, + {conditions: this.esmConditions}, ); if (this._moduleMockRegistry.has(moduleID)) { @@ -703,7 +710,7 @@ export default class Runtime { reexports.forEach(reexport => { const resolved = this._resolveModule(modulePath, reexport, { - conditions: esmConditions, + conditions: this.esmConditions, }); const exports = this.getExportsOfCjs(resolved); @@ -727,7 +734,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - isInternal ? undefined : {conditions: cjsConditions}, + isInternal ? undefined : {conditions: this.cjsConditions}, ); let modulePath: string | undefined; @@ -758,7 +765,7 @@ export default class Runtime { modulePath = this._resolveModule( from, moduleName, - isInternal ? undefined : {conditions: cjsConditions}, + isInternal ? undefined : {conditions: this.cjsConditions}, ); } @@ -851,7 +858,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - {conditions: cjsConditions}, + {conditions: this.cjsConditions}, ); const mockRegistry = this._isolatedMockRegistry || this._mockRegistry; @@ -871,7 +878,7 @@ export default class Runtime { let modulePath = this._resolver.getMockModule(from, moduleName) || - this._resolveModule(from, moduleName, {conditions: cjsConditions}); + this._resolveModule(from, moduleName, {conditions: this.cjsConditions}); let isManualMock = manualMockOrStub && @@ -977,7 +984,7 @@ export default class Runtime { try { if ( this._shouldMock(from, moduleName, this._explicitShouldMock, { - conditions: cjsConditions, + conditions: this.cjsConditions, }) ) { return this.requireMock(from, moduleName); @@ -1122,7 +1129,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - {conditions: cjsConditions}, + {conditions: this.cjsConditions}, ); this._explicitShouldMock.set(moduleID, true); this._mockFactories.set(moduleID, mockFactory); @@ -1143,7 +1150,7 @@ export default class Runtime { this._virtualModuleMocks, from, moduleName, - {conditions: esmConditions}, + {conditions: this.esmConditions}, ); this._explicitShouldMockModule.set(moduleID, true); this._moduleMockFactories.set(moduleID, mockFactory); @@ -1220,7 +1227,7 @@ export default class Runtime { absolutePath, moduleName, // required to also resolve files without leading './' directly in the path - {conditions: cjsConditions, paths: [absolutePath]}, + {conditions: this.cjsConditions, paths: [absolutePath]}, ); if (module) { return module; @@ -1234,7 +1241,9 @@ export default class Runtime { ); } try { - return this._resolveModule(from, moduleName, {conditions: cjsConditions}); + return this._resolveModule(from, moduleName, { + conditions: this.cjsConditions, + }); } catch (err) { const module = this._resolver.getMockModule(from, moduleName); @@ -1589,7 +1598,7 @@ export default class Runtime { private _generateMock(from: Config.Path, moduleName: string) { const modulePath = this._resolver.resolveStubModuleName(from, moduleName) || - this._resolveModule(from, moduleName, {conditions: cjsConditions}); + this._resolveModule(from, moduleName, {conditions: this.cjsConditions}); if (!this._mockMetaDataCache.has(modulePath)) { // This allows us to handle circular dependencies while generating an // automock @@ -1772,7 +1781,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - {conditions: cjsConditions}, + {conditions: this.cjsConditions}, ); this._explicitShouldMock.set(moduleID, false); return jestObject; @@ -1782,7 +1791,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - {conditions: cjsConditions}, + {conditions: this.cjsConditions}, ); this._explicitShouldMock.set(moduleID, false); this._transitiveShouldMock.set(moduleID, false); @@ -1797,7 +1806,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - {conditions: cjsConditions}, + {conditions: this.cjsConditions}, ); this._explicitShouldMock.set(moduleID, true); return jestObject;