From b9e3a55ccbf4c1228190ca3eb47bc680abba103b Mon Sep 17 00:00:00 2001 From: Simen Bekkhus Date: Thu, 10 Feb 2022 19:08:08 +0100 Subject: [PATCH] feat: support `exports` in `package.json` (#11961) --- CHANGELOG.md | 1 + examples/angular/jest.config.js | 2 +- examples/angular/test-env.js | 13 +++ .../{import/file.js => exports/default.js} | 0 .../{require/file.js => exports/import.js} | 0 .../conditions/node_modules/exports/main.js | 0 .../node_modules/exports/nestedDefault.js | 0 .../node_modules/exports/nestedRequire.js | 0 .../conditions/node_modules/exports/other.js | 0 .../node_modules/exports/package.json | 16 ++++ .../node_modules/exports/require.js | 0 .../node_modules/import/package.json | 6 -- .../node_modules/require/package.json | 6 -- .../src/__tests__/resolve.test.ts | 64 +++++++++++++-- packages/jest-resolve/src/defaultResolver.ts | 82 ++++++++++++------- .../src/__tests__/NODE_PATH_dir/package.json | 6 ++ .../src/__tests__/test_root/package.json | 6 ++ packages/jest-runtime/src/index.ts | 10 +-- 18 files changed, 158 insertions(+), 54 deletions(-) create mode 100644 examples/angular/test-env.js rename packages/jest-resolve/src/__mocks__/conditions/node_modules/{import/file.js => exports/default.js} (100%) rename packages/jest-resolve/src/__mocks__/conditions/node_modules/{require/file.js => exports/import.js} (100%) create mode 100644 packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/main.js create mode 100644 packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/nestedDefault.js create mode 100644 packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/nestedRequire.js create mode 100644 packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/other.js create mode 100644 packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/package.json create mode 100644 packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/require.js delete mode 100644 packages/jest-resolve/src/__mocks__/conditions/node_modules/import/package.json delete mode 100644 packages/jest-resolve/src/__mocks__/conditions/node_modules/require/package.json create mode 100644 packages/jest-runtime/src/__tests__/NODE_PATH_dir/package.json create mode 100644 packages/jest-runtime/src/__tests__/test_root/package.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4090888c91cc..4366e726c58a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - `[@jest/expect-utils]` New module exporting utils for `expect` ([#12323](https://github.com/facebook/jest/pull/12323)) - `[jest-jasmine2, jest-runtime]` [**BREAKING**] Use `Symbol` to pass `jest.setTimeout` value instead of `jasmine` specific logic ([#12124](https://github.com/facebook/jest/pull/12124)) - `[jest-jasmine2, jest-types]` [**BREAKING**] Move all `jasmine` specific types from `@jest/types` to its own package ([#12125](https://github.com/facebook/jest/pull/12125)) +- `[jest-resolver]` [**BREAKING**] Add support for `package.json` `exports` ([11961](https://github.com/facebook/jest/pull/11961)) - `[jest-snapshot]` [**BREAKING**] Migrate to ESM ([#12342](https://github.com/facebook/jest/pull/12342)) - `[jest-worker]` [**BREAKING**] Allow only absolute `workerPath` ([#12343](https://github.com/facebook/jest/pull/12343)) diff --git a/examples/angular/jest.config.js b/examples/angular/jest.config.js index 06e6c25e3075..18094f1ba955 100644 --- a/examples/angular/jest.config.js +++ b/examples/angular/jest.config.js @@ -1,7 +1,7 @@ module.exports = { moduleFileExtensions: ['ts', 'html', 'js', 'json'], setupFilesAfterEnv: ['/setupJest.js'], - testEnvironment: 'jsdom', + testEnvironment: '/test-env.js', transform: { '\\.[tj]s$': ['babel-jest', {configFile: require.resolve('./.babelrc')}], }, diff --git a/examples/angular/test-env.js b/examples/angular/test-env.js new file mode 100644 index 000000000000..542775fe3eb4 --- /dev/null +++ b/examples/angular/test-env.js @@ -0,0 +1,13 @@ +'use strict'; + +const { + TestEnvironment: JSDOMTestEnvironment, +} = require('jest-environment-jsdom'); + +module.exports = class AngularEnv extends JSDOMTestEnvironment { + exportConditions() { + // we need to include `node` as `rxjs` defines `node`, `es2015`, `default`, not `browser` or `require` + // https://github.com/ReactiveX/rxjs/pull/6821 + return super.exportConditions().concat('node'); + } +}; diff --git a/packages/jest-resolve/src/__mocks__/conditions/node_modules/import/file.js b/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/default.js similarity index 100% rename from packages/jest-resolve/src/__mocks__/conditions/node_modules/import/file.js rename to packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/default.js diff --git a/packages/jest-resolve/src/__mocks__/conditions/node_modules/require/file.js b/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/import.js similarity index 100% rename from packages/jest-resolve/src/__mocks__/conditions/node_modules/require/file.js rename to packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/import.js diff --git a/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/main.js b/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/main.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/nestedDefault.js b/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/nestedDefault.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/nestedRequire.js b/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/nestedRequire.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/other.js b/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/other.js new file mode 100644 index 000000000000..e69de29bb2d1 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 new file mode 100644 index 000000000000..7fd4bab2a661 --- /dev/null +++ b/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/package.json @@ -0,0 +1,16 @@ +{ + "name": "import", + "main": "main.js", + "exports": { + ".": { + "require": "./require.js", + "import": "./import.js", + "default": "./default.js" + }, + "./nested": "./nestedDefault.js", + "./deeplyNested" : { + "require": "./nestedRequire.js", + "default": "./nestedDefault.js" + } + } +} diff --git a/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/require.js b/packages/jest-resolve/src/__mocks__/conditions/node_modules/exports/require.js new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/packages/jest-resolve/src/__mocks__/conditions/node_modules/import/package.json b/packages/jest-resolve/src/__mocks__/conditions/node_modules/import/package.json deleted file mode 100644 index 24fc72b1cac7..000000000000 --- a/packages/jest-resolve/src/__mocks__/conditions/node_modules/import/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "import", - "exports": { - "import": "./file.js" - } -} diff --git a/packages/jest-resolve/src/__mocks__/conditions/node_modules/require/package.json b/packages/jest-resolve/src/__mocks__/conditions/node_modules/require/package.json deleted file mode 100644 index c42b33ecca86..000000000000 --- a/packages/jest-resolve/src/__mocks__/conditions/node_modules/require/package.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "require", - "exports": { - "require": "./file.js" - } -} diff --git a/packages/jest-resolve/src/__tests__/resolve.test.ts b/packages/jest-resolve/src/__tests__/resolve.test.ts index 65b89a77b63d..6c6dfaf0705e 100644 --- a/packages/jest-resolve/src/__tests__/resolve.test.ts +++ b/packages/jest-resolve/src/__tests__/resolve.test.ts @@ -160,24 +160,78 @@ describe('findNodeModule', () => { }); test('resolves with import', () => { - const result = Resolver.findNodeModule('import', { + const result = Resolver.findNodeModule('exports', { basedir: conditionsRoot, conditions: ['import'], }); expect(result).toEqual( - path.resolve(conditionsRoot, './node_modules/import/file.js'), + path.resolve(conditionsRoot, './node_modules/exports/import.js'), ); }); test('resolves with require', () => { - const result = Resolver.findNodeModule('require', { + const result = Resolver.findNodeModule('exports', { basedir: conditionsRoot, conditions: ['require'], }); expect(result).toEqual( - path.resolve(conditionsRoot, './node_modules/require/file.js'), + path.resolve(conditionsRoot, './node_modules/exports/require.js'), + ); + }); + + test('gets default when nothing is passed', () => { + const result = Resolver.findNodeModule('exports', { + basedir: conditionsRoot, + conditions: [], + }); + + expect(result).toEqual( + path.resolve(conditionsRoot, './node_modules/exports/default.js'), + ); + }); + + test('respects order in package.json, not conditions', () => { + const resultImport = Resolver.findNodeModule('exports', { + basedir: conditionsRoot, + conditions: ['import', 'require'], + }); + const resultRequire = Resolver.findNodeModule('exports', { + basedir: conditionsRoot, + conditions: ['require', 'import'], + }); + + expect(resultImport).toEqual(resultRequire); + }); + + test('supports nested paths', () => { + const result = Resolver.findNodeModule('exports/nested', { + basedir: conditionsRoot, + conditions: [], + }); + + expect(result).toEqual( + path.resolve(conditionsRoot, './node_modules/exports/nestedDefault.js'), + ); + }); + + test('supports nested conditions', () => { + const resultRequire = Resolver.findNodeModule('exports/deeplyNested', { + basedir: conditionsRoot, + conditions: ['require'], + }); + const resultDefault = Resolver.findNodeModule('exports/deeplyNested', { + basedir: conditionsRoot, + conditions: [], + }); + + expect(resultRequire).toEqual( + path.resolve(conditionsRoot, './node_modules/exports/nestedRequire.js'), + ); + + expect(resultDefault).toEqual( + path.resolve(conditionsRoot, './node_modules/exports/nestedDefault.js'), ); }); }); @@ -251,8 +305,8 @@ describe('resolveModule', () => { const src = require.resolve('../'); const resolved = resolver.resolveModule(src, 'mockJsDependency', { paths: [ - path.resolve(__dirname, '../../src/__tests__'), path.resolve(__dirname, '../../src/__mocks__'), + path.resolve(__dirname, '../../src/__tests__'), ], }); expect(resolved).toBe(require.resolve('../__mocks__/mockJsDependency.js')); diff --git a/packages/jest-resolve/src/defaultResolver.ts b/packages/jest-resolve/src/defaultResolver.ts index 1c461feab16e..758117214744 100644 --- a/packages/jest-resolve/src/defaultResolver.ts +++ b/packages/jest-resolve/src/defaultResolver.ts @@ -5,13 +5,14 @@ * LICENSE file in the root directory of this source tree. */ -import {resolve} from 'path'; +import {isAbsolute} from 'path'; import pnpResolver from 'jest-pnp-resolver'; import {sync as resolveSync} from 'resolve'; import { Options as ResolveExportsOptions, resolve as resolveExports, } from 'resolve.exports'; +import slash = require('slash'); import type {Config} from '@jest/types'; import { PkgJson, @@ -59,10 +60,8 @@ export default function defaultResolver( ...options, isDirectory, isFile, - packageFilter: createPackageFilter( - options.conditions, - options.packageFilter, - ), + packageFilter: createPackageFilter(path, options.packageFilter), + pathFilter: createPathFilter(path, options.conditions, options.pathFilter), preserveSymlinks: false, readPackageSync, realpathSync, @@ -82,45 +81,68 @@ function readPackageSync(_: unknown, file: Config.Path): PkgJson { } function createPackageFilter( - conditions?: Array, + originalPath: Config.Path, userFilter?: ResolverOptions['packageFilter'], ): ResolverOptions['packageFilter'] { - function attemptExportsFallback(pkg: PkgJson) { - const options: ResolveExportsOptions = conditions - ? {conditions, unsafe: true} - : // no conditions were passed - let's assume this is Jest internal and it should be `require` - {browser: false, require: true}; - - try { - return resolveExports(pkg, '.', options); - } catch { - return undefined; - } + if (shouldIgnoreRequestForExports(originalPath)) { + return userFilter; } - return function packageFilter(pkg, packageDir) { + return function packageFilter(pkg, ...rest) { let filteredPkg = pkg; if (userFilter) { - filteredPkg = userFilter(filteredPkg, packageDir); - } - - if (filteredPkg.main != null) { - return filteredPkg; + filteredPkg = userFilter(filteredPkg, ...rest); } - const indexInRoot = resolve(packageDir, './index.js'); - - // if the module contains an `index.js` file in root, `resolve` will request - // that if there is no `main`. Since we don't wanna break that, add this - // check - if (isFile(indexInRoot)) { + if (filteredPkg.exports == null) { return filteredPkg; } return { ...filteredPkg, - main: attemptExportsFallback(filteredPkg), + // remove `main` so `resolve` doesn't look at it and confuse the `.` + // loading in `pathFilter` + main: undefined, }; }; } + +function createPathFilter( + originalPath: Config.Path, + conditions?: Array, + userFilter?: ResolverOptions['pathFilter'], +): ResolverOptions['pathFilter'] { + if (shouldIgnoreRequestForExports(originalPath)) { + return userFilter; + } + + const options: ResolveExportsOptions = conditions + ? {conditions, unsafe: true} + : // no conditions were passed - let's assume this is Jest internal and it should be `require` + {browser: false, require: true}; + + return function pathFilter(pkg, path, relativePath, ...rest) { + let pathToUse = relativePath; + + if (userFilter) { + pathToUse = userFilter(pkg, path, relativePath, ...rest); + } + + if (pkg.exports == null) { + return pathToUse; + } + + // this `index` thing can backfire, but `resolve` adds it: https://github.com/browserify/resolve/blob/f1b51848ecb7f56f77bfb823511d032489a13eab/lib/sync.js#L192 + const isRootRequire = + pathToUse === 'index' && !originalPath.endsWith('/index'); + + const newPath = isRootRequire ? '.' : slash(pathToUse); + + return resolveExports(pkg, newPath, options) || pathToUse; + }; +} + +// if it's a relative import or an absolute path, exports are ignored +const shouldIgnoreRequestForExports = (path: Config.Path) => + path.startsWith('.') || isAbsolute(path); diff --git a/packages/jest-runtime/src/__tests__/NODE_PATH_dir/package.json b/packages/jest-runtime/src/__tests__/NODE_PATH_dir/package.json new file mode 100644 index 000000000000..103175ed9050 --- /dev/null +++ b/packages/jest-runtime/src/__tests__/NODE_PATH_dir/package.json @@ -0,0 +1,6 @@ +{ + "name": "NODE_PATH_dir", + "version": "1.0.0", + "dependencies": { + } +} diff --git a/packages/jest-runtime/src/__tests__/test_root/package.json b/packages/jest-runtime/src/__tests__/test_root/package.json new file mode 100644 index 000000000000..48df13f673ca --- /dev/null +++ b/packages/jest-runtime/src/__tests__/test_root/package.json @@ -0,0 +1,6 @@ +{ + "name": "test_root", + "version": "1.0.0", + "dependencies": { + } +} diff --git a/packages/jest-runtime/src/index.ts b/packages/jest-runtime/src/index.ts index 22be30d419d4..3940af1822de 100644 --- a/packages/jest-runtime/src/index.ts +++ b/packages/jest-runtime/src/index.ts @@ -754,7 +754,7 @@ export default class Runtime { this._virtualMocks, from, moduleName, - isInternal ? undefined : {conditions: this.cjsConditions}, + {conditions: this.cjsConditions}, ); let modulePath: string | undefined; @@ -782,11 +782,9 @@ export default class Runtime { } if (!modulePath) { - modulePath = this._resolveModule( - from, - moduleName, - isInternal ? undefined : {conditions: this.cjsConditions}, - ); + modulePath = this._resolveModule(from, moduleName, { + conditions: this.cjsConditions, + }); } if (this.unstable_shouldLoadAsEsm(modulePath)) {