Skip to content

Commit

Permalink
feat(jest-resolve): expose JestResolver, AsyncResolver and `SyncR…
Browse files Browse the repository at this point in the history
…esolver` types (#12707)
  • Loading branch information
mrazauskas committed Apr 21, 2022
1 parent 75c7c40 commit 0208815
Show file tree
Hide file tree
Showing 14 changed files with 157 additions and 59 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Expand Up @@ -45,6 +45,8 @@
- `[jest-resolve]` Support package self-reference ([#12682](https://github.com/facebook/jest/pull/12682))
- `[jest-resolve, jest-runtime]` Add support for `data:` URI import and mock ([#12392](https://github.com/facebook/jest/pull/12392))
- `[jest-resolve, jest-runtime]` Add support for async resolver ([#11540](https://github.com/facebook/jest/pull/11540))
- `[jest-resolve]` [**BREAKING**] Remove `browser?: boolean` from resolver options, `conditions: ['browser']` should be used instead ([#12707](https://github.com/facebook/jest/pull/12707))
- `[jest-resolve]` Expose `JestResolver`, `AsyncResolver` and `SyncResolver` types ([#12707](https://github.com/facebook/jest/pull/12707))
- `[jest-runner]` Allow `setupFiles` module to export an async function ([#12042](https://github.com/facebook/jest/pull/12042))
- `[jest-runner]` Allow passing `testEnvironmentOptions` via docblocks ([#12470](https://github.com/facebook/jest/pull/12470))
- `[jest-runner]` Exposing `CallbackTestRunner`, `EmittingTestRunner` abstract classes to help typing third party runners ([#12646](https://github.com/facebook/jest/pull/12646))
Expand Down
57 changes: 27 additions & 30 deletions docs/Configuration.md
Expand Up @@ -919,41 +919,49 @@ By default, each test file gets its own independent module registry. Enabling `r

Default: `undefined`

This option allows the use of a custom resolver. This resolver must be a node module that exports _either_:
This option allows the use of a custom resolver. This resolver must be a module that exports _either_:

1. a function expecting a string as the first argument for the path to resolve and an options object as the second argument. The function should either return a path to the module that should be resolved or throw an error if the module can't be found. _or_
2. an object containing `async` and/or `sync` properties. The `sync` property should be a function with the shape explained above, and the `async` property should also be a function that accepts the same arguments, but returns a promise which resolves with the path to the module or rejects with an error.

The options object provided to resolvers has the shape:

```json
{
"basedir": string,
"conditions": [string],
"defaultResolver": "function(request, options) -> string",
"extensions": [string],
"moduleDirectory": [string],
"paths": [string],
"packageFilter": "function(pkg, pkgdir)",
"pathFilter": "function(pkg, path, relativePath)",
"rootDir": [string]
}
```ts
type PackageJson = Record<string, unknown>;

type ResolverOptions = {
/** Directory to begin resolving from. */
basedir: string;
/** List of export conditions. */
conditions?: Array<string>;
/** Instance of default resolver. */
defaultResolver: (path: string, options: ResolverOptions) => string;
/** List of file extensions to search in order. */
extensions?: Array<string>;
/** List of directory names to be looked up for modules recursively. */
moduleDirectory?: Array<string>;
/** List of `require.paths` to use if nothing is found in `node_modules`. */
paths?: Array<string>;
/** Allows transforming parsed `package.json` contents. */
packageFilter?: (pkg: PackageJson, file: string, dir: string) => PackageJson;
/** Allows transforms a path within a package. */
pathFilter?: (pkg: PackageJson, path: string, relativePath: string) => string;
/** Current root directory. */
rootDir?: string;
};
```

:::tip

The `defaultResolver` passed as an option is the Jest default resolver which might be useful when you write your custom one. It takes the same arguments as your custom synchronous one, e.g. `(request, options)` and returns a string or throws.
The `defaultResolver` passed as an option is the Jest default resolver which might be useful when you write your custom one. It takes the same arguments as your custom synchronous one, e.g. `(path, options)` and returns a string or throws.

:::

For example, if you want to respect Browserify's [`"browser"` field](https://github.com/browserify/browserify-handbook/blob/master/readme.markdown#browser-field), you can use the following configuration:

```json
{
...
"jest": {
"resolver": "<rootDir>/resolver.js"
}
"resolver": "<rootDir>/resolver.js"
}
```

Expand All @@ -965,19 +973,8 @@ module.exports = browserResolve.sync;

By combining `defaultResolver` and `packageFilter` we can implement a `package.json` "pre-processor" that allows us to change how the default resolver will resolve modules. For example, imagine we want to use the field `"module"` if it is present, otherwise fallback to `"main"`:

```json
{
...
"jest": {
"resolver": "my-module-resolve"
}
}
```

```js
// my-module-resolve package

module.exports = (request, options) => {
module.exports = (path, options) => {
// Call the defaultResolver, so we leverage its cache, error handling, etc.
return options.defaultResolver(request, {
...options,
Expand Down
4 changes: 2 additions & 2 deletions e2e/__tests__/__snapshots__/moduleNameMapper.test.ts.snap
Expand Up @@ -41,7 +41,7 @@ exports[`moduleNameMapper wrong array configuration 1`] = `
12 | module.exports = () => 'test';
13 |
at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:901:17)
at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:899:17)
at Object.require (index.js:10:1)"
`;

Expand Down Expand Up @@ -70,6 +70,6 @@ exports[`moduleNameMapper wrong configuration 1`] = `
12 | module.exports = () => 'test';
13 |
at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:901:17)
at createNoMappedModuleFoundError (../../packages/jest-resolve/build/resolver.js:899:17)
at Object.require (index.js:10:1)"
`;
Expand Up @@ -37,6 +37,6 @@ exports[`show error message with matching files 1`] = `
| ^
9 |
at Resolver._throwModNotFoundError (../../packages/jest-resolve/build/resolver.js:493:11)
at Resolver._throwModNotFoundError (../../packages/jest-resolve/build/resolver.js:491:11)
at Object.require (index.js:8:18)"
`;
70 changes: 70 additions & 0 deletions packages/jest-resolve/__typetests__/resolver.test.ts
@@ -0,0 +1,70 @@
/**
* 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 {expectAssignable, expectError, expectType} from 'tsd-lite';
import type {AsyncResolver, JestResolver, SyncResolver} from 'jest-resolve';

type PackageJson = Record<string, unknown>;
type PackageFilter = (
pkg: PackageJson,
file: string,
dir: string,
) => PackageJson;
type PathFilter = (
pkg: PackageJson,
path: string,
relativePath: string,
) => string;

// AsyncResolver

const asyncResolver: AsyncResolver = async (path, options) => {
expectType<string>(path);

expectType<string>(options.basedir);
expectType<Array<string> | undefined>(options.conditions);
expectType<SyncResolver>(options.defaultResolver);
expectType<Array<string> | undefined>(options.extensions);
expectType<Array<string> | undefined>(options.moduleDirectory);
expectType<PackageFilter | undefined>(options.packageFilter);
expectType<PathFilter | undefined>(options.pathFilter);
expectType<Array<string> | undefined>(options.paths);
expectType<string | undefined>(options.rootDir);

return path;
};

const notReturningAsyncResolver = async () => {};
expectError<AsyncResolver>(notReturningAsyncResolver());

// SyncResolver

const syncResolver: SyncResolver = (path, options) => {
expectType<string>(path);

expectType<string>(options.basedir);
expectType<Array<string> | undefined>(options.conditions);
expectType<SyncResolver>(options.defaultResolver);
expectType<Array<string> | undefined>(options.extensions);
expectType<Array<string> | undefined>(options.moduleDirectory);
expectType<PackageFilter | undefined>(options.packageFilter);
expectType<PathFilter | undefined>(options.pathFilter);
expectType<Array<string> | undefined>(options.paths);
expectType<string | undefined>(options.rootDir);

return path;
};

const notReturningSyncResolver = () => {};
expectError<SyncResolver>(notReturningSyncResolver());

// JestResolver

expectAssignable<JestResolver>({async: asyncResolver});
expectAssignable<JestResolver>({sync: syncResolver});
expectAssignable<JestResolver>({async: asyncResolver, sync: syncResolver});
expectError<JestResolver>({});
12 changes: 12 additions & 0 deletions packages/jest-resolve/__typetests__/tsconfig.json
@@ -0,0 +1,12 @@
{
"extends": "../../../tsconfig.json",
"compilerOptions": {
"composite": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"skipLibCheck": true,

"types": []
},
"include": ["./**/*"]
}
4 changes: 3 additions & 1 deletion packages/jest-resolve/package.json
Expand Up @@ -28,8 +28,10 @@
"slash": "^3.0.0"
},
"devDependencies": {
"@tsd/typescript": "~4.6.2",
"@types/graceful-fs": "^4.1.3",
"@types/resolve": "^1.20.0"
"@types/resolve": "^1.20.0",
"tsd-lite": "^0.5.1"
},
"engines": {
"node": "^12.13.0 || ^14.15.0 || ^16.13.0 || >=17.0.0"
Expand Down
4 changes: 0 additions & 4 deletions packages/jest-resolve/src/__tests__/resolve.test.ts
Expand Up @@ -108,7 +108,6 @@ describe('findNodeModule', () => {

const newPath = Resolver.findNodeModule('test', {
basedir: '/',
browser: true,
conditions: ['conditions, woooo'],
extensions: ['js'],
moduleDirectory: ['node_modules'],
Expand All @@ -120,7 +119,6 @@ describe('findNodeModule', () => {
expect(userResolver.mock.calls[0][0]).toBe('test');
expect(userResolver.mock.calls[0][1]).toStrictEqual({
basedir: '/',
browser: true,
conditions: ['conditions, woooo'],
defaultResolver,
extensions: ['js'],
Expand Down Expand Up @@ -315,7 +313,6 @@ describe('findNodeModuleAsync', () => {

const newPath = await Resolver.findNodeModuleAsync('test', {
basedir: '/',
browser: true,
conditions: ['conditions, woooo'],
extensions: ['js'],
moduleDirectory: ['node_modules'],
Expand All @@ -327,7 +324,6 @@ describe('findNodeModuleAsync', () => {
expect(userResolverAsync.async.mock.calls[0][0]).toBe('test');
expect(userResolverAsync.async.mock.calls[0][1]).toStrictEqual({
basedir: '/',
browser: true,
conditions: ['conditions, woooo'],
defaultResolver,
extensions: ['js'],
Expand Down
35 changes: 26 additions & 9 deletions packages/jest-resolve/src/defaultResolver.ts
Expand Up @@ -13,28 +13,44 @@ import {
resolve as resolveExports,
} from 'resolve.exports';
import {
PkgJson,
PackageJson,
findClosestPackageJson,
isDirectory,
isFile,
readPackageCached,
realpathSync,
} from './fileWalkers';

// copy from `resolve`'s types so we don't have their types in our definition
// files
interface ResolverOptions {
type ResolverOptions = {
/** Directory to begin resolving from. */
basedir: string;
browser?: boolean;
/** List of export conditions. */
conditions?: Array<string>;
/** Instance of default resolver. */
defaultResolver: typeof defaultResolver;
/** List of file extensions to search in order. */
extensions?: Array<string>;
/**
* List of directory names to be looked up for modules recursively.
*
* @defaultValue
* The default is `['node_modules']`.
*/
moduleDirectory?: Array<string>;
/**
* List of `require.paths` to use if nothing is found in `node_modules`.
*
* @defaultValue
* The default is `undefined`.
*/
paths?: Array<string>;
/** Allows transforming parsed `package.json` contents. */
packageFilter?: (pkg: PackageJson, file: string, dir: string) => PackageJson;
/** Allows transforms a path within a package. */
pathFilter?: (pkg: PackageJson, path: string, relativePath: string) => string;
/** Current root directory. */
rootDir?: string;
packageFilter?: (pkg: PkgJson, dir: string) => PkgJson;
pathFilter?: (pkg: PkgJson, path: string, relativePath: string) => string;
}
};

type UpstreamResolveOptionsWithConditions = UpstreamResolveOptions &
Pick<ResolverOptions, 'basedir' | 'conditions'>;
Expand Down Expand Up @@ -63,6 +79,7 @@ const defaultResolver: SyncResolver = (path, options) => {
return pnpResolver(path, options);
}

// @ts-expect-error: TODO remove after merging https://github.com/DefinitelyTyped/DefinitelyTyped/pull/59990
const resolveOptions: UpstreamResolveOptionsWithConditions = {
...options,
isDirectory,
Expand Down Expand Up @@ -91,7 +108,7 @@ export default defaultResolver;
* helper functions
*/

function readPackageSync(_: unknown, file: string): PkgJson {
function readPackageSync(_: unknown, file: string): PackageJson {
return readPackageCached(file);
}

Expand Down
8 changes: 4 additions & 4 deletions packages/jest-resolve/src/fileWalkers.ts
Expand Up @@ -71,17 +71,17 @@ function realpathCached(path: string): string {
return result;
}

export type PkgJson = Record<string, unknown>;
export type PackageJson = Record<string, unknown>;

const packageContents = new Map<string, PkgJson>();
export function readPackageCached(path: string): PkgJson {
const packageContents = new Map<string, PackageJson>();
export function readPackageCached(path: string): PackageJson {
let result = packageContents.get(path);

if (result != null) {
return result;
}

result = JSON.parse(fs.readFileSync(path, 'utf8')) as PkgJson;
result = JSON.parse(fs.readFileSync(path, 'utf8')) as PackageJson;

packageContents.set(path, result);

Expand Down
7 changes: 6 additions & 1 deletion packages/jest-resolve/src/index.ts
Expand Up @@ -7,7 +7,12 @@

import Resolver from './resolver';

export type {ResolveModuleConfig} from './resolver';
export type {AsyncResolver, SyncResolver} from './defaultResolver';
export type {
FindNodeModuleConfig,
ResolveModuleConfig,
ResolverObject as JestResolver,
} from './resolver';
export * from './utils';

export default Resolver;
7 changes: 2 additions & 5 deletions packages/jest-resolve/src/resolver.ts
Expand Up @@ -24,9 +24,8 @@ import nodeModulesPaths from './nodeModulesPaths';
import shouldLoadAsEsm, {clearCachedLookups} from './shouldLoadAsEsm';
import type {ResolverConfig} from './types';

type FindNodeModuleConfig = {
export type FindNodeModuleConfig = {
basedir: string;
browser?: boolean;
conditions?: Array<string>;
extensions?: Array<string>;
moduleDirectory?: Array<string>;
Expand Down Expand Up @@ -124,7 +123,6 @@ export default class Resolver {
try {
return resolver(path, {
basedir: options.basedir,
browser: options.browser,
conditions: options.conditions,
defaultResolver,
extensions: options.extensions,
Expand Down Expand Up @@ -167,7 +165,6 @@ export default class Resolver {
try {
const result = await resolver(path, {
basedir: options.basedir,
browser: options.browser,
conditions: options.conditions,
defaultResolver,
extensions: options.extensions,
Expand Down Expand Up @@ -860,7 +857,7 @@ Please check your configuration for these entries:

type ResolverSyncObject = {sync: SyncResolver; async?: AsyncResolver};
type ResolverAsyncObject = {sync?: SyncResolver; async: AsyncResolver};
type ResolverObject = ResolverSyncObject | ResolverAsyncObject;
export type ResolverObject = ResolverSyncObject | ResolverAsyncObject;

function loadResolver(
resolver: string | undefined | null,
Expand Down

0 comments on commit 0208815

Please sign in to comment.