diff --git a/CHANGELOG.md b/CHANGELOG.md index 363abee8a97e..14ec6ea627de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[jest-runtime][jest-environment][jest-environment-jsdom-abstract][jest-environment-node]` Allow more granular custom export conditions in test environment options; conditions can be defined both globally and for a set of modules ([#15340](https://github.com/jestjs/jest/pull/15340)) - `[babel-jest]` Add option `excludeJestPreset` to allow opting out of `babel-preset-jest` ([#15164](https://github.com/jestjs/jest/pull/15164)) - `[jest-circus, jest-cli, jest-config]` Add `waitNextEventLoopTurnForUnhandledRejectionEvents` flag to minimise performance impact of correct detection of unhandled promise rejections introduced in [#14315](https://github.com/jestjs/jest/pull/14315) ([#14681](https://github.com/jestjs/jest/pull/14681)) - `[jest-circus]` Add a `waitBeforeRetry` option to `jest.retryTimes` ([#14738](https://github.com/jestjs/jest/pull/14738)) diff --git a/docs/Configuration.md b/docs/Configuration.md index 1ca9a15e1341..16f5f77633a9 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -2023,6 +2023,43 @@ const config: Config = { export default config; ``` +In case you need get specific `exports` for a library or set of libraries, you can define it this way: + +```js tab +/** @type {import('jest').Config} */ +const config = { + testEnvironment: 'jsdom', + testEnvironmentOptions: { + customExportConditions: [ + { + modules: ['msw', 'msw/node', '@mswjs/interceptors/*'], + conditions: [], // use only basic conditions depending on if it's in CJS/ESM context + }, + ], + }, +}; + +module.exports = config; +``` + +```ts tab +import type {Config} from 'jest'; + +const config: Config = { + testEnvironment: 'jsdom', + testEnvironmentOptions: { + customExportConditions: [ + { + modules: ['msw', 'msw/node', '@mswjs/interceptors/*'], + conditions: [], // use only basic conditions depending on if it's in CJS/ESM context + }, + ], + }, +}; + +export default config; +``` + These options can also be passed in a docblock, similar to `testEnvironment`. The string with options must be parseable by `JSON.parse`: ```js diff --git a/packages/jest-environment-jsdom-abstract/src/index.ts b/packages/jest-environment-jsdom-abstract/src/index.ts index 17164455c8d5..d812105cd699 100644 --- a/packages/jest-environment-jsdom-abstract/src/index.ts +++ b/packages/jest-environment-jsdom-abstract/src/index.ts @@ -7,10 +7,12 @@ import type {Context} from 'vm'; import type * as jsdom from 'jsdom'; -import type { - EnvironmentContext, - JestEnvironment, - JestEnvironmentConfig, +import { + type EnvironmentContext, + type JestEnvironment, + type JestEnvironmentConfig, + type JestExportConditionsPerModules, + isExportConditions, } from '@jest/environment'; import {LegacyFakeTimers, ModernFakeTimers} from '@jest/fake-timers'; import type {Global} from '@jest/types'; @@ -26,10 +28,6 @@ type Win = Window & }; }; -function isString(value: unknown): value is string { - return typeof value === 'string'; -} - export default abstract class BaseJSDOMEnvironment implements JestEnvironment { @@ -40,7 +38,9 @@ export default abstract class BaseJSDOMEnvironment private errorEventListener: ((event: Event & {error: Error}) => void) | null; moduleMocker: ModuleMocker | null; customExportConditions = ['browser']; - private readonly _configuredExportConditions?: Array; + private readonly _configuredExportConditions?: Array< + string | JestExportConditionsPerModules + >; protected constructor( config: JestEnvironmentConfig, @@ -126,12 +126,12 @@ export default abstract class BaseJSDOMEnvironment const {customExportConditions} = projectConfig.testEnvironmentOptions; if ( Array.isArray(customExportConditions) && - customExportConditions.every(isString) + customExportConditions.every(isExportConditions) ) { this._configuredExportConditions = customExportConditions; } else { throw new Error( - 'Custom export conditions specified but they are not an array of strings', + 'Custom export conditions specified but they are not an array of proper shape', ); } } @@ -178,7 +178,7 @@ export default abstract class BaseJSDOMEnvironment this.fakeTimersModern = null; } - exportConditions(): Array { + exportConditions(): Array { return this._configuredExportConditions ?? this.customExportConditions; } diff --git a/packages/jest-environment-node/src/index.ts b/packages/jest-environment-node/src/index.ts index 11a36e97801b..96fbaf54936b 100644 --- a/packages/jest-environment-node/src/index.ts +++ b/packages/jest-environment-node/src/index.ts @@ -6,10 +6,12 @@ */ import {type Context, createContext, runInContext} from 'vm'; -import type { - EnvironmentContext, - JestEnvironment, - JestEnvironmentConfig, +import { + type EnvironmentContext, + type JestEnvironment, + type JestEnvironmentConfig, + type JestExportConditionsPerModules, + isExportConditions, } from '@jest/environment'; import {LegacyFakeTimers, ModernFakeTimers} from '@jest/fake-timers'; import type {Global} from '@jest/types'; @@ -56,10 +58,6 @@ const nodeGlobals = new Map( }), ); -function isString(value: unknown): value is string { - return typeof value === 'string'; -} - const timerIdToRef = (id: number) => ({ id, ref() { @@ -79,7 +77,9 @@ export default class NodeEnvironment implements JestEnvironment { global: Global.Global; moduleMocker: ModuleMocker | null; customExportConditions = ['node', 'node-addons']; - private readonly _configuredExportConditions?: Array; + private readonly _configuredExportConditions?: Array< + string | JestExportConditionsPerModules + >; // while `context` is unused, it should always be passed constructor(config: JestEnvironmentConfig, _context: EnvironmentContext) { @@ -168,12 +168,12 @@ export default class NodeEnvironment implements JestEnvironment { const {customExportConditions} = projectConfig.testEnvironmentOptions; if ( Array.isArray(customExportConditions) && - customExportConditions.every(isString) + customExportConditions.every(isExportConditions) ) { this._configuredExportConditions = customExportConditions; } else { throw new Error( - 'Custom export conditions specified but they are not an array of strings', + 'Custom export conditions specified but they are not an array of proper shape', ); } } @@ -211,7 +211,7 @@ export default class NodeEnvironment implements JestEnvironment { this.fakeTimersModern = null; } - exportConditions(): Array { + exportConditions(): Array { return this._configuredExportConditions ?? this.customExportConditions; } diff --git a/packages/jest-environment/src/index.ts b/packages/jest-environment/src/index.ts index 9f1eccc3c378..9649a1cb489b 100644 --- a/packages/jest-environment/src/index.ts +++ b/packages/jest-environment/src/index.ts @@ -37,6 +37,37 @@ export interface JestEnvironmentConfig { globalConfig: Config.GlobalConfig; } +export type JestExportConditionsPerModules = { + modules: Array; + conditions: Array; +}; + +export function isSimpleExportConditionsItem( + item: string | JestExportConditionsPerModules, +): item is string { + return typeof item === 'string'; +} + +export function isExportConditionsItemPerModules( + item: string | JestExportConditionsPerModules, +): item is JestExportConditionsPerModules { + return typeof item !== 'string'; +} + +export function isExportConditions( + item: unknown, +): item is string | JestExportConditionsPerModules { + return ( + typeof item === 'string' || + (typeof item === 'object' && + item !== null && + 'modules' in item && + 'conditions' in item && + Array.isArray((item as JestExportConditionsPerModules).modules) && + Array.isArray((item as JestExportConditionsPerModules).conditions)) + ); +} + export declare class JestEnvironment { constructor(config: JestEnvironmentConfig, context: EnvironmentContext); global: Global.Global; @@ -47,7 +78,7 @@ export declare class JestEnvironment { setup(): Promise; teardown(): Promise; handleTestEvent?: Circus.EventHandler; - exportConditions?: () => Array; + exportConditions?: () => Array; } export type Module = NodeModule; diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 477be4b7ef22..66e66c9db890 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -23,12 +23,15 @@ import {CoverageInstrumenter, type V8Coverage} from 'collect-v8-coverage'; import * as fs from 'graceful-fs'; import slash = require('slash'); import stripBOM = require('strip-bom'); -import type { - Jest, - JestEnvironment, - JestImportMeta, - Module, - ModuleWrapper, +import { + type Jest, + type JestEnvironment, + type JestExportConditionsPerModules, + type JestImportMeta, + type Module, + type ModuleWrapper, + isExportConditionsItemPerModules, + isSimpleExportConditionsItem, } from '@jest/environment'; import type {LegacyFakeTimers, ModernFakeTimers} from '@jest/fake-timers'; import type {expect, jest} from '@jest/globals'; @@ -212,8 +215,11 @@ export default class Runtime { private _moduleImplementation?: typeof nativeModule.Module; private readonly jestObjectCaches: Map; private jestGlobals?: JestGlobals; + private readonly esmConditions_base: Array; + private readonly cjsConditions_base: Array; private readonly esmConditions: Array; private readonly cjsConditions: Array; + private readonly envExportConditionsPerModules: Array; private isTornDown = false; private isInsideTestCode: boolean | undefined; @@ -281,15 +287,23 @@ export default class Runtime { unmockRegExpCache.set(config, this._unmockList); } - const envExportConditions = this._environment.exportConditions?.() ?? []; + const envExportConditions = ( + this._environment.exportConditions?.() ?? [] + ).filter(isSimpleExportConditionsItem); + this.esmConditions_base = ['import', 'default']; + this.cjsConditions_base = ['require', 'default']; this.esmConditions = [ - ...new Set(['import', 'default', ...envExportConditions]), + ...new Set([...this.esmConditions_base, ...envExportConditions]), ]; this.cjsConditions = [ - ...new Set(['require', 'default', ...envExportConditions]), + ...new Set([...this.cjsConditions_base, ...envExportConditions]), ]; + this.envExportConditionsPerModules = ( + this._environment.exportConditions?.() ?? [] + ).filter(isExportConditionsItemPerModules); + if (config.automock) { for (const filePath of config.setupFiles) { if (filePath.includes(NODE_MODULES)) { @@ -532,7 +546,7 @@ export default class Runtime { const resolvedPath = this._resolver.resolveModule( parentPath, specifier, - {conditions: this.esmConditions}, + {conditions: this._getEsmConditions(specifier)}, ); return pathToFileURL(resolvedPath).href; @@ -836,7 +850,7 @@ export default class Runtime { this._virtualModuleMocks, from, moduleName, - {conditions: this.esmConditions}, + {conditions: this._getEsmConditions(moduleName)}, ); if (this._moduleMockRegistry.has(moduleID)) { @@ -925,7 +939,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - {conditions: this.cjsConditions}, + {conditions: this._getCjsConditions(moduleName)}, ); let modulePath: string | undefined; @@ -1043,7 +1057,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - {conditions: this.cjsConditions}, + {conditions: this._getCjsConditions(moduleName)}, ); if (this._isolatedMockRegistry?.has(moduleID)) { @@ -1364,7 +1378,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - {conditions: this.cjsConditions}, + {conditions: this._getCjsConditions(moduleName)}, ); this._explicitShouldMock.set(moduleID, true); this._mockFactories.set(moduleID, mockFactory); @@ -1385,7 +1399,7 @@ export default class Runtime { this._virtualModuleMocks, from, moduleName, - {conditions: this.esmConditions}, + {conditions: this._getEsmConditions(moduleName)}, ); this._explicitShouldMockModule.set(moduleID, true); this._moduleMockFactories.set(moduleID, mockFactory); @@ -1443,10 +1457,60 @@ export default class Runtime { this.isTornDown = true; } + private _matchesModuleName(moduleName: string, patterns: Array) { + for (const pattern of patterns) { + if (!pattern.endsWith('/*') && moduleName === pattern) { + return true; + } else if ( + pattern.endsWith('/*') && + (moduleName === pattern.slice(0, -2) || + moduleName.startsWith(pattern.slice(0, -1))) + ) { + return true; + } + } + + return false; + } + + private _getConditions( + baseConditions: Array, + mainConditions: Array, + moduleName?: string, + ) { + if (moduleName === undefined) { + return mainConditions; + } + + for (const {modules, conditions} of this.envExportConditionsPerModules) { + if (this._matchesModuleName(moduleName, modules)) { + return [...new Set([...baseConditions, ...conditions])]; + } + } + + return mainConditions; + } + + private _getCjsConditions(moduleName?: string) { + return this._getConditions( + this.cjsConditions_base, + this.cjsConditions, + moduleName, + ); + } + + private _getEsmConditions(moduleName?: string) { + return this._getConditions( + this.esmConditions_base, + this.esmConditions, + moduleName, + ); + } + private _resolveCjsModule(from: string, to: string | undefined) { return to ? this._resolver.resolveModule(from, to, { - conditions: this.cjsConditions, + conditions: this._getCjsConditions(to), }) : from; } @@ -1454,7 +1518,7 @@ export default class Runtime { private _resolveModule(from: string, to: string | undefined) { return to ? this._resolver.resolveModuleAsync(from, to, { - conditions: this.esmConditions, + conditions: this._getEsmConditions(to), }) : from; } @@ -1474,7 +1538,7 @@ export default class Runtime { const module = this._resolver.resolveModuleFromDirIfExists( moduleName, moduleName, - {conditions: this.cjsConditions, paths: []}, + {conditions: this._getCjsConditions(moduleName), paths: []}, ); if (module) { return module; @@ -1486,7 +1550,10 @@ export default class Runtime { absolutePath, moduleName, // required to also resolve files without leading './' directly in the path - {conditions: this.cjsConditions, paths: [absolutePath]}, + { + conditions: this._getCjsConditions(moduleName), + paths: [absolutePath], + }, ); if (module) { return module; @@ -1941,7 +2008,9 @@ export default class Runtime { moduleName: string, explicitShouldMock: Map, ): boolean { - const options: ResolveModuleConfig = {conditions: this.cjsConditions}; + const options: ResolveModuleConfig = { + conditions: this._getCjsConditions(moduleName), + }; const moduleID = this._resolver.getModuleID( this._virtualMocks, from, @@ -2012,7 +2081,9 @@ export default class Runtime { moduleName: string, explicitShouldMock: Map, ): Promise { - const options: ResolveModuleConfig = {conditions: this.esmConditions}; + const options: ResolveModuleConfig = { + conditions: this._getEsmConditions(moduleName), + }; const moduleID = await this._resolver.getModuleIDAsync( this._virtualMocks, from, @@ -2153,7 +2224,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - {conditions: this.cjsConditions}, + {conditions: this._getCjsConditions(moduleName)}, ); this._explicitShouldMock.set(moduleID, false); return jestObject; @@ -2163,7 +2234,7 @@ export default class Runtime { this._virtualModuleMocks, from, moduleName, - {conditions: this.esmConditions}, + {conditions: this._getEsmConditions(moduleName)}, ); this._explicitShouldMockModule.set(moduleID, false); return jestObject; @@ -2173,7 +2244,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - {conditions: this.cjsConditions}, + {conditions: this._getCjsConditions(moduleName)}, ); this._explicitShouldMock.set(moduleID, false); this._transitiveShouldMock.set(moduleID, false); @@ -2188,7 +2259,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - {conditions: this.cjsConditions}, + {conditions: this._getCjsConditions(moduleName)}, ); this._explicitShouldMock.set(moduleID, true); return jestObject;