diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a4d33ec1c1..120c4e01b9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[jest-config]` Add `readInitialConfig` utility function ([#13356](https://github.com/facebook/jest/pull/13356)) - `[jest-core]` Enable testResultsProcessor to be async ([#13343](https://github.com/facebook/jest/pull/13343)) - `[expect, @jest/expect-utils]` Allow `isA` utility to take a type argument ([#13355](https://github.com/facebook/jest/pull/13355)) diff --git a/e2e/__tests__/readInitialOptions.test.ts b/e2e/__tests__/readInitialOptions.test.ts new file mode 100644 index 000000000000..9d2ba417eb4f --- /dev/null +++ b/e2e/__tests__/readInitialOptions.test.ts @@ -0,0 +1,148 @@ +/** + * 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 path = require('path'); +import execa = require('execa'); +import type {ReadJestConfigOptions, readInitialOptions} from 'jest-config'; + +function resolveFixture(...pathSegments: Array) { + return path.resolve(__dirname, '..', 'read-initial-options', ...pathSegments); +} + +interface ProxyReadJestConfigOptions extends ReadJestConfigOptions { + cwd?: string; +} + +/** + * These e2e tests are running via a child process, because we're running in a VM and are not allowed to `import` directly + * It also represents a more real-world example of how to run. + */ +async function proxyReadInitialOptions( + configFile: string | undefined, + options: ProxyReadJestConfigOptions, +): ReturnType { + const {stdout} = await execa( + 'node', + [ + require.resolve('../read-initial-options/readOptions.js'), + configFile ?? '', + JSON.stringify(options), + ], + {cwd: options?.cwd}, + ); + return JSON.parse(stdout); +} + +describe('readInitialOptions', () => { + test('should read from the cwd by default', 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 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('should be able to skip config reading, instead read from cwd', async () => { + const expectedConfigFile = resolveFixture( + 'json-config', + 'jest.config.json', + ); + const {config, configPath} = await proxyReadInitialOptions( + resolveFixture('js-config', 'jest.config.js'), + { + cwd: resolveFixture('json-config'), + readFromCwd: true, + }, + ); + + expect(config).toEqual({ + jestConfig: 'jest.config.json', + rootDir: path.dirname(expectedConfigFile), + }); + expect(configPath).toEqual(expectedConfigFile); + }); + + 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( + error => error, + ); + expect(error.message).toContain('Multiple configurations found'); + expect(error.message).toContain('multiple-config-files/jest.config.js'); + expect(error.message).toContain('multiple-config-files/jest.config.json'); + }); + + test('should be able to ignore multiple config files error', async () => { + const cwd = resolveFixture('multiple-config-files'); + const {config, configPath} = await proxyReadInitialOptions(undefined, { + cwd, + skipMultipleConfigError: true, + }); + expect(config).toEqual({ + jestConfig: 'jest.config.js', + rootDir: resolveFixture('multiple-config-files'), + }); + expect(configPath).toEqual( + resolveFixture('multiple-config-files', 'jest.config.js'), + ); + }); +}); diff --git a/e2e/read-initial-options/async-config/jest.config.js b/e2e/read-initial-options/async-config/jest.config.js new file mode 100644 index 000000000000..00df2a4f3837 --- /dev/null +++ b/e2e/read-initial-options/async-config/jest.config.js @@ -0,0 +1,11 @@ +/** + * 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 = async function () { + return { + jestConfig: 'async-config', + }; +}; diff --git a/e2e/read-initial-options/js-config/jest.config.js b/e2e/read-initial-options/js-config/jest.config.js new file mode 100644 index 000000000000..40ef4689be89 --- /dev/null +++ b/e2e/read-initial-options/js-config/jest.config.js @@ -0,0 +1,9 @@ +/** + * 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 = { + jestConfig: 'jest.config.js', +}; diff --git a/e2e/read-initial-options/json-config/jest.config.json b/e2e/read-initial-options/json-config/jest.config.json new file mode 100644 index 000000000000..bf022d79f271 --- /dev/null +++ b/e2e/read-initial-options/json-config/jest.config.json @@ -0,0 +1,3 @@ +{ + "jestConfig": "jest.config.json" +} diff --git a/e2e/read-initial-options/mjs-config/jest.config.mjs b/e2e/read-initial-options/mjs-config/jest.config.mjs new file mode 100644 index 000000000000..4875bf59fc0c --- /dev/null +++ b/e2e/read-initial-options/mjs-config/jest.config.mjs @@ -0,0 +1,9 @@ +/** + * 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 default { + jestConfig: 'jest.config.mjs', +}; diff --git a/e2e/read-initial-options/multiple-config-files/jest.config.js b/e2e/read-initial-options/multiple-config-files/jest.config.js new file mode 100644 index 000000000000..40ef4689be89 --- /dev/null +++ b/e2e/read-initial-options/multiple-config-files/jest.config.js @@ -0,0 +1,9 @@ +/** + * 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 = { + jestConfig: 'jest.config.js', +}; diff --git a/e2e/read-initial-options/multiple-config-files/jest.config.json b/e2e/read-initial-options/multiple-config-files/jest.config.json new file mode 100644 index 000000000000..bf022d79f271 --- /dev/null +++ b/e2e/read-initial-options/multiple-config-files/jest.config.json @@ -0,0 +1,3 @@ +{ + "jestConfig": "jest.config.json" +} diff --git a/e2e/read-initial-options/pkg-config/package.json b/e2e/read-initial-options/pkg-config/package.json new file mode 100644 index 000000000000..766501789210 --- /dev/null +++ b/e2e/read-initial-options/pkg-config/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "jestConfig": "package.json" + } +} diff --git a/e2e/read-initial-options/readOptions.js b/e2e/read-initial-options/readOptions.js new file mode 100644 index 000000000000..4bcc661dc079 --- /dev/null +++ b/e2e/read-initial-options/readOptions.js @@ -0,0 +1,22 @@ +/** + * 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 {readInitialOptions} = require('jest-config'); +async function readConfig() { + let config = process.argv[2]; + let options = undefined; + if (config === '') { + config = undefined; + } + if (process.argv[3]) { + options = JSON.parse(process.argv[3]); + } + console.log(JSON.stringify(await readInitialOptions(config, options))); +} +readConfig().catch(err => { + console.error(err); + process.exitCode = 1; +}); diff --git a/e2e/read-initial-options/ts-config/jest.config.ts b/e2e/read-initial-options/ts-config/jest.config.ts new file mode 100644 index 000000000000..bf05ee62de87 --- /dev/null +++ b/e2e/read-initial-options/ts-config/jest.config.ts @@ -0,0 +1,9 @@ +/** + * 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 default { + jestConfig: 'jest.config.ts', +}; diff --git a/packages/jest-config/src/__tests__/readConfig.test.ts b/packages/jest-config/src/__tests__/readConfig.test.ts index 1452e8355171..09cb362ecf49 100644 --- a/packages/jest-config/src/__tests__/readConfig.test.ts +++ b/packages/jest-config/src/__tests__/readConfig.test.ts @@ -10,8 +10,7 @@ import {readConfig} from '../index'; test('readConfig() throws when an object is passed without a file path', async () => { await expect( readConfig( - // @ts-expect-error - null /* argv */, + {$0: '', _: []}, {} /* packageRootOrConfig */, false /* skipArgvConfigOption */, null /* parentConfigPath */, diff --git a/packages/jest-config/src/__tests__/readInitialOptions.test.ts b/packages/jest-config/src/__tests__/readInitialOptions.test.ts new file mode 100644 index 000000000000..4b1038275b6d --- /dev/null +++ b/packages/jest-config/src/__tests__/readInitialOptions.test.ts @@ -0,0 +1,30 @@ +/** + * 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 type {Config} from '@jest/types'; +import {readInitialOptions} from '../index'; + +describe(readInitialOptions, () => { + test('should be able to use serialized jest config', async () => { + const inputConfig = {jestConfig: 'serialized'}; + const {config, configPath} = await readInitialOptions( + JSON.stringify(inputConfig), + ); + expect(config).toEqual({...inputConfig, rootDir: process.cwd()}); + expect(configPath).toBeNull(); + }); + + test('should allow deserialized options', async () => { + const inputConfig = {jestConfig: 'deserialized'}; + const {config, configPath} = await readInitialOptions(undefined, { + packageRootOrConfig: inputConfig as Config.InitialOptions, + parentConfigDirname: process.cwd(), + }); + expect(config).toEqual({...inputConfig, rootDir: process.cwd()}); + expect(configPath).toBeNull(); + }); + // Note: actual file reading is tested in e2e test +}); diff --git a/packages/jest-config/src/index.ts b/packages/jest-config/src/index.ts index de947b1d5de2..f8fb20d3590a 100644 --- a/packages/jest-config/src/index.ts +++ b/packages/jest-config/src/index.ts @@ -43,56 +43,18 @@ export async function readConfig( projectIndex = Infinity, skipMultipleConfigError = false, ): Promise { - let rawOptions: Config.InitialOptions; - let configPath = null; - - if (typeof packageRootOrConfig !== 'string') { - if (parentConfigDirname) { - rawOptions = packageRootOrConfig; - rawOptions.rootDir = rawOptions.rootDir - ? replaceRootDirInPath(parentConfigDirname, rawOptions.rootDir) - : parentConfigDirname; - } else { - throw new Error( - 'Jest: Cannot use configuration as an object without a file path.', - ); - } - } else if (isJSONString(argv.config)) { - // A JSON string was passed to `--config` argument and we can parse it - // and use as is. - let config; - try { - config = JSON.parse(argv.config); - } catch { - throw new Error( - 'There was an error while parsing the `--config` argument as a JSON string.', - ); - } - - // NOTE: we might need to resolve this dir to an absolute path in the future - config.rootDir = config.rootDir || packageRootOrConfig; - rawOptions = config; - // A string passed to `--config`, which is either a direct path to the config - // or a path to directory containing `package.json`, `jest.config.js` or `jest.config.ts` - } else if (!skipArgvConfigOption && typeof argv.config == 'string') { - configPath = resolveConfigPath( - argv.config, - process.cwd(), - skipMultipleConfigError, - ); - rawOptions = await readConfigFileAndSetRootDir(configPath); - } else { - // Otherwise just try to find config in the current rootDir. - configPath = resolveConfigPath( + const {config: initialOptions, configPath} = await readInitialOptions( + argv.config, + { packageRootOrConfig, - process.cwd(), + parentConfigDirname, + readFromCwd: skipArgvConfigOption, skipMultipleConfigError, - ); - rawOptions = await readConfigFileAndSetRootDir(configPath); - } + }, + ); const {options, hasDeprecationWarnings} = await normalize( - rawOptions, + initialOptions, argv, configPath, projectIndex, @@ -267,6 +229,90 @@ This usually means that your ${chalk.bold( } }; +export interface ReadJestConfigOptions { + /** + * The package root or deserialized config (default is cwd) + */ + packageRootOrConfig?: string | Config.InitialOptions; + /** + * When the `packageRootOrConfig` contains config, this parameter should + * contain the dirname of the parent config + */ + parentConfigDirname?: null | string; + /** + * Indicates whether or not to read the specified config file from disk. + * When true, jest will read try to read config from the current working directory. + * (default is false) + */ + readFromCwd?: boolean; + /** + * Indicates whether or not to ignore the error of jest finding multiple config files. + * (default is false) + */ + skipMultipleConfigError?: boolean; +} + +/** + * Reads the jest config, without validating them or filling it out with defaults. + * @param config The path to the file or serialized config. + * @param param1 Additional options + * @returns The raw initial config (not validated) + */ +export async function readInitialOptions( + config?: string, + { + packageRootOrConfig = process.cwd(), + parentConfigDirname = null, + readFromCwd = false, + skipMultipleConfigError = false, + }: ReadJestConfigOptions = {}, +): Promise<{config: Config.InitialOptions; configPath: string | null}> { + if (typeof packageRootOrConfig !== 'string') { + if (parentConfigDirname) { + const rawOptions = packageRootOrConfig; + rawOptions.rootDir = rawOptions.rootDir + ? replaceRootDirInPath(parentConfigDirname, rawOptions.rootDir) + : parentConfigDirname; + return {config: rawOptions, configPath: null}; + } else { + throw new Error( + 'Jest: Cannot use configuration as an object without a file path.', + ); + } + } + if (isJSONString(config)) { + try { + // A JSON string was passed to `--config` argument and we can parse it + // and use as is. + const initialOptions = JSON.parse(config); + // NOTE: we might need to resolve this dir to an absolute path in the future + initialOptions.rootDir = initialOptions.rootDir || packageRootOrConfig; + return {config: initialOptions, configPath: null}; + } catch { + throw new Error( + 'There was an error while parsing the `--config` argument as a JSON string.', + ); + } + } + if (!readFromCwd && typeof config == 'string') { + // A string passed to `--config`, which is either a direct path to the config + // or a path to directory containing `package.json`, `jest.config.js` or `jest.config.ts` + const configPath = resolveConfigPath( + config, + process.cwd(), + skipMultipleConfigError, + ); + return {config: await readConfigFileAndSetRootDir(configPath), configPath}; + } + // Otherwise just try to find config in the current rootDir. + const configPath = resolveConfigPath( + packageRootOrConfig, + process.cwd(), + skipMultipleConfigError, + ); + return {config: await readConfigFileAndSetRootDir(configPath), configPath}; +} + // Possible scenarios: // 1. jest --config config.json // 2. jest --projects p1 p2