diff --git a/integration_tests/__tests__/multi_project_runner.test.js b/integration_tests/__tests__/multi_project_runner.test.js index 9b78ea3fdab7..eb94d271bf11 100644 --- a/integration_tests/__tests__/multi_project_runner.test.js +++ b/integration_tests/__tests__/multi_project_runner.test.js @@ -89,3 +89,98 @@ test('can pass projects or global config', () => { expect(result1.summary).toBe(result2.summary); expect(sortLines(result1.rest)).toBe(sortLines(result2.rest)); }); + +test('resolves projects and their properly', () => { + writeFiles(DIR, { + '.watchmanconfig': '', + 'package.json': JSON.stringify({ + jest: { + projects: [ + 'project1.conf.json', + '/project2/project2.conf.json', + ], + }, + }), + 'project1.conf.json': JSON.stringify({ + name: 'project1', + rootDir: './project1', + // root dir should be this project's directory + setupFiles: ['/project1_setup.js'], + testEnvironment: 'node', + }), + 'project1/__tests__/test.test.js': `test('project1', () => expect(global.project1).toBe(true))`, + 'project1/project1_setup.js': 'global.project1 = true;', + 'project2/__tests__/test.test.js': `test('project2', () => expect(global.project2).toBe(true))`, + 'project2/project2.conf.json': JSON.stringify({ + name: 'project2', + rootDir: '../', // root dir is set to the top level + setupFiles: ['/project2/project2_setup.js'], // rootDir shold be of the + testEnvironment: 'node', + testPathIgnorePatterns: ['project1'], + }), + 'project2/project2_setup.js': 'global.project2 = true;', + }); + + let stderr; + ({stderr} = runJest(DIR)); + + expect(stderr).toMatch('Ran all test suites in 2 projects.'); + expect(stderr).toMatch(' PASS project1/__tests__/test.test.js'); + expect(stderr).toMatch(' PASS project2/__tests__/test.test.js'); + + // Use globs + writeFiles(DIR, { + 'dir1/random_file': '', + 'dir2/random_file': '', + 'package.json': JSON.stringify({ + jest: { + projects: ['**/*.conf.json'], + }, + }), + }); + + ({stderr} = runJest(DIR)); + expect(stderr).toMatch('Ran all test suites in 2 projects.'); + expect(stderr).toMatch(' PASS project1/__tests__/test.test.js'); + expect(stderr).toMatch(' PASS project2/__tests__/test.test.js'); + + // Include two projects that will resolve to the same config + writeFiles(DIR, { + 'dir1/random_file': '', + 'dir2/random_file': '', + 'package.json': JSON.stringify({ + jest: { + projects: [ + 'dir1', + 'dir2', + 'project1.conf.json', + '/project2/project2.conf.json', + ], + }, + }), + }); + + ({stderr} = runJest(DIR)); + expect(stderr).toMatch( + /One or more specified projects share the same config file/, + ); + + // praject with a directory/file that does not exist + writeFiles(DIR, { + 'package.json': JSON.stringify({ + jest: { + projects: [ + 'banana', + 'project1.conf.json', + '/project2/project2.conf.json', + ], + }, + }), + }); + + ({stderr} = runJest(DIR)); + expect(stderr).toMatch( + `Can't find a root directory while resolving a config file path.`, + ); + expect(stderr).toMatch(/banana/); +}); diff --git a/packages/jest-cli/src/cli/index.js b/packages/jest-cli/src/cli/index.js index 7d7eff1241bf..25236ae6c99d 100644 --- a/packages/jest-cli/src/cli/index.js +++ b/packages/jest-cli/src/cli/index.js @@ -159,6 +159,29 @@ const _printVersionAndExit = outputStream => { process.exit(0); }; +const _ensureNoDuplicateConfigs = (parsedConfigs, projects) => { + const configPathSet = new Set(); + + for (const {configPath} of parsedConfigs) { + if (configPathSet.has(configPath)) { + let message = + 'One or more specified projects share the same config file\n'; + + parsedConfigs.forEach(({configPath}, index) => { + message = + message + + '\nProject: "' + + projects[index] + + '"\nConfig: "' + + String(configPath) + + '"'; + }); + throw new Error(message); + } + configPathSet.add(configPath); + } +}; + // Possible scenarios: // 1. jest --config config.json // 2. jest --projects p1 p2 @@ -203,6 +226,7 @@ const _getConfigs = ( if (projects.length > 1) { const parsedConfigs = projects.map(root => readConfig(argv, root, true)); + _ensureNoDuplicateConfigs(parsedConfigs, projects); configs = parsedConfigs.map(({config}) => config); if (!hasDeprecationWarnings) { hasDeprecationWarnings = parsedConfigs.some( diff --git a/packages/jest-config/src/__tests__/normalize.test.js b/packages/jest-config/src/__tests__/normalize.test.js index 79f00ef921de..956f1da06910 100644 --- a/packages/jest-config/src/__tests__/normalize.test.js +++ b/packages/jest-config/src/__tests__/normalize.test.js @@ -915,37 +915,3 @@ describe('preset without setupFiles', () => { ); }); }); - -describe('projects', () => { - beforeEach(() => { - jest.resetModules(); - - const Resolver = require('jest-resolve'); - Resolver.findNodeModule = findNodeModule; - }); - - test('resolves projects correctly', () => { - const root = '/path/to/test'; - const glob = require('glob'); - glob.sync = jest.fn( - pattern => - pattern.indexOf('/examples/') !== -1 - ? [root + '/examples/async', root + '/examples/snapshot'] - : [pattern], - ); - const normalize = require('../normalize'); - const {options} = normalize( - { - projects: ['', '/examples/*'], - rootDir: root, - }, - {}, - ); - - expect(options.projects).toEqual([ - root, - root + '/examples/async', - root + '/examples/snapshot', - ]); - }); -}); diff --git a/packages/jest-config/src/__tests__/resolve_config_path.test.js b/packages/jest-config/src/__tests__/resolve_config_path.test.js new file mode 100644 index 000000000000..62852aa30519 --- /dev/null +++ b/packages/jest-config/src/__tests__/resolve_config_path.test.js @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + */ + +import {cleanup, writeFiles} from '../../../../integration_tests/utils'; +import os from 'os'; +import path from 'path'; +import resolveConfigPath from '../resolve_config_path'; + +import skipOnWindows from '../../../../scripts/skip_on_windows'; + +skipOnWindows.suite(); + +const DIR = path.resolve(os.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/; + +beforeEach(() => cleanup(DIR)); +afterEach(() => cleanup(DIR)); + +test('file path', () => { + const relativeConfigPath = 'a/b/c/my_config.js'; + const absoluteConfigPath = path.resolve(DIR, relativeConfigPath); + + writeFiles(DIR, {[relativeConfigPath]: ''}); + + // absolute + expect(resolveConfigPath(absoluteConfigPath, DIR)).toBe(absoluteConfigPath); + expect(() => resolveConfigPath('/does_not_exist', DIR)).toThrowError( + NO_ROOT_DIR_ERROR_PATTERN, + ); + + // relative + expect(resolveConfigPath(relativeConfigPath, DIR)).toBe(absoluteConfigPath); + expect(() => resolveConfigPath('does_not_exist', DIR)).toThrowError( + NO_ROOT_DIR_ERROR_PATTERN, + ); +}); + +test('directory path', () => { + const relativePackageJsonPath = 'a/b/c/package.json'; + const absolutePackageJsonPath = path.resolve(DIR, relativePackageJsonPath); + const relativeJestConfigPath = 'a/b/c/jest.config.js'; + const absoluteJestConfigPath = path.resolve(DIR, relativeJestConfigPath); + + writeFiles(DIR, {'a/b/c/some_random_file.js': ''}); + + // no configs yet. should throw + expect(() => + // absolute + resolveConfigPath(path.dirname(absoluteJestConfigPath), DIR), + ).toThrowError(ERROR_PATTERN); + + expect(() => + // relative + resolveConfigPath(path.dirname(relativeJestConfigPath), DIR), + ).toThrowError(ERROR_PATTERN); + + writeFiles(DIR, {[relativePackageJsonPath]: ''}); + + // absolute + expect(resolveConfigPath(path.dirname(absolutePackageJsonPath), DIR)).toBe( + absolutePackageJsonPath, + ); + + // relative + expect(resolveConfigPath(path.dirname(relativePackageJsonPath), DIR)).toBe( + absolutePackageJsonPath, + ); + + writeFiles(DIR, {[relativeJestConfigPath]: ''}); + + // jest.config.js takes presedence + + // absolute + expect(resolveConfigPath(path.dirname(absolutePackageJsonPath), DIR)).toBe( + absoluteJestConfigPath, + ); + + // relative + expect(resolveConfigPath(path.dirname(relativePackageJsonPath), DIR)).toBe( + absoluteJestConfigPath, + ); + + expect(() => { + resolveConfigPath( + path.join(path.dirname(relativePackageJsonPath), 'j/x/b/m/'), + DIR, + ); + }).toThrowError(NO_ROOT_DIR_ERROR_PATTERN); +}); diff --git a/packages/jest-config/src/constants.js b/packages/jest-config/src/constants.js index 3579cd7299d3..3f62a61958c1 100644 --- a/packages/jest-config/src/constants.js +++ b/packages/jest-config/src/constants.js @@ -13,3 +13,5 @@ import path from 'path'; exports.NODE_MODULES = path.sep + 'node_modules' + path.sep; exports.DEFAULT_JS_PATTERN = '^.+\\.jsx?$'; exports.DEFAULT_REPORTER_LABEL = 'default'; +exports.PACKAGE_JSON = 'package.json'; +exports.JEST_CONFIG = 'jest.config.js'; diff --git a/packages/jest-config/src/find_config.js b/packages/jest-config/src/find_config.js deleted file mode 100644 index 096e6072a03f..000000000000 --- a/packages/jest-config/src/find_config.js +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. - * - * This source code is licensed under the BSD-style license found in the - * LICENSE file in the root directory of this source tree. An additional grant - * of patent rights can be found in the PATENTS file in the same directory. - * - * @flow - */ - -import type {InitialOptions, Path} from 'types/Config'; - -import fs from 'fs'; -import path from 'path'; -import jsonlint from './vendor/jsonlint'; - -const JEST_CONFIG = 'jest.config.js'; -const PACKAGE_JSON = 'package.json'; - -const isFile = filePath => - fs.existsSync(filePath) && !fs.lstatSync(filePath).isDirectory(); - -const findConfig = (root: Path): InitialOptions => { - // $FlowFixMe - let options: InitialOptions = {}; - let directory = root; - const isJS = directory.endsWith('.js'); - if ((isJS || directory.endsWith('.json')) && isFile(directory)) { - const filePath = path.resolve(process.cwd(), directory); - if (isJS) { - // $FlowFixMe - options = require(filePath); - } else { - let pkg; - try { - // $FlowFixMe - pkg = require(filePath); - } catch (error) { - throw new Error( - `Jest: Failed to parse config file ${filePath}\n` + - ` ${jsonlint.errors(fs.readFileSync(filePath, 'utf8'))}`, - ); - } - if (directory.endsWith(PACKAGE_JSON)) { - options = pkg.jest || options; - } else { - options = pkg; - } - } - options.rootDir = options.rootDir - ? path.resolve(path.dirname(directory), options.rootDir) - : path.dirname(directory); - return options; - } - - do { - const configJsFilePath = path.resolve(path.join(directory, JEST_CONFIG)); - if (isFile(configJsFilePath)) { - // $FlowFixMe - options = require(configJsFilePath); - break; - } - - const packageJsonFilePath = path.resolve( - path.join(directory, PACKAGE_JSON), - ); - if (isFile(packageJsonFilePath)) { - // $FlowFixMe - const pkg = require(packageJsonFilePath); - if (pkg.jest) { - options = pkg.jest; - } - // Even if there is no configuration, we stop traveling up the - // tree if we hit a `package.json` file. - break; - } - } while (directory !== (directory = path.dirname(directory))); - - options.rootDir = options.rootDir - ? path.resolve(root, options.rootDir) - : path.resolve(directory); - - return options; -}; - -module.exports = findConfig; diff --git a/packages/jest-config/src/index.js b/packages/jest-config/src/index.js index d8de6a214aff..7d4618ead117 100644 --- a/packages/jest-config/src/index.js +++ b/packages/jest-config/src/index.js @@ -9,12 +9,12 @@ */ import type {Argv} from 'types/Argv'; -import type {GlobalConfig, ProjectConfig} from 'types/Config'; +import type {GlobalConfig, Path, ProjectConfig} from 'types/Config'; -import path from 'path'; import {getTestEnvironment, isJSONString} from './utils'; -import findConfig from './find_config'; import normalize from './normalize'; +import resolveConfigPath from './resolve_config_path'; +import readConfigFileAndSetRootDir from './read_config_file_and_set_root_dir'; function readConfig( argv: Argv, @@ -25,39 +25,42 @@ function readConfig( // read individual configs for every project. skipArgvConfigOption?: boolean, ): { - config: ProjectConfig, + configPath: ?Path, globalConfig: GlobalConfig, hasDeprecationWarnings: boolean, + config: ProjectConfig, } { - const rawOptions = readOptions(argv, packageRoot, skipArgvConfigOption); + let rawOptions; + let configPath; + + // A JSON string was passed to `--config` argument and we can parse it + // and use as is. + if (isJSONString(argv.config)) { + const config = JSON.parse(argv.config); + // NOTE: we might need to resolve this dir to an absolute path in the future + config.rootDir = config.rootDir || packageRoot; + rawOptions = config; + // A string passed to `--config`, which is either a direct path to the config + // or a path to directory containing `package.json` or `jest.conf.js` + } else if (!skipArgvConfigOption && typeof argv.config == 'string') { + configPath = resolveConfigPath(argv.config, process.cwd()); + rawOptions = readConfigFileAndSetRootDir(configPath); + } else { + // Otherwise just try to find config in the current rootDir. + configPath = resolveConfigPath(packageRoot, process.cwd()); + rawOptions = readConfigFileAndSetRootDir(configPath); + } + const {options, hasDeprecationWarnings} = normalize(rawOptions, argv); const {globalConfig, projectConfig} = getConfigs(options); return { config: projectConfig, + configPath, globalConfig, hasDeprecationWarnings, }; } -const readOptions = (argv, root, skipArgvConfigOption) => { - // A JSON string was passed to `--config` argument and we can parse it - // and use as is. - if (isJSONString(argv.config)) { - const config = JSON.parse(argv.config); - config.rootDir = config.rootDir || root; - return config; - } - - // A string passed to `--config`, which is either a direct path to the config - // or a path to directory containing `package.json` or `jest.conf.js` - if (!skipArgvConfigOption && typeof argv.config == 'string') { - return findConfig(path.resolve(process.cwd(), argv.config)); - } - - // Otherwise just try to find config in the current rootDir. - return findConfig(root); -}; - const getConfigs = ( options: Object, ): {globalConfig: GlobalConfig, projectConfig: ProjectConfig} => { diff --git a/packages/jest-config/src/normalize.js b/packages/jest-config/src/normalize.js index a7f8aa68257a..2ce502d145b7 100644 --- a/packages/jest-config/src/normalize.js +++ b/packages/jest-config/src/normalize.js @@ -12,13 +12,13 @@ import type {Argv} from 'types/Argv'; import type {InitialOptions, ReporterConfig} from 'types/Config'; import crypto from 'crypto'; +import glob from 'glob'; import path from 'path'; import {ValidationError, validate} from 'jest-validate'; import validatePattern from './validate_pattern'; import {clearLine} from 'jest-util'; import chalk from 'chalk'; import getMaxWorkers from './get_max_workers'; -import glob from 'glob'; import Resolver from 'jest-resolve'; import utils from 'jest-regex-util'; import { @@ -85,7 +85,6 @@ const setupPreset = ( ); } - // $FlowFixMe return Object.assign({}, preset, options); }; @@ -429,16 +428,15 @@ function normalize(options: InitialOptions, argv: Argv) { } break; case 'projects': - const projects = options[key]; - let list = []; - projects && - projects.forEach( - filePath => - (list = list.concat( - glob.sync(_replaceRootDirInPath(options.rootDir, filePath)), - )), - ); - value = list; + value = (options[key] || []) + .map(project => _replaceRootDirTags(options.rootDir, project)) + .reduce((projects, project) => { + // Project can be specified as globs. If a glob matches any files, + // We expand it to these paths. If not, we keep the original path + // for the future resolution. + const globMatches = glob.sync(project); + return projects.concat(globMatches.length ? globMatches : project); + }, []); break; case 'moduleDirectories': case 'testMatch': diff --git a/packages/jest-config/src/read_config_file_and_set_root_dir.js b/packages/jest-config/src/read_config_file_and_set_root_dir.js new file mode 100644 index 000000000000..ddc161370b19 --- /dev/null +++ b/packages/jest-config/src/read_config_file_and_set_root_dir.js @@ -0,0 +1,60 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + */ + +import type {InitialOptions, Path} from 'types/Config'; + +import path from 'path'; +import fs from 'fs'; +import jsonlint from './vendor/jsonlint'; +import {PACKAGE_JSON} from './constants'; + +// Read the configuration and set its `rootDir` +// 1. If it's a `package.json` file, we look into its "jest" property +// 2. For any other file, we just require it. +module.exports = (configPath: Path): InitialOptions => { + const isJSON = configPath.endsWith('.json'); + let configObject; + + try { + // $FlowFixMe dynamic require + configObject = require(configPath); + } catch (error) { + if (isJSON) { + throw new Error( + `Jest: Failed to parse config file ${configPath}\n` + + ` ${jsonlint.errors(fs.readFileSync(configPath, 'utf8'))}`, + ); + } else { + throw error; + } + } + + if (configPath.endsWith(PACKAGE_JSON)) { + // Event if there's no "jest" property in package.json we will still use + // an empty object. + configObject = configObject.jest || {}; + } + + if (configObject.rootDir) { + // We don't touch it if it has an absolute path specified + if (!path.isAbsolute(configObject.rootDir)) { + // otherwise, we'll resolve it relative to the file's __dirname + configObject.rootDir = path.resolve( + path.dirname(configPath), + configObject.rootDir, + ); + } + } else { + // If rootDir is not there, we'll set it to this file's __dirname + configObject.rootDir = path.dirname(configPath); + } + + return configObject; +}; diff --git a/packages/jest-config/src/resolve_config_path.js b/packages/jest-config/src/resolve_config_path.js new file mode 100644 index 000000000000..0898ea704e98 --- /dev/null +++ b/packages/jest-config/src/resolve_config_path.js @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + */ + +import type {Path} from 'types/Config'; + +import path from 'path'; +import fs from 'fs'; +import {JEST_CONFIG, PACKAGE_JSON} from './constants'; + +const isFile = filePath => + fs.existsSync(filePath) && !fs.lstatSync(filePath).isDirectory(); + +module.exports = (pathToResolve: Path, cwd: Path): Path => { + if (!path.isAbsolute(cwd)) { + throw new Error(`"cwd" must be an absolute path. cwd: ${cwd}`); + } + const absolutePath = path.isAbsolute(pathToResolve) + ? pathToResolve + : path.resolve(cwd, pathToResolve); + + if (isFile(absolutePath)) { + return absolutePath; + } + + // This is a guard agains passing non existing path as a project/config, + // that will otherwise result in a very confusing situation. + // e.g. + // With a directory structure like this: + // my_project/ + // packcage.json + // + // Passing a `my_project/some_directory_that_doesnt_exist` as a project + // name will resolve into a (possibly empty) `my_project/package.json` and + // try to run all tests it finds under `my_project` directory. + if (!fs.existsSync(absolutePath)) { + throw new Error( + `Can't find a root directory while resolving a config file path.\n` + + `Provided path to resolve: ${pathToResolve}\n` + + `cwd: ${cwd}`, + ); + } + + return resolveConfigPathByTraversing(absolutePath, pathToResolve, cwd); +}; + +const resolveConfigPathByTraversing = ( + pathToResolve: Path, + initialPath: Path, + cwd: Path, +) => { + const jestConfig = path.resolve(pathToResolve, JEST_CONFIG); + if (isFile(jestConfig)) { + return jestConfig; + } + + const packageJson = path.resolve(pathToResolve, PACKAGE_JSON); + if (isFile(packageJson)) { + return packageJson; + } + + // This is the system root. + // We tried everything, config is nowhere to be found ¯\_(ツ)_/¯ + if (pathToResolve === path.sep) { + throw new Error(makeResolutionErrorMessage(initialPath, cwd)); + } + + // go up a level and try it again + return resolveConfigPathByTraversing( + path.dirname(pathToResolve), + initialPath, + cwd, + ); +}; + +const makeResolutionErrorMessage = (initialPath: Path, cwd: Path) => { + return ( + 'Could not find a config file based on provided values:\n' + + `path: "${initialPath}"\n` + + `cwd: "${cwd}"\n` + + 'Configh paths must be specified by either a direct path to a config\n' + + 'file, or a path to a directory. If directory is given, Jest will try to\n' + + `traverse directory tree up, until it finds either "${JEST_CONFIG}" or\n` + + `"${PACKAGE_JSON}".` + ); +}; diff --git a/packages/jest-runtime/src/__tests__/runtime_cli.test.js b/packages/jest-runtime/src/__tests__/runtime_cli.test.js index 98dcf37ef291..6bafac51a2ba 100644 --- a/packages/jest-runtime/src/__tests__/runtime_cli.test.js +++ b/packages/jest-runtime/src/__tests__/runtime_cli.test.js @@ -5,12 +5,15 @@ * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. * - * @emails oncall+jsinfra + * @flow */ 'use strict'; const path = require('path'); const {sync: spawnSync} = require('cross-spawn'); +const skipOnWindows = require('../../../../scripts/skip_on_windows'); + +skipOnWindows.suite(); const JEST_RUNTIME = path.resolve(__dirname, '../../bin/jest-runtime.js'); diff --git a/types/Config.js b/types/Config.js index f7fef7986cee..4308336aac1f 100644 --- a/types/Config.js +++ b/types/Config.js @@ -61,7 +61,7 @@ export type DefaultOptions = {| watchman: boolean, |}; -export type InitialOptions = {| +export type InitialOptions = { automock?: boolean, bail?: boolean, browser?: boolean, @@ -79,7 +79,7 @@ export type InitialOptions = {| expand?: boolean, findRelatedTests?: boolean, forceExit?: boolean, - json: boolean, + json?: boolean, globals?: ConfigGlobals, haste?: HasteConfig, reporters?: Array, @@ -99,7 +99,7 @@ export type InitialOptions = {| outputFile?: Path, preprocessorIgnorePatterns?: Array, preset?: ?string, - projects: ?Array, + projects?: Array, replname?: ?string, resetMocks?: boolean, resetModules?: boolean, @@ -110,13 +110,14 @@ export type InitialOptions = {| setupFiles?: Array, setupTestFrameworkScriptFile?: Path, silent?: boolean, - skipNodeResolution: boolean, + skipNodeResolution?: boolean, snapshotSerializers?: Array, testEnvironment?: string, testFailureExitCode?: string | number, testMatch?: Array, testNamePattern?: string, testPathIgnorePatterns?: Array, + testPathDirs?: Array, testRegex?: string, testResultsProcessor?: ?string, testRunner?: string, @@ -131,7 +132,7 @@ export type InitialOptions = {| watch?: boolean, watchAll?: boolean, watchman?: boolean, -|}; +}; export type SnapshotUpdateState = 'all' | 'new' | 'none';