Skip to content

Commit

Permalink
feat: pass conditions when resolving modules (#11859)
Browse files Browse the repository at this point in the history
  • Loading branch information
SimenB committed Sep 13, 2021
1 parent 03ff659 commit a0bf671
Show file tree
Hide file tree
Showing 17 changed files with 262 additions and 24 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -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))
Expand Down
4 changes: 4 additions & 0 deletions docs/Configuration.md
Expand Up @@ -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]
}
```
Expand Down Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap
Expand Up @@ -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)
`;

Expand Down Expand Up @@ -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)
`;
Expand Up @@ -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)
`;
35 changes: 35 additions & 0 deletions 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;
}
}
});
});
12 changes: 12 additions & 0 deletions 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');
});
12 changes: 12 additions & 0 deletions 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');
});
10 changes: 10 additions & 0 deletions 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',
};
10 changes: 10 additions & 0 deletions 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';
}
10 changes: 10 additions & 0 deletions 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"
}
}
}
18 changes: 18 additions & 0 deletions e2e/resolve-conditions/package.json
@@ -0,0 +1,18 @@
{
"jest": {
"moduleFileExtensions": [
"js",
"cjs",
"mjs",
"json"
],
"resolver": "<rootDir>/resolver.js",
"testMatch": [
"<rootDir>/**/*.test.*"
],
"transform": {}
},
"dependencies": {
"resolve.exports": "^1.0.2"
}
}
34 changes: 34 additions & 0 deletions 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
);
};
}
21 changes: 21 additions & 0 deletions 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
2 changes: 2 additions & 0 deletions packages/jest-resolve/src/__tests__/resolve.test.ts
Expand Up @@ -105,6 +105,7 @@ describe('findNodeModule', () => {
const newPath = Resolver.findNodeModule('test', {
basedir: '/',
browser: true,
conditions: ['conditions, woooo'],
extensions: ['js'],
moduleDirectory: ['node_modules'],
paths: ['/something'],
Expand All @@ -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'],
Expand Down
12 changes: 11 additions & 1 deletion packages/jest-resolve/src/defaultResolver.ts
Expand Up @@ -19,7 +19,16 @@ type ResolverOptions = {
moduleDirectory?: Array<string>;
paths?: Array<Config.Path>;
rootDir?: Config.Path;
packageFilter?: (pkg: any, pkgfile: string) => any;
packageFilter?: (
pkg: Record<string, unknown>,
pkgfile: string,
) => Record<string, unknown>;
pathFilter?: (
pkg: Record<string, unknown>,
path: string,
relativePath: string,
) => string;
conditions?: Array<string>;
};

// https://github.com/facebook/jest/pull/10617
Expand Down Expand Up @@ -48,6 +57,7 @@ export default function defaultResolver(
isFile,
moduleDirectory: options.moduleDirectory,
packageFilter: options.packageFilter,
pathFilter: options.pathFilter,
paths: options.paths,
preserveSymlinks: false,
readPackageSync,
Expand Down
27 changes: 21 additions & 6 deletions packages/jest-resolve/src/resolver.ts
Expand Up @@ -23,6 +23,7 @@ import type {ResolverConfig} from './types';
type FindNodeModuleConfig = {
basedir: Config.Path;
browser?: boolean;
conditions?: Array<string>;
extensions?: Array<string>;
moduleDirectory?: Array<string>;
paths?: Array<Config.Path>;
Expand All @@ -32,6 +33,7 @@ type FindNodeModuleConfig = {
};

export type ResolveModuleConfig = {
conditions?: Array<string>;
skipNodeResolution?: boolean;
paths?: Array<Config.Path>;
};
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -183,6 +187,7 @@ export default class Resolver {

return Resolver.findNodeModule(name, {
basedir: dirname,
conditions: options?.conditions,
extensions,
moduleDirectory,
paths,
Expand Down Expand Up @@ -321,23 +326,31 @@ export default class Resolver {
virtualMocks: Map<string, boolean>,
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;
const id =
moduleType +
sep +
(absolutePath ? absolutePath + sep : '') +
(mockPath ? mockPath + sep : '');
(mockPath ? mockPath + sep : '') +
(stringifiedOptions ? stringifiedOptions + sep : '');

this._moduleIDCache.set(key, id);
return id;
Expand All @@ -351,13 +364,14 @@ export default class Resolver {
virtualMocks: Map<string, boolean>,
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(
Expand All @@ -373,12 +387,13 @@ export default class Resolver {
virtualMocks: Map<string, boolean>,
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;
}

Expand Down

0 comments on commit a0bf671

Please sign in to comment.