diff --git a/CHANGELOG.md b/CHANGELOG.md index cca2602e30cd..5495a62f3f76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Features - `[jest-config]` Support config files exporting (`async`) `function`s ([#10001](https://github.com/facebook/jest/pull/10001)) +- `[jest-cli, jest-core]` Add `--selectProjects` CLI argument to filter test suites by project name ([#8612](https://github.com/facebook/jest/pull/8612)) ### Fixes diff --git a/docs/CLI.md b/docs/CLI.md index a6ed467c95d0..b0c989dfba67 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -254,6 +254,10 @@ Run tests with specified reporters. [Reporter options](configuration#reporters-a Alias: `-i`. Run all tests serially in the current process, rather than creating a worker pool of child processes that run tests. This can be useful for debugging. +### `--selectProjects ... ` + +Run only the tests of the specified projects. Jest uses the attribute `displayName` in the configuration to identify each project. If you use this option, you should provide a `displayName` to all your projects. + ### `--runTestsByPath` Run only the tests that were specified with their exact paths. diff --git a/e2e/__tests__/selectProjects.test.ts b/e2e/__tests__/selectProjects.test.ts new file mode 100644 index 000000000000..472221575cc7 --- /dev/null +++ b/e2e/__tests__/selectProjects.test.ts @@ -0,0 +1,189 @@ +/** + * 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 {resolve} from 'path'; + +import run, { + RunJestJsonResult, + RunJestResult, + json as runWithJson, +} from '../runJest'; + +describe('Given a config with two named projects, first-project and second-project', () => { + const dir = resolve(__dirname, '..', 'select-projects'); + + describe('when Jest is started with `--selectProjects first-project`', () => { + let result: RunJestJsonResult; + beforeAll(() => { + result = runWithJson('select-projects', [ + '--selectProjects', + 'first-project', + ]); + }); + it('runs the tests in the first project only', () => { + expect(result.json).toHaveProperty('success', true); + expect(result.json).toHaveProperty('numTotalTests', 1); + expect(result.json.testResults.map(({name}) => name)).toEqual([ + resolve(dir, '__tests__/first-project.test.js'), + ]); + }); + it('prints that only first-project will run', () => { + expect(result.stderr).toMatch(/^Running one project: first-project/); + }); + }); + + describe('when Jest is started with `--selectProjects second-project`', () => { + let result: RunJestJsonResult; + beforeAll(() => { + result = runWithJson('select-projects', [ + '--selectProjects', + 'second-project', + ]); + }); + it('runs the tests in the second project only', () => { + expect(result.json).toHaveProperty('success', true); + expect(result.json).toHaveProperty('numTotalTests', 1); + expect(result.json.testResults.map(({name}) => name)).toEqual([ + resolve(dir, '__tests__/second-project.test.js'), + ]); + }); + it('prints that only second-project will run', () => { + expect(result.stderr).toMatch(/^Running one project: second-project/); + }); + }); + + describe('when Jest is started with `--selectProjects first-project second-project`', () => { + let result: RunJestJsonResult; + beforeAll(() => { + result = runWithJson('select-projects', [ + '--selectProjects', + 'first-project', + 'second-project', + ]); + }); + it('runs the tests in the first and second projects', () => { + expect(result.json).toHaveProperty('success', true); + expect(result.json).toHaveProperty('numTotalTests', 2); + expect(result.json.testResults.map(({name}) => name).sort()).toEqual([ + resolve(dir, '__tests__/first-project.test.js'), + resolve(dir, '__tests__/second-project.test.js'), + ]); + }); + it('prints that both first-project and second-project will run', () => { + expect(result.stderr).toMatch( + /^Running 2 projects:\n- first-project\n- second-project/, + ); + }); + }); + + describe('when Jest is started without providing `--selectProjects`', () => { + let result: RunJestJsonResult; + beforeAll(() => { + result = runWithJson('select-projects', []); + }); + it('runs the tests in the first and second projects', () => { + expect(result.json).toHaveProperty('success', true); + expect(result.json).toHaveProperty('numTotalTests', 2); + expect(result.json.testResults.map(({name}) => name).sort()).toEqual([ + resolve(dir, '__tests__/first-project.test.js'), + resolve(dir, '__tests__/second-project.test.js'), + ]); + }); + it('does not print which projects are run', () => { + expect(result.stderr).not.toMatch(/^Running/); + }); + }); + + describe('when Jest is started with `--selectProjects third-project`', () => { + let result: RunJestResult; + beforeAll(() => { + result = run('select-projects', ['--selectProjects', 'third-project']); + }); + it('fails', () => { + expect(result).toHaveProperty('failed', true); + }); + it('prints that no project was found', () => { + expect(result.stdout).toMatch( + /^You provided values for --selectProjects but no projects were found matching the selection/, + ); + }); + }); +}); + +describe('Given a config with two projects, first-project and an unnamed project', () => { + const dir = resolve(__dirname, '..', 'select-projects-missing-name'); + + describe('when Jest is started with `--selectProjects first-project`', () => { + let result: RunJestJsonResult; + beforeAll(() => { + result = runWithJson('select-projects-missing-name', [ + '--selectProjects', + 'first-project', + ]); + }); + it('runs the tests in the first project only', () => { + expect(result.json.success).toBe(true); + expect(result.json.numTotalTests).toBe(1); + expect(result.json.testResults.map(({name}) => name)).toEqual([ + resolve(dir, '__tests__/first-project.test.js'), + ]); + }); + it('prints that a project does not have a name', () => { + expect(result.stderr).toMatch( + /^You provided values for --selectProjects but a project does not have a name/, + ); + }); + it('prints that only first-project will run', () => { + const stderrThirdLine = result.stderr.split('\n')[2]; + expect(stderrThirdLine).toMatch(/^Running one project: first-project/); + }); + }); + + describe('when Jest is started without providing `--selectProjects`', () => { + let result: RunJestJsonResult; + beforeAll(() => { + result = runWithJson('select-projects-missing-name', []); + }); + it('runs the tests in the first and second projects', () => { + expect(result.json.success).toBe(true); + expect(result.json.numTotalTests).toBe(2); + expect(result.json.testResults.map(({name}) => name).sort()).toEqual([ + resolve(dir, '__tests__/first-project.test.js'), + resolve(dir, '__tests__/second-project.test.js'), + ]); + }); + it('does not print that a project has no name', () => { + expect(result.stderr).not.toMatch( + /^You provided values for --selectProjects but a project does not have a name/, + ); + }); + }); + + describe('when Jest is started with `--selectProjects third-project`', () => { + let result: RunJestResult; + beforeAll(() => { + result = run('select-projects-missing-name', [ + '--selectProjects', + 'third-project', + ]); + }); + it('fails', () => { + expect(result).toHaveProperty('failed', true); + }); + it('prints that a project does not have a name', () => { + expect(result.stdout).toMatch( + /^You provided values for --selectProjects but a project does not have a name/, + ); + }); + it('prints that no project was found', () => { + const stdoutThirdLine = result.stdout.split('\n')[2]; + expect(stdoutThirdLine).toMatch( + /^You provided values for --selectProjects but no projects were found matching the selection/, + ); + }); + }); +}); diff --git a/e2e/runJest.ts b/e2e/runJest.ts index 28eda5785672..44e5e5217894 100644 --- a/e2e/runJest.ts +++ b/e2e/runJest.ts @@ -95,7 +95,7 @@ function spawnJest( export type RunJestResult = execa.ExecaReturnValue; -interface RunJestJsonResult extends RunJestResult { +export interface RunJestJsonResult extends RunJestResult { json: FormattedTestResults; } diff --git a/e2e/select-projects-missing-name/__tests__/first-project.test.js b/e2e/select-projects-missing-name/__tests__/first-project.test.js new file mode 100644 index 000000000000..f440fcd752c5 --- /dev/null +++ b/e2e/select-projects-missing-name/__tests__/first-project.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. + */ + +it('should run when first-project appears in selectProjects', () => { + expect(true).toBe(true); +}); diff --git a/e2e/select-projects-missing-name/__tests__/second-project.test.js b/e2e/select-projects-missing-name/__tests__/second-project.test.js new file mode 100644 index 000000000000..c3db24a8b9a6 --- /dev/null +++ b/e2e/select-projects-missing-name/__tests__/second-project.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. + */ + +it('should run when second-project appears in selectProjects', () => { + expect(true).toBe(true); +}); diff --git a/e2e/select-projects-missing-name/package.json b/e2e/select-projects-missing-name/package.json new file mode 100644 index 000000000000..a9743659e309 --- /dev/null +++ b/e2e/select-projects-missing-name/package.json @@ -0,0 +1,20 @@ +{ + "description": "Testing the behaviour of --selectProjects when a project does not have a name", + "jest": { + "projects": [ + { + "displayName": "first-project", + "testMatch": [ + "/__tests__/first-project.test.js" + ], + "testEnvironment": "node" + }, + { + "testMatch": [ + "/__tests__/second-project.test.js" + ], + "testEnvironment": "node" + } + ] + } +} diff --git a/e2e/select-projects/__tests__/first-project.test.js b/e2e/select-projects/__tests__/first-project.test.js new file mode 100644 index 000000000000..f440fcd752c5 --- /dev/null +++ b/e2e/select-projects/__tests__/first-project.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. + */ + +it('should run when first-project appears in selectProjects', () => { + expect(true).toBe(true); +}); diff --git a/e2e/select-projects/__tests__/second-project.test.js b/e2e/select-projects/__tests__/second-project.test.js new file mode 100644 index 000000000000..c3db24a8b9a6 --- /dev/null +++ b/e2e/select-projects/__tests__/second-project.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. + */ + +it('should run when second-project appears in selectProjects', () => { + expect(true).toBe(true); +}); diff --git a/e2e/select-projects/package.json b/e2e/select-projects/package.json new file mode 100644 index 000000000000..28e0bcc45e07 --- /dev/null +++ b/e2e/select-projects/package.json @@ -0,0 +1,24 @@ +{ + "description": "Testing the behaviour of --selectProjects", + "jest": { + "projects": [ + { + "displayName": "first-project", + "testMatch": [ + "/__tests__/first-project.test.js" + ], + "testEnvironment": "node" + }, + { + "displayName": { + "name": "second-project", + "color": "blue" + }, + "testMatch": [ + "/__tests__/second-project.test.js" + ], + "testEnvironment": "node" + } + ] + } +} diff --git a/packages/jest-cli/src/__tests__/cli/args.test.ts b/packages/jest-cli/src/__tests__/cli/args.test.ts index 8e732954b771..46ff7a7e4aed 100644 --- a/packages/jest-cli/src/__tests__/cli/args.test.ts +++ b/packages/jest-cli/src/__tests__/cli/args.test.ts @@ -72,6 +72,13 @@ describe('check', () => { }, ); + it('raises an exception if selectProjects is not provided any project names', () => { + const argv: Config.Argv = {selectProjects: []} as Config.Argv; + expect(() => check(argv)).toThrow( + 'The --selectProjects option requires the name of at least one project to be specified.\n', + ); + }); + it('raises an exception if config is not a valid JSON string', () => { const argv = {config: 'x:1'} as Config.Argv; expect(() => check(argv)).toThrow( diff --git a/packages/jest-cli/src/cli/args.ts b/packages/jest-cli/src/cli/args.ts index b368e8a5830d..f7018d51ab10 100644 --- a/packages/jest-cli/src/cli/args.ts +++ b/packages/jest-cli/src/cli/args.ts @@ -49,6 +49,13 @@ export function check(argv: Config.Argv): true { ); } + if (argv.selectProjects && argv.selectProjects.length === 0) { + throw new Error( + 'The --selectProjects option requires the name of at least one project to be specified.\n' + + 'Example usage: jest --selectProjects my-first-project my-second-project', + ); + } + if ( argv.config && !isJSONString(argv.config) && @@ -528,6 +535,13 @@ export const options = { "Allows to use a custom runner instead of Jest's default test runner.", type: 'string', }, + selectProjects: { + description: + 'Run only the tests of the specified projects.' + + 'Jest uses the attribute `displayName` in the configuration to identify each project.', + string: true, + type: 'array', + }, setupFiles: { description: 'A list of paths to modules that run some code to configure or ' + diff --git a/packages/jest-core/src/cli/index.ts b/packages/jest-core/src/cli/index.ts index a9c2946711b9..a1082d079f1c 100644 --- a/packages/jest-core/src/cli/index.ts +++ b/packages/jest-core/src/cli/index.ts @@ -26,6 +26,9 @@ import TestWatcher from '../TestWatcher'; import watch from '../watch'; import pluralize from '../pluralize'; import logDebugMessages from '../lib/log_debug_messages'; +import getConfigsOfProjectsToRun from '../getConfigsOfProjectsToRun'; +import getProjectNamesMissingWarning from '../getProjectNamesMissingWarning'; +import getSelectProjectsMessage from '../getSelectProjectsMessage'; const {print: preRunMessagePrint} = preRunMessage; @@ -68,9 +71,22 @@ export async function runCLI( exit(0); } + let configsOfProjectsToRun = configs; + if (argv.selectProjects) { + const namesMissingWarning = getProjectNamesMissingWarning(configs); + if (namesMissingWarning) { + outputStream.write(namesMissingWarning); + } + configsOfProjectsToRun = getConfigsOfProjectsToRun( + argv.selectProjects, + configs, + ); + outputStream.write(getSelectProjectsMessage(configsOfProjectsToRun)); + } + await _run10000( globalConfig, - configs, + configsOfProjectsToRun, hasDeprecationWarnings, outputStream, r => (results = r), diff --git a/packages/jest-core/src/getConfigsOfProjectsToRun.ts b/packages/jest-core/src/getConfigsOfProjectsToRun.ts new file mode 100644 index 000000000000..b8b8288ccc68 --- /dev/null +++ b/packages/jest-core/src/getConfigsOfProjectsToRun.ts @@ -0,0 +1,20 @@ +/** + * 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 getProjectDisplayName from './getProjectDisplayName'; + +export default function getConfigsOfProjectsToRun( + namesOfProjectsToRun: Array, + projectConfigs: Array, +): Array { + const setOfProjectsToRun = new Set(namesOfProjectsToRun); + return projectConfigs.filter(config => { + const name = getProjectDisplayName(config); + return name && setOfProjectsToRun.has(name); + }); +} diff --git a/packages/jest-core/src/getProjectDisplayName.ts b/packages/jest-core/src/getProjectDisplayName.ts new file mode 100644 index 000000000000..24f55d6746ea --- /dev/null +++ b/packages/jest-core/src/getProjectDisplayName.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 type {Config} from '@jest/types'; + +export default function getProjectDisplayName( + projectConfig: Config.ProjectConfig, +): string | undefined { + const {displayName} = projectConfig; + if (!displayName) { + return undefined; + } + if (typeof displayName === 'string') { + return displayName; + } + if (typeof displayName === 'object') { + return displayName.name; + } + return undefined; +} diff --git a/packages/jest-core/src/getProjectNamesMissingWarning.ts b/packages/jest-core/src/getProjectNamesMissingWarning.ts new file mode 100644 index 000000000000..53f0543d1cc8 --- /dev/null +++ b/packages/jest-core/src/getProjectNamesMissingWarning.ts @@ -0,0 +1,29 @@ +/** + * 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 chalk = require('chalk'); +import type {Config} from '@jest/types'; +import getProjectDisplayName from './getProjectDisplayName'; + +export default function getProjectNamesMissingWarning( + projectConfigs: Array, +): string | undefined { + const numberOfProjectsWithoutAName = projectConfigs.filter( + config => !getProjectDisplayName(config), + ).length; + if (numberOfProjectsWithoutAName === 0) { + return undefined; + } + return chalk.yellow( + `You provided values for --selectProjects but ${ + numberOfProjectsWithoutAName === 1 + ? 'a project does not have a name' + : `${numberOfProjectsWithoutAName} projects do not have a name` + }.\n` + + 'Set displayName in the config of all projects in order to disable this warning.\n', + ); +} diff --git a/packages/jest-core/src/getSelectProjectsMessage.ts b/packages/jest-core/src/getSelectProjectsMessage.ts new file mode 100644 index 000000000000..5d3fff3577b4 --- /dev/null +++ b/packages/jest-core/src/getSelectProjectsMessage.ts @@ -0,0 +1,47 @@ +/** + * 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 chalk = require('chalk'); +import type {Config} from '@jest/types'; +import getProjectDisplayName from './getProjectDisplayName'; + +export default function getSelectProjectsMessage( + projectConfigs: Array, +): string { + if (projectConfigs.length === 0) { + return getNoSelectionWarning(); + } + return getProjectsRunningMessage(projectConfigs); +} + +function getNoSelectionWarning(): string { + return chalk.yellow( + 'You provided values for --selectProjects but no projects were found matching the selection.\n', + ); +} + +function getProjectsRunningMessage( + projectConfigs: Array, +): string { + if (projectConfigs.length === 1) { + const name = getProjectDisplayName(projectConfigs[0]); + return `Running one project: ${chalk.bold(name)}\n`; + } + const projectsList = projectConfigs + .map(getProjectNameListElement) + .sort() + .join('\n'); + return `Running ${projectConfigs.length} projects:\n${projectsList}\n`; +} + +function getProjectNameListElement( + projectConfig: Config.ProjectConfig, +): string { + const name = getProjectDisplayName(projectConfig); + const elementContent = name ? chalk.bold(name) : ''; + return `- ${elementContent}`; +} diff --git a/packages/jest-types/src/Config.ts b/packages/jest-types/src/Config.ts index cf895c67c1a8..8bfc411710f7 100644 --- a/packages/jest-types/src/Config.ts +++ b/packages/jest-types/src/Config.ts @@ -408,6 +408,7 @@ export type Argv = Arguments< rootDir: string; roots: Array; runInBand: boolean; + selectProjects: Array; setupFiles: Array; setupFilesAfterEnv: Array; showConfig: boolean;