diff --git a/CHANGELOG.md b/CHANGELOG.md index 573ddf17f723..a87c2630548d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ### Features +- `[jest-config]` Warn when multiple Jest configs are located ([#11922](https://github.com/facebook/jest/pull/11922)) + ### Fixes - `[expect]` Pass matcher context to asymmetric matchers ([#11926](https://github.com/facebook/jest/pull/11926) & [#11930](https://github.com/facebook/jest/pull/11930)) diff --git a/e2e/Utils.ts b/e2e/Utils.ts index fd0a591dfa49..e5b44d1a74ca 100644 --- a/e2e/Utils.ts +++ b/e2e/Utils.ts @@ -176,7 +176,6 @@ interface JestPackageJson extends PackageJson { } const DEFAULT_PACKAGE_JSON: JestPackageJson = { - description: 'THIS IS AN AUTOGENERATED FILE AND SHOULD NOT BE ADDED TO GIT', jest: { testEnvironment: 'node', }, @@ -184,12 +183,16 @@ const DEFAULT_PACKAGE_JSON: JestPackageJson = { export const createEmptyPackage = ( directory: Config.Path, - packageJson = DEFAULT_PACKAGE_JSON, + packageJson: PackageJson = DEFAULT_PACKAGE_JSON, ) => { + const packageJsonWithDefaults = { + ...packageJson, + description: 'THIS IS AN AUTOGENERATED FILE AND SHOULD NOT BE ADDED TO GIT', + }; fs.mkdirSync(directory, {recursive: true}); fs.writeFileSync( path.resolve(directory, 'package.json'), - JSON.stringify(packageJson, null, 2), + JSON.stringify(packageJsonWithDefaults, null, 2), ); }; diff --git a/e2e/__tests__/__snapshots__/multipleConfigs.ts.snap b/e2e/__tests__/__snapshots__/multipleConfigs.ts.snap new file mode 100644 index 000000000000..ae086ee790db --- /dev/null +++ b/e2e/__tests__/__snapshots__/multipleConfigs.ts.snap @@ -0,0 +1,25 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`multiple configs will warn 1`] = ` +● Multiple configurations found: + * /e2e/multiple-configs/jest.config.js + * /e2e/multiple-configs/jest.config.json + * \`jest\` key in /e2e/multiple-configs/package.json + + Implicit config resolution does not allow multiple configuration files. + Either remove unused config files or select one explicitly with \`--config\`. + + Configuration Documentation: + https://jestjs.io/docs/configuration.html + +PASS Config from js file __tests__/test.js + ✓ dummy test +`; + +exports[`multiple configs will warn 2`] = ` +Test Suites: 1 passed, 1 total +Tests: 1 passed, 1 total +Snapshots: 0 total +Time: <> +Ran all test suites. +`; diff --git a/e2e/__tests__/configOverride.test.ts b/e2e/__tests__/configOverride.test.ts new file mode 100644 index 000000000000..0efb17551a6f --- /dev/null +++ b/e2e/__tests__/configOverride.test.ts @@ -0,0 +1,24 @@ +/** + * 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 {getConfig} from '../runJest'; + +test('reads config from cjs file', () => { + const {configs} = getConfig( + 'config-override', + ['--config', 'different-config.json'], + { + skipPkgJsonCheck: true, + }, + ); + + expect(configs).toHaveLength(1); + expect(configs[0].displayName).toEqual({ + color: 'white', + name: 'Config from different-config.json file', + }); +}); diff --git a/e2e/__tests__/dependencyClash.test.ts b/e2e/__tests__/dependencyClash.test.ts index 87b9c81eee77..d6ea667631a1 100644 --- a/e2e/__tests__/dependencyClash.test.ts +++ b/e2e/__tests__/dependencyClash.test.ts @@ -18,7 +18,7 @@ const hasteImplModulePath = path beforeEach(() => { cleanup(tempDir); - createEmptyPackage(tempDir); + createEmptyPackage(tempDir, {}); }); // This test case is checking that when having both diff --git a/e2e/__tests__/multipleConfigs.ts b/e2e/__tests__/multipleConfigs.ts new file mode 100644 index 000000000000..305140510bc3 --- /dev/null +++ b/e2e/__tests__/multipleConfigs.ts @@ -0,0 +1,43 @@ +/** + * 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 * as path from 'path'; +import {wrap} from 'jest-snapshot-serializer-raw'; +import slash = require('slash'); +import {extractSummary} from '../Utils'; +import runJest from '../runJest'; + +const MULTIPLE_CONFIGS_WARNING_TEXT = 'Multiple configurations found'; + +test('multiple configs will warn', () => { + const rootDir = slash(path.resolve(__dirname, '../..')); + const {exitCode, stderr} = runJest('multiple-configs', [], { + skipPkgJsonCheck: true, + }); + + expect(exitCode).toBe(0); + expect(stderr).toContain(MULTIPLE_CONFIGS_WARNING_TEXT); + + const cleanStdErr = stderr.replace(new RegExp(rootDir, 'g'), ''); + const {rest, summary} = extractSummary(cleanStdErr); + + expect(wrap(rest)).toMatchSnapshot(); + expect(wrap(summary)).toMatchSnapshot(); +}); + +test('multiple configs warning can be suppressed by using --config', () => { + const {exitCode, stderr} = runJest( + 'multiple-configs', + ['--config', 'jest.config.json'], + { + skipPkgJsonCheck: true, + }, + ); + + expect(exitCode).toBe(0); + expect(stderr).not.toContain(MULTIPLE_CONFIGS_WARNING_TEXT); +}); diff --git a/e2e/config-override/__tests__/test.js b/e2e/config-override/__tests__/test.js new file mode 100644 index 000000000000..2b4a7ced6f45 --- /dev/null +++ b/e2e/config-override/__tests__/test.js @@ -0,0 +1,10 @@ +/** + * 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. + */ + +test('dummy test', () => { + expect(1).toBe(1); +}); diff --git a/e2e/config-override/different-config.json b/e2e/config-override/different-config.json new file mode 100644 index 000000000000..707a2314b2f1 --- /dev/null +++ b/e2e/config-override/different-config.json @@ -0,0 +1,3 @@ +{ + "displayName": "Config from different-config.json file" +} diff --git a/e2e/config-override/jest.config.json b/e2e/config-override/jest.config.json new file mode 100644 index 000000000000..ac2653d362cb --- /dev/null +++ b/e2e/config-override/jest.config.json @@ -0,0 +1,3 @@ +{ + "displayName": "Config from json file" +} diff --git a/e2e/config-override/package.json b/e2e/config-override/package.json new file mode 100644 index 000000000000..fecde4600298 --- /dev/null +++ b/e2e/config-override/package.json @@ -0,0 +1,3 @@ +{ + "name": "config-override" +} diff --git a/e2e/esm-config/cjs/package.json b/e2e/esm-config/cjs/package.json index 586d4ca6b75c..d9f70a793cd8 100644 --- a/e2e/esm-config/cjs/package.json +++ b/e2e/esm-config/cjs/package.json @@ -1,3 +1,3 @@ { - "jest": {} + "name": "cjs-config" } diff --git a/e2e/esm-config/mjs/package.json b/e2e/esm-config/mjs/package.json index 586d4ca6b75c..e230b9436a74 100644 --- a/e2e/esm-config/mjs/package.json +++ b/e2e/esm-config/mjs/package.json @@ -1,3 +1,3 @@ { - "jest": {} + "name": "mjs-config" } diff --git a/e2e/multiple-configs/__tests__/test.js b/e2e/multiple-configs/__tests__/test.js new file mode 100644 index 000000000000..2b4a7ced6f45 --- /dev/null +++ b/e2e/multiple-configs/__tests__/test.js @@ -0,0 +1,10 @@ +/** + * 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. + */ + +test('dummy test', () => { + expect(1).toBe(1); +}); diff --git a/e2e/multiple-configs/jest.config.js b/e2e/multiple-configs/jest.config.js new file mode 100644 index 000000000000..a2876a6ef5e6 --- /dev/null +++ b/e2e/multiple-configs/jest.config.js @@ -0,0 +1,10 @@ +/** + * 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 = { + displayName: 'Config from js file', +}; diff --git a/e2e/multiple-configs/jest.config.json b/e2e/multiple-configs/jest.config.json new file mode 100644 index 000000000000..ac2653d362cb --- /dev/null +++ b/e2e/multiple-configs/jest.config.json @@ -0,0 +1,3 @@ +{ + "displayName": "Config from json file" +} diff --git a/e2e/multiple-configs/package.json b/e2e/multiple-configs/package.json new file mode 100644 index 000000000000..46e3ee413cc7 --- /dev/null +++ b/e2e/multiple-configs/package.json @@ -0,0 +1,5 @@ +{ + "jest": { + "displayName": "Config from package.json file" + } +} diff --git a/packages/jest-config/src/__tests__/resolveConfigPath.test.ts b/packages/jest-config/src/__tests__/resolveConfigPath.test.ts index 10c45675498d..96db06c9213b 100644 --- a/packages/jest-config/src/__tests__/resolveConfigPath.test.ts +++ b/packages/jest-config/src/__tests__/resolveConfigPath.test.ts @@ -14,6 +14,17 @@ import resolveConfigPath from '../resolveConfigPath'; const DIR = path.resolve(tmpdir(), 'resolve_config_path_test'); const ERROR_PATTERN = /Could not find a config file based on provided values/; const NO_ROOT_DIR_ERROR_PATTERN = /Can't find a root directory/; +const MULTIPLE_CONFIGS_ERROR_PATTERN = /Multiple configurations found/; + +const mockConsoleWarn = () => { + jest.spyOn(console, 'warn'); + const mockedConsoleWarn = console.warn as jest.Mock>; + + // We will mock console.warn because it would produce a lot of noise in the tests + mockedConsoleWarn.mockImplementation(() => {}); + + return mockedConsoleWarn; +}; beforeEach(() => cleanup(DIR)); afterEach(() => cleanup(DIR)); @@ -45,6 +56,8 @@ describe.each(JEST_CONFIG_EXT_ORDER.slice(0))( }); test(`directory path with "${extension}"`, () => { + const mockedConsoleWarn = mockConsoleWarn(); + const relativePackageJsonPath = 'a/b/c/package.json'; const absolutePackageJsonPath = path.resolve( DIR, @@ -53,9 +66,9 @@ describe.each(JEST_CONFIG_EXT_ORDER.slice(0))( const relativeJestConfigPath = `a/b/c/jest.config${extension}`; const absoluteJestConfigPath = path.resolve(DIR, relativeJestConfigPath); + // no configs yet. should throw writeFiles(DIR, {[`a/b/c/some_random_file${extension}`]: ''}); - // no configs yet. should throw expect(() => // absolute resolveConfigPath(path.dirname(absoluteJestConfigPath), DIR), @@ -68,6 +81,7 @@ describe.each(JEST_CONFIG_EXT_ORDER.slice(0))( writeFiles(DIR, {[relativePackageJsonPath]: ''}); + mockedConsoleWarn.mockClear(); // absolute expect( resolveConfigPath(path.dirname(absolutePackageJsonPath), DIR), @@ -77,20 +91,45 @@ describe.each(JEST_CONFIG_EXT_ORDER.slice(0))( expect( resolveConfigPath(path.dirname(relativePackageJsonPath), DIR), ).toBe(absolutePackageJsonPath); + expect(mockedConsoleWarn).not.toBeCalled(); + // jest.config.js takes precedence writeFiles(DIR, {[relativeJestConfigPath]: ''}); - // jest.config.js takes precedence + mockedConsoleWarn.mockClear(); + // absolute + expect( + resolveConfigPath(path.dirname(absolutePackageJsonPath), DIR), + ).toBe(absoluteJestConfigPath); + + // relative + expect( + resolveConfigPath(path.dirname(relativePackageJsonPath), DIR), + ).toBe(absoluteJestConfigPath); + expect(mockedConsoleWarn).not.toBeCalled(); + + // jest.config.js and package.json with 'jest' cannot be used together + writeFiles(DIR, {[relativePackageJsonPath]: JSON.stringify({jest: {}})}); // absolute + mockedConsoleWarn.mockClear(); expect( resolveConfigPath(path.dirname(absolutePackageJsonPath), DIR), ).toBe(absoluteJestConfigPath); + expect(mockedConsoleWarn).toBeCalledTimes(1); + expect(mockedConsoleWarn.mock.calls[0].join()).toMatch( + MULTIPLE_CONFIGS_ERROR_PATTERN, + ); // relative + mockedConsoleWarn.mockClear(); expect( resolveConfigPath(path.dirname(relativePackageJsonPath), DIR), ).toBe(absoluteJestConfigPath); + expect(mockedConsoleWarn).toBeCalledTimes(1); + expect(mockedConsoleWarn.mock.calls[0].join()).toMatch( + MULTIPLE_CONFIGS_ERROR_PATTERN, + ); expect(() => { resolveConfigPath( @@ -101,3 +140,48 @@ describe.each(JEST_CONFIG_EXT_ORDER.slice(0))( }); }, ); + +const pickPairsWithSameOrder = (array: ReadonlyArray) => + array + .map((value1, idx, arr) => + arr.slice(idx + 1).map(value2 => [value1, value2]), + ) + // TODO: use .flat() when we drop Node 10 + .reduce((acc, val) => acc.concat(val), []); + +test('pickPairsWithSameOrder', () => { + expect(pickPairsWithSameOrder([1, 2, 3])).toStrictEqual([ + [1, 2], + [1, 3], + [2, 3], + ]); +}); + +describe.each(pickPairsWithSameOrder(JEST_CONFIG_EXT_ORDER))( + 'Using multiple configs shows warning', + (extension1, extension2) => { + test(`Using jest.config${extension1} and jest.config${extension2} shows warning`, () => { + const mockedConsoleWarn = mockConsoleWarn(); + + const relativeJestConfigPaths = [ + `a/b/c/jest.config${extension1}`, + `a/b/c/jest.config${extension2}`, + ]; + + writeFiles(DIR, { + [relativeJestConfigPaths[0]]: '', + [relativeJestConfigPaths[1]]: '', + }); + + // multiple configs here, should print warning + mockedConsoleWarn.mockClear(); + expect( + resolveConfigPath(path.dirname(relativeJestConfigPaths[0]), DIR), + ).toBe(path.resolve(DIR, relativeJestConfigPaths[0])); + expect(mockedConsoleWarn).toBeCalledTimes(1); + expect(mockedConsoleWarn.mock.calls[0].join()).toMatch( + MULTIPLE_CONFIGS_ERROR_PATTERN, + ); + }); + }, +); diff --git a/packages/jest-config/src/index.ts b/packages/jest-config/src/index.ts index 364346ecf954..f9b4c8456218 100644 --- a/packages/jest-config/src/index.ts +++ b/packages/jest-config/src/index.ts @@ -44,6 +44,7 @@ export async function readConfig( skipArgvConfigOption?: boolean, parentConfigDirname?: Config.Path | null, projectIndex: number = Infinity, + skipMultipleConfigWarning = false, ): Promise { let rawOptions: Config.InitialOptions; let configPath = null; @@ -77,11 +78,19 @@ export async function readConfig( // 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()); + configPath = resolveConfigPath( + argv.config, + process.cwd(), + skipMultipleConfigWarning, + ); rawOptions = await readConfigFileAndSetRootDir(configPath); } else { // Otherwise just try to find config in the current rootDir. - configPath = resolveConfigPath(packageRootOrConfig, process.cwd()); + configPath = resolveConfigPath( + packageRootOrConfig, + process.cwd(), + skipMultipleConfigWarning, + ); rawOptions = await readConfigFileAndSetRootDir(configPath); } @@ -332,6 +341,8 @@ export async function readConfigs( skipArgvConfigOption, configPath ? path.dirname(configPath) : cwd, projectIndex, + // we wanna skip the warning if this is the "main" project + projectIsCwd, ); }), ); diff --git a/packages/jest-config/src/resolveConfigPath.ts b/packages/jest-config/src/resolveConfigPath.ts index dc8655513858..0f1439197a36 100644 --- a/packages/jest-config/src/resolveConfigPath.ts +++ b/packages/jest-config/src/resolveConfigPath.ts @@ -6,7 +6,9 @@ */ import * as path from 'path'; +import chalk = require('chalk'); import * as fs from 'graceful-fs'; +import slash = require('slash'); import type {Config} from '@jest/types'; import { JEST_CONFIG_BASE_NAME, @@ -19,7 +21,11 @@ const isFile = (filePath: Config.Path) => const getConfigFilename = (ext: string) => JEST_CONFIG_BASE_NAME + ext; -export default (pathToResolve: Config.Path, cwd: Config.Path): Config.Path => { +export default ( + pathToResolve: Config.Path, + cwd: Config.Path, + skipMultipleConfigWarning = false, +): Config.Path => { if (!path.isAbsolute(cwd)) { throw new Error(`"cwd" must be an absolute path. cwd: ${cwd}`); } @@ -49,24 +55,35 @@ export default (pathToResolve: Config.Path, cwd: Config.Path): Config.Path => { ); } - return resolveConfigPathByTraversing(absolutePath, pathToResolve, cwd); + return resolveConfigPathByTraversing( + absolutePath, + pathToResolve, + cwd, + skipMultipleConfigWarning, + ); }; const resolveConfigPathByTraversing = ( pathToResolve: Config.Path, initialPath: Config.Path, cwd: Config.Path, + skipMultipleConfigWarning: boolean, ): Config.Path => { - const jestConfig = JEST_CONFIG_EXT_ORDER.map(ext => + const configFiles = JEST_CONFIG_EXT_ORDER.map(ext => path.resolve(pathToResolve, getConfigFilename(ext)), - ).find(isFile); - if (jestConfig) { - return jestConfig; + ).filter(isFile); + + const packageJson = findPackageJson(pathToResolve); + if (packageJson && hasPackageJsonJestKey(packageJson)) { + configFiles.push(packageJson); + } + + if (!skipMultipleConfigWarning && configFiles.length > 1) { + console.warn(makeMultipleConfigsWarning(configFiles)); } - const packageJson = path.resolve(pathToResolve, PACKAGE_JSON); - if (isFile(packageJson)) { - return packageJson; + if (configFiles.length > 0 || packageJson) { + return configFiles[0] ?? packageJson; } // This is the system root. @@ -80,9 +97,29 @@ const resolveConfigPathByTraversing = ( path.dirname(pathToResolve), initialPath, cwd, + skipMultipleConfigWarning, ); }; +const findPackageJson = (pathToResolve: Config.Path) => { + const packagePath = path.resolve(pathToResolve, PACKAGE_JSON); + if (isFile(packagePath)) { + return packagePath; + } + + return undefined; +}; + +const hasPackageJsonJestKey = (packagePath: Config.Path) => { + const content = fs.readFileSync(packagePath, 'utf8'); + try { + return 'jest' in JSON.parse(content); + } catch { + // If package is not a valid JSON + return false; + } +}; + const makeResolutionErrorMessage = ( initialPath: Config.Path, cwd: Config.Path, @@ -95,3 +132,29 @@ const makeResolutionErrorMessage = ( `traverse directory tree up, until it finds one of those files in exact order: ${JEST_CONFIG_EXT_ORDER.map( ext => `"${getConfigFilename(ext)}"`, ).join(' or ')}.`; + +function extraIfPackageJson(configPath: Config.Path) { + if (configPath.endsWith(PACKAGE_JSON)) { + return '`jest` key in '; + } + + return ''; +} + +const makeMultipleConfigsWarning = (configPaths: Array) => + chalk.yellow( + [ + chalk.bold('\u25cf Multiple configurations found:'), + ...configPaths.map( + configPath => + ` * ${extraIfPackageJson(configPath)}${slash(configPath)}`, + ), + '', + ' Implicit config resolution does not allow multiple configuration files.', + ' Either remove unused config files or select one explicitly with `--config`.', + '', + ' Configuration Documentation:', + ' https://jestjs.io/docs/configuration.html', + '', + ].join('\n'), + );