feat(jest-config):Support loading TS config files via docblock loader (#15190)

This commit is contained in:
Rajat 2024-07-23 13:59:57 +05:30 committed by GitHub
parent 3b0c06198b
commit 798d6c04d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 491 additions and 78 deletions

View File

@ -14,6 +14,7 @@
- `[jest-config]` Allow loading `jest.config.cts` files ([#14070](https://github.com/facebook/jest/pull/14070))
- `[jest-config]` Added an option to disable `ts-node` typechecking ([#15161](https://github.com/jestjs/jest/pull/15161))
- `[jest-config]` Show `rootDir` in error message when a `preset` fails to load ([#15194](https://github.com/jestjs/jest/pull/15194))
- `[jest-config]` Support loading TS config files using `esbuild-register` via docblock loader ([#15190](https://github.com/jestjs/jest/pull/15190))
- `[@jest/core]` Group together open handles with the same stack trace ([#13417](https://github.com/jestjs/jest/pull/13417), & [#14789](https://github.com/jestjs/jest/pull/14789))
- `[@jest/core]` Add `perfStats` to surface test setup overhead ([#14622](https://github.com/jestjs/jest/pull/14622))
- `[@jest/core]` [**BREAKING**] Changed `--filter` to accept an object with shape `{ filtered: Array<string> }` to match [documentation](https://jestjs.io/docs/cli#--filterfile) ([#13319](https://github.com/jestjs/jest/pull/13319))

View File

@ -57,9 +57,23 @@ export default async (): Promise<Config> => {
:::tip
To read TypeScript configuration files Jest requires [`ts-node`](https://npmjs.com/package/ts-node). Make sure it is installed in your project.
To read TypeScript configuration files Jest by default requires [`ts-node`](https://npmjs.com/package/ts-node). You can override this behavior by adding a `@jest-config-loader` docblock at the top of the file. Currently, [`ts-node`](https://npmjs.com/package/ts-node) and [`esbuild-register`](https://npmjs.com/package/esbuild-register) is supported. Make sure `ts-node` or the loader you specify is installed.
To read configuration files without typechecking, You can set `JEST_CONFIG_TRANSPILE_ONLY` environment variable to `true` (case insensitive).
```ts title="jest.config.ts"
/** @jest-config-loader ts-node */
// or
/** @jest-config-loader esbuild-register */
import type {Config} from 'jest';
const config: Config = {
verbose: true,
};
export default config;
```
If you are using `ts-node`, you can set `JEST_CONFIG_TRANSPILE_ONLY` environment variable to `true` (case insensitive) to read configuration files without typechecking.
:::

View File

@ -34,3 +34,16 @@ test('multiple configs error can be suppressed by using --config', () => {
);
expect(exitCode).toBe(0);
});
test('should works correctly when using different loaders in different projects', () => {
const {exitCode, stdout, stderr} = runJest(
'multi-project-multiple-configs',
['--projects', 'prj-1', 'prj-2'],
{
skipPkgJsonCheck: true,
},
);
expect(exitCode).toBe(0);
console.log(stdout);
console.log(stderr);
});

View File

@ -46,60 +46,27 @@ describe('readInitialOptions', () => {
expect(config).toEqual({jestConfig: 'jest.config.js', rootDir});
expect(configPath).toEqual(configFile);
});
test('should read a jest.config.js file', async () => {
const configFile = resolveFixture('js-config', 'jest.config.js');
const rootDir = resolveFixture('js-config');
const {config, configPath} = await proxyReadInitialOptions(undefined, {
cwd: rootDir,
});
expect(config).toEqual({jestConfig: 'jest.config.js', rootDir});
expect(configPath).toEqual(configFile);
});
test('should read a package.json file', async () => {
const configFile = resolveFixture('pkg-config', 'package.json');
const rootDir = resolveFixture('pkg-config');
const {config, configPath} = await proxyReadInitialOptions(undefined, {
cwd: rootDir,
});
expect(config).toEqual({jestConfig: 'package.json', rootDir});
expect(configPath).toEqual(configFile);
});
test('should read a jest.config.ts file', async () => {
const configFile = resolveFixture('ts-config', 'jest.config.ts');
const rootDir = resolveFixture('ts-config');
const {config, configPath} = await proxyReadInitialOptions(undefined, {
cwd: rootDir,
});
expect(config).toEqual({jestConfig: 'jest.config.ts', rootDir});
expect(configPath).toEqual(configFile);
});
test('should read a jest.config.mjs file', async () => {
const configFile = resolveFixture('mjs-config', 'jest.config.mjs');
const rootDir = resolveFixture('mjs-config');
const {config, configPath} = await proxyReadInitialOptions(undefined, {
cwd: rootDir,
});
expect(config).toEqual({jestConfig: 'jest.config.mjs', rootDir});
expect(configPath).toEqual(configFile);
});
test('should read a jest.config.json file', async () => {
const configFile = resolveFixture('json-config', 'jest.config.json');
const rootDir = resolveFixture('json-config');
const {config, configPath} = await proxyReadInitialOptions(undefined, {
cwd: rootDir,
});
expect(config).toEqual({jestConfig: 'jest.config.json', rootDir});
expect(configPath).toEqual(configFile);
});
test('should read a jest config exporting an async function', async () => {
const configFile = resolveFixture('async-config', 'jest.config.js');
const rootDir = resolveFixture('async-config');
const {config, configPath} = await proxyReadInitialOptions(undefined, {
cwd: rootDir,
});
expect(config).toEqual({jestConfig: 'async-config', rootDir});
expect(configPath).toEqual(configFile);
});
test.each([
['js-config', 'jest.config.js', 'jest.config.js'],
['pkg-config', 'package.json', 'package.json'],
['ts-node-config', 'jest.config.ts', 'jest.config.ts'],
['ts-esbuild-register-config', 'jest.config.ts', 'jest.config.ts'],
['mjs-config', 'jest.config.mjs', 'jest.config.mjs'],
['json-config', 'jest.config.json', 'jest.config.json'],
['async-config', 'jest.config.js', 'async-config'],
])(
'should read %s/%s file',
async (directory: string, filename: string, configString: string) => {
const configFile = resolveFixture(directory, filename);
const rootDir = resolveFixture(directory);
const {config, configPath} = await proxyReadInitialOptions(undefined, {
cwd: rootDir,
});
expect(config).toEqual({jestConfig: configString, rootDir});
expect(configPath).toEqual(configFile);
},
);
test('should be able to skip config reading, instead read from cwd', async () => {
const expectedConfigFile = resolveFixture(
@ -121,6 +88,16 @@ describe('readInitialOptions', () => {
expect(configPath).toEqual(expectedConfigFile);
});
test('should give an error when using unsupported loader', async () => {
const cwd = resolveFixture('ts-loader-config');
const error: Error = await proxyReadInitialOptions(undefined, {cwd}).catch(
error => error,
);
expect(error.message).toContain(
"Jest: 'ts-loader' is not a valid TypeScript configuration loader.",
);
});
test('should give an error when there are multiple config files', async () => {
const cwd = resolveFixture('multiple-config-files');
const error: Error = await proxyReadInitialOptions(undefined, {cwd}).catch(

View File

@ -4,6 +4,7 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
export default {
jestConfig: 'jest.config.ts',
};
test('dummy test', () => {
expect(1).toBe(1);
});

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-config-loader esbuild-register
*/
import type {Config} from 'jest';
const config: Config = {
displayName: 'PROJECT 1',
};
export default config;

View File

@ -0,0 +1,10 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
test('dummy test', () => {
expect(2).toBe(2);
});

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-config-loader ts-node
*/
import type {Config} from 'jest';
const config: Config = {
displayName: 'PROJECT 2',
};
export default config;

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-config-loader esbuild-register
*/
interface Config {
jestConfig: string;
}
export default {
jestConfig: 'jest.config.ts',
} as Config;

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-config-loader ts-loader
*/
interface Config {
jestConfig: string;
}
export default {
jestConfig: 'jest.config.ts',
} as Config;

View File

@ -0,0 +1,16 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @jest-config-loader ts-node
*/
interface Config {
jestConfig: string;
}
export default {
jestConfig: 'jest.config.ts',
} as Config;

View File

@ -20,12 +20,16 @@
},
"peerDependencies": {
"@types/node": "*",
"esbuild-register": ">=3.4.0",
"ts-node": ">=9.0.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"esbuild-register": {
"optional": true
},
"ts-node": {
"optional": true
}
@ -42,6 +46,7 @@
"glob": "^10.3.10",
"graceful-fs": "^4.2.9",
"jest-circus": "workspace:*",
"jest-docblock": "workspace:*",
"jest-environment-node": "workspace:*",
"jest-get-type": "workspace:*",
"jest-regex-util": "workspace:*",
@ -59,6 +64,8 @@
"@types/graceful-fs": "^4.1.3",
"@types/micromatch": "^4.0.7",
"@types/parse-json": "^4.0.0",
"esbuild": "^0.23.0",
"esbuild-register": "^3.4.0",
"semver": "^7.5.3",
"ts-node": "^10.5.0",
"typescript": "^5.0.4"

View File

@ -10,8 +10,8 @@ import {isNativeError} from 'util/types';
import * as fs from 'graceful-fs';
import parseJson = require('parse-json');
import stripJsonComments = require('strip-json-comments');
import type {Service} from 'ts-node';
import type {Config} from '@jest/types';
import {extract, parse} from 'jest-docblock';
import {interopRequireDefault, requireOrImportModule} from 'jest-util';
import {
JEST_CONFIG_EXT_CTS,
@ -20,6 +20,10 @@ import {
PACKAGE_JSON,
} from './constants';
interface TsLoader {
enabled: (bool: boolean) => void;
}
type TsLoaderModule = 'ts-node' | 'esbuild-register';
// Read the configuration and set its `rootDir`
// 1. If it's a `package.json` file, we look into its "jest" property
// 2. If it's a `jest.config.ts` file, we use `ts-node` to transpile & require it
@ -89,8 +93,20 @@ const loadTSConfigFile = async (
configPath: string,
): Promise<Config.InitialOptions> => {
// Get registered TypeScript compiler instance
const registeredCompiler = await getRegisteredCompiler();
const docblockPragmas = parse(extract(fs.readFileSync(configPath, 'utf8')));
const tsLoader = docblockPragmas['jest-config-loader'] || 'ts-node';
if (Array.isArray(tsLoader)) {
throw new TypeError(
`Jest: You can only define a single loader through docblocks, got "${tsLoader.join(
', ',
)}"`,
);
}
const registeredCompiler = await getRegisteredCompiler(
tsLoader as TsLoaderModule,
);
registeredCompiler.enabled(true);
let configObject = interopRequireDefault(require(configPath)).default;
@ -105,36 +121,61 @@ const loadTSConfigFile = async (
return configObject;
};
let registeredCompilerPromise: Promise<Service>;
let registeredCompilerPromise: Promise<TsLoader>;
function getRegisteredCompiler() {
function getRegisteredCompiler(loader: TsLoaderModule) {
// Cache the promise to avoid multiple registrations
registeredCompilerPromise = registeredCompilerPromise ?? registerTsNode();
registeredCompilerPromise =
registeredCompilerPromise ?? registerTsLoader(loader);
return registeredCompilerPromise;
}
async function registerTsNode(): Promise<Service> {
async function registerTsLoader(loader: TsLoaderModule): Promise<TsLoader> {
try {
// Register TypeScript compiler instance
const tsNode = await import(/* webpackIgnore: true */ 'ts-node');
return tsNode.register({
compilerOptions: {
module: 'CommonJS',
moduleResolution: 'Node10',
},
moduleTypes: {
'**': 'cjs',
},
transpileOnly:
process.env.JEST_CONFIG_TRANSPILE_ONLY?.toLowerCase() === 'true',
});
if (loader === 'ts-node') {
const tsLoader = await import(/* webpackIgnore: true */ 'ts-node');
return tsLoader.register({
compilerOptions: {
module: 'CommonJS',
},
moduleTypes: {
'**': 'cjs',
},
transpileOnly:
process.env.JEST_CONFIG_TRANSPILE_ONLY?.toLowerCase() === 'true',
});
} else if (loader === 'esbuild-register') {
const tsLoader = await import(
/* webpackIgnore: true */ 'esbuild-register/dist/node'
);
let instance: {unregister: () => void} | undefined;
return {
enabled: (bool: boolean) => {
if (bool) {
instance = tsLoader.register({
target: `node${process.version.slice(1)}`,
});
} else {
instance?.unregister();
}
},
};
}
throw new Error(
`Jest: '${loader}' is not a valid TypeScript configuration loader.`,
);
} catch (error) {
if (
isNativeError(error) &&
(error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND'
) {
throw new Error(
`Jest: 'ts-node' is required for the TypeScript configuration files. Make sure it is installed\nError: ${error.message}`,
`Jest: '${loader}' is required for the TypeScript configuration files. Make sure it is installed\nError: ${error.message}`,
);
}

View File

@ -10,6 +10,7 @@
// jest-test-sequencer, but that is just `require.resolve`d, so no real use
// for their types
"references": [
{"path": "../jest-docblock"},
{"path": "../jest-environment-node"},
{"path": "../jest-get-type"},
{"path": "../jest-pattern"},

268
yarn.lock
View File

@ -2683,6 +2683,174 @@ __metadata:
languageName: node
linkType: hard
"@esbuild/aix-ppc64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/aix-ppc64@npm:0.23.0"
conditions: os=aix & cpu=ppc64
languageName: node
linkType: hard
"@esbuild/android-arm64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/android-arm64@npm:0.23.0"
conditions: os=android & cpu=arm64
languageName: node
linkType: hard
"@esbuild/android-arm@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/android-arm@npm:0.23.0"
conditions: os=android & cpu=arm
languageName: node
linkType: hard
"@esbuild/android-x64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/android-x64@npm:0.23.0"
conditions: os=android & cpu=x64
languageName: node
linkType: hard
"@esbuild/darwin-arm64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/darwin-arm64@npm:0.23.0"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
"@esbuild/darwin-x64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/darwin-x64@npm:0.23.0"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
"@esbuild/freebsd-arm64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/freebsd-arm64@npm:0.23.0"
conditions: os=freebsd & cpu=arm64
languageName: node
linkType: hard
"@esbuild/freebsd-x64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/freebsd-x64@npm:0.23.0"
conditions: os=freebsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/linux-arm64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/linux-arm64@npm:0.23.0"
conditions: os=linux & cpu=arm64
languageName: node
linkType: hard
"@esbuild/linux-arm@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/linux-arm@npm:0.23.0"
conditions: os=linux & cpu=arm
languageName: node
linkType: hard
"@esbuild/linux-ia32@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/linux-ia32@npm:0.23.0"
conditions: os=linux & cpu=ia32
languageName: node
linkType: hard
"@esbuild/linux-loong64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/linux-loong64@npm:0.23.0"
conditions: os=linux & cpu=loong64
languageName: node
linkType: hard
"@esbuild/linux-mips64el@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/linux-mips64el@npm:0.23.0"
conditions: os=linux & cpu=mips64el
languageName: node
linkType: hard
"@esbuild/linux-ppc64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/linux-ppc64@npm:0.23.0"
conditions: os=linux & cpu=ppc64
languageName: node
linkType: hard
"@esbuild/linux-riscv64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/linux-riscv64@npm:0.23.0"
conditions: os=linux & cpu=riscv64
languageName: node
linkType: hard
"@esbuild/linux-s390x@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/linux-s390x@npm:0.23.0"
conditions: os=linux & cpu=s390x
languageName: node
linkType: hard
"@esbuild/linux-x64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/linux-x64@npm:0.23.0"
conditions: os=linux & cpu=x64
languageName: node
linkType: hard
"@esbuild/netbsd-x64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/netbsd-x64@npm:0.23.0"
conditions: os=netbsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/openbsd-arm64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/openbsd-arm64@npm:0.23.0"
conditions: os=openbsd & cpu=arm64
languageName: node
linkType: hard
"@esbuild/openbsd-x64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/openbsd-x64@npm:0.23.0"
conditions: os=openbsd & cpu=x64
languageName: node
linkType: hard
"@esbuild/sunos-x64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/sunos-x64@npm:0.23.0"
conditions: os=sunos & cpu=x64
languageName: node
linkType: hard
"@esbuild/win32-arm64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/win32-arm64@npm:0.23.0"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
"@esbuild/win32-ia32@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/win32-ia32@npm:0.23.0"
conditions: os=win32 & cpu=ia32
languageName: node
linkType: hard
"@esbuild/win32-x64@npm:0.23.0":
version: 0.23.0
resolution: "@esbuild/win32-x64@npm:0.23.0"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
"@eslint-community/eslint-utils@npm:^4.2.0, @eslint-community/eslint-utils@npm:^4.4.0":
version: 4.4.0
resolution: "@eslint-community/eslint-utils@npm:4.4.0"
@ -9647,6 +9815,100 @@ __metadata:
languageName: node
linkType: hard
"esbuild-register@npm:^3.4.0":
version: 3.5.0
resolution: "esbuild-register@npm:3.5.0"
dependencies:
debug: ^4.3.4
peerDependencies:
esbuild: ">=0.12 <1"
checksum: f4307753c9672a2c901d04a1165031594a854f0a4c6f4c1db08aa393b68a193d38f2df483dc8ca0513e89f7b8998415e7e26fb9830989fb8cdccc5fb5f181c6b
languageName: node
linkType: hard
"esbuild@npm:^0.23.0":
version: 0.23.0
resolution: "esbuild@npm:0.23.0"
dependencies:
"@esbuild/aix-ppc64": 0.23.0
"@esbuild/android-arm": 0.23.0
"@esbuild/android-arm64": 0.23.0
"@esbuild/android-x64": 0.23.0
"@esbuild/darwin-arm64": 0.23.0
"@esbuild/darwin-x64": 0.23.0
"@esbuild/freebsd-arm64": 0.23.0
"@esbuild/freebsd-x64": 0.23.0
"@esbuild/linux-arm": 0.23.0
"@esbuild/linux-arm64": 0.23.0
"@esbuild/linux-ia32": 0.23.0
"@esbuild/linux-loong64": 0.23.0
"@esbuild/linux-mips64el": 0.23.0
"@esbuild/linux-ppc64": 0.23.0
"@esbuild/linux-riscv64": 0.23.0
"@esbuild/linux-s390x": 0.23.0
"@esbuild/linux-x64": 0.23.0
"@esbuild/netbsd-x64": 0.23.0
"@esbuild/openbsd-arm64": 0.23.0
"@esbuild/openbsd-x64": 0.23.0
"@esbuild/sunos-x64": 0.23.0
"@esbuild/win32-arm64": 0.23.0
"@esbuild/win32-ia32": 0.23.0
"@esbuild/win32-x64": 0.23.0
dependenciesMeta:
"@esbuild/aix-ppc64":
optional: true
"@esbuild/android-arm":
optional: true
"@esbuild/android-arm64":
optional: true
"@esbuild/android-x64":
optional: true
"@esbuild/darwin-arm64":
optional: true
"@esbuild/darwin-x64":
optional: true
"@esbuild/freebsd-arm64":
optional: true
"@esbuild/freebsd-x64":
optional: true
"@esbuild/linux-arm":
optional: true
"@esbuild/linux-arm64":
optional: true
"@esbuild/linux-ia32":
optional: true
"@esbuild/linux-loong64":
optional: true
"@esbuild/linux-mips64el":
optional: true
"@esbuild/linux-ppc64":
optional: true
"@esbuild/linux-riscv64":
optional: true
"@esbuild/linux-s390x":
optional: true
"@esbuild/linux-x64":
optional: true
"@esbuild/netbsd-x64":
optional: true
"@esbuild/openbsd-arm64":
optional: true
"@esbuild/openbsd-x64":
optional: true
"@esbuild/sunos-x64":
optional: true
"@esbuild/win32-arm64":
optional: true
"@esbuild/win32-ia32":
optional: true
"@esbuild/win32-x64":
optional: true
bin:
esbuild: bin/esbuild
checksum: 22138538225d5ce79f84fc0d3d3e31b57a91ef50ef00f2d6a9c8a4be4ed28d4b1d0ed14239e54341d1b9a7079f25e69761d0266f3c255da94e647b079b790421
languageName: node
linkType: hard
"escalade@npm:^3.1.1, escalade@npm:^3.1.2":
version: 3.1.2
resolution: "escalade@npm:3.1.2"
@ -13085,9 +13347,12 @@ __metadata:
chalk: ^4.0.0
ci-info: ^4.0.0
deepmerge: ^4.2.2
esbuild: ^0.23.0
esbuild-register: ^3.4.0
glob: ^10.3.10
graceful-fs: ^4.2.9
jest-circus: "workspace:*"
jest-docblock: "workspace:*"
jest-environment-node: "workspace:*"
jest-get-type: "workspace:*"
jest-regex-util: "workspace:*"
@ -13105,10 +13370,13 @@ __metadata:
typescript: ^5.0.4
peerDependencies:
"@types/node": "*"
esbuild-register: ">=3.4.0"
ts-node: ">=9.0.0"
peerDependenciesMeta:
"@types/node":
optional: true
esbuild-register:
optional: true
ts-node:
optional: true
languageName: unknown