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-config):Support loading TS config files via docblock loader #15190

Merged
merged 17 commits into from
Jul 23, 2024
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
- `[jest-config]` Loads config file from provided path in `package.json` ([#14044](https://github.com/facebook/jest/pull/14044))
- `[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]` 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))
Expand Down
18 changes: 16 additions & 2 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link
Member

Choose a reason for hiding this comment

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

not for this PR, but maybe in a follow-up - should we drop this new env variable and use docblock config instead (like we support for test environment: https://jestjs.io/blog/2022/04/25/jest-28#inline-testenvironmentoptions)? Then people could also e.g. opt into using swc (https://typestrong.org/ts-node/docs/swc/)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

that would be better, will send PR for that
we might also need to add support for swc based loader at some point #12156


:::

Expand Down
74 changes: 20 additions & 54 deletions e2e/__tests__/readInitialOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,60 +46,26 @@ 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([
SimenB marked this conversation as resolved.
Show resolved Hide resolved
['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(
Expand Down
14 changes: 14 additions & 0 deletions e2e/read-initial-options/ts-esbuild-register-config/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* 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;
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
* 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;
2 changes: 2 additions & 0 deletions e2e/typescript-config/modern-module-resolution/jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*
* 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
*/

const config = {
Expand Down
7 changes: 7 additions & 0 deletions packages/jest-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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:*",
Expand All @@ -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"
Expand Down
75 changes: 56 additions & 19 deletions packages/jest-config/src/readConfigFileAndSetRootDir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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(
Copy link
Contributor Author

@k-rajat19 k-rajat19 Jul 18, 2024

Choose a reason for hiding this comment

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

This is not working when passing multiple loaders like this
/**@jest-config-loader ts-node esbuild-register*/ it simply returns a string 'ts-node esbuild-register' instead of returning an array of strings ['ts-node','esbuild-register']
is this an expected behavior or a bug in jest-docblock?

Copy link
Member

Choose a reason for hiding this comment

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

that's expected - for it to be an array you'd need to do

/**
 * @jest-config-loader ts-node
 * @jest-config-loader esbuild-register
 */

`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;
Expand All @@ -105,36 +121,57 @@ const loadTSConfigFile = async (
return configObject;
};

let registeredCompilerPromise: Promise<Service>;
let registeredCompilerPromise: Promise<TsLoader>;
Copy link
Member

@SimenB SimenB Jul 17, 2024

Choose a reason for hiding this comment

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

I wonder if this needs to be a Map<TsLoaderModule, Promise<TsLoader>>? I'm thinking in cases where multi projects are used, and some project uses a different TS loader. That might already not work if the loaders get in the way of each other, tho.

Maybe a better thing would just be to save what module was loaded, and then throw an error if later another one is attempted to be used?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I have added integration tests for different loaders in different projects and they seem to be working fine d2c3a8d
do we still need to save the loader and doesn't allow using a different one?


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}`,
);
}

Expand Down
1 change: 1 addition & 0 deletions packages/jest-config/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
Loading
Loading