Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(jest-resolve): expose JestResolver, AsyncResolver and SyncResolver types #12707

Merged
merged 7 commits into from Apr 21, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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,
Comment on lines +12 to +13
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Typings for static methods of Resolver class.

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