diff --git a/docs/ProgrammaticApi.md b/docs/ProgrammaticApi.md new file mode 100644 index 000000000000..17760f8e7045 --- /dev/null +++ b/docs/ProgrammaticApi.md @@ -0,0 +1,125 @@ +--- +id: programmatic-api +title: Programmatic API +--- + +:::caution + +The programmatic API is currently **experimental**. It is useful for advanced use cases only. You normally don't need to use it if you just want to run your tests. + +::: + +This page documents Jest's programmable API that can be used to run jest from `node`. TypeScript types are provided. + +## Simple example + +```js +import {createJest} from 'jest'; + +const jest = await createJest(); +jest.globalConfig = { + collectCoverage: false, + watch: false, + ...jest.globalConfig, +}; +const {results} = await jest.run(); +console.log(`run success, ${result.numPassedTests} passed tests.`); +``` + +## Programmatic API reference + +### `createJest(args: Partial = {}, projectPath = ['.']): Promise` \[function] + +Create a Jest instance asynchronously. You can provide command line arguments (for example, `process.argv`) as first argument and a list of custom [projects](./Configuration.md#projects-arraystring--projectconfig) as the second argument. If no `projects`, were configured, the current project will be provided as project config. + +Examples: + +```js +import {createJest} from 'jest'; + +const jest = await createJest(); +const jest2 = await createJest({config: 'jest.alternative.config.js'}); +``` + +### `jest.globalConfig` \[Readonly\] + +The global config associated with this jest instance. It is `readonly`, so it cannot be changed in-place. In order to change it, you will need to create a new object. + +Example: + +```js +jest.globalConfig = { + ...jest.globalConfig, + collectCoverage: false, + watch: false, +}; +``` + +### `jest.projectConfigs` \[Readonly\\[]] + +A list of project configurations associated with this jest instance. They are `readonly`, so it cannot be changed in-place. In order to change it, you will need to create a new object. + +```js +jest.projectConfigs = jest.projectConfigs.map(config => ({ + ...config, + setupFiles: ['custom-setup.js', ...config.setupFiles], +})); +``` + +### `jest.run` \[function] + +Async function that performs the run. It returns a promise that resolves in a `JestRunResult` object. This object has a `results` property that contains the actual results. + +## Advanced use cases + +These are more advanced use cases that demonstrate the power of the api. + +### Overriding config options + +You can use `createJest` to create a Jest instance, and alter some of the options using `globalConfig` adn `projectConfigs`. + +```js +import {createJest} from 'jest'; +const jest = await createJest(); + +// Override global options +jest.globalConfig = { + ...jest.globalConfig, + collectCoverage: false, + reporters: [], + testResultsProcessor: undefined, + watch: false, + testPathPattern: 'my-test.spec.js', +}; + +// Override project options +jest.projectConfigs = jest.projectConfigs.map(config => ({ + ...config, + setupFiles: ['custom-setup.js', ...config.setupFiles], +})); + +// Run +const {results} = await jest.run(); +console.log(`run success, ${results.numPassedTests} passed tests.`); +``` + +### Override options based on the configured options + +You might want to override options based on other options. For example, you might want to provide your own version of the `jsdom` or `node` test environment. + +```js +import {createJest} from 'jest'; + +const jest = await createJest(); + +jest.projectConfigs = [ + { + ...jest.projectConfigs[0], + // Change the test environment based on the configured test environment + testEnvironment: overrideTestEnvironment(configs[0].testEnvironment), + }, +]; + +const {results} = await jest.run(); +console.log(results); +``` diff --git a/e2e/__tests__/__snapshots__/runProgrammatically.test.ts.snap b/e2e/__tests__/__snapshots__/runProgrammatically.test.ts.snap new file mode 100644 index 000000000000..1c2ee60beb86 --- /dev/null +++ b/e2e/__tests__/__snapshots__/runProgrammatically.test.ts.snap @@ -0,0 +1,11 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`createJest run programmatically: stdout 1`] = `"run success, 1 passed tests."`; + +exports[`createJest run programmatically: summary 1`] = ` +"Test Suites: 1 passed, 1 total +Tests: 1 passed, 1 total +Snapshots: 0 total +Time: <> +Ran all test suites." +`; diff --git a/e2e/__tests__/__snapshots__/runProgrammaticallyMultipleProjects.test.ts.snap b/e2e/__tests__/__snapshots__/runProgrammaticallyMultipleProjects.test.ts.snap index 9ffad72f23c0..c3701bc37252 100644 --- a/e2e/__tests__/__snapshots__/runProgrammaticallyMultipleProjects.test.ts.snap +++ b/e2e/__tests__/__snapshots__/runProgrammaticallyMultipleProjects.test.ts.snap @@ -1,6 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`run programmatically with multiple projects: summary 1`] = ` +exports[`run jest programmatically with multiple projects: summary 1`] = ` +"Test Suites: 2 passed, 2 total +Tests: 2 passed, 2 total +Snapshots: 0 total +Time: <> +Ran all test suites in 2 projects." +`; + +exports[`runCLI programmatically with multiple projects: summary 1`] = ` "Test Suites: 2 passed, 2 total Tests: 2 passed, 2 total Snapshots: 0 total diff --git a/e2e/__tests__/runProgrammatically.test.ts b/e2e/__tests__/runProgrammatically.test.ts index 217cdf1fab19..b767bccc1b9e 100644 --- a/e2e/__tests__/runProgrammatically.test.ts +++ b/e2e/__tests__/runProgrammatically.test.ts @@ -6,7 +6,8 @@ */ import {resolve} from 'path'; -import {run} from '../Utils'; +import stripAnsi = require('strip-ansi'); +import {extractSummary, run} from '../Utils'; const dir = resolve(__dirname, '..', 'run-programmatically'); @@ -19,3 +20,11 @@ test('run Jest programmatically esm', () => { const {stdout} = run('node index.js --version', dir); expect(stdout).toMatch(/\d{2}\.\d{1,2}\.\d{1,2}[-\S]*-dev$/); }); + +test('createJest run programmatically', () => { + const {stderr, stdout} = run('node jest.mjs', dir); + const {summary} = extractSummary(stripAnsi(stderr)); + + expect(summary).toMatchSnapshot('summary'); + expect(stdout).toMatchSnapshot('stdout'); +}); diff --git a/e2e/__tests__/runProgrammaticallyMultipleProjects.test.ts b/e2e/__tests__/runProgrammaticallyMultipleProjects.test.ts index 84b9951ecbc3..ebfa82dc4eec 100644 --- a/e2e/__tests__/runProgrammaticallyMultipleProjects.test.ts +++ b/e2e/__tests__/runProgrammaticallyMultipleProjects.test.ts @@ -11,7 +11,14 @@ import {extractSummary, run} from '../Utils'; const dir = resolve(__dirname, '../run-programmatically-multiple-projects'); -test('run programmatically with multiple projects', () => { +test('runCLI programmatically with multiple projects', () => { + const {stderr, exitCode} = run('node run-cli.js', dir); + const {summary} = extractSummary(stripAnsi(stderr)); + expect(exitCode).toBe(0); + expect(summary).toMatchSnapshot('summary'); +}); + +test('run jest programmatically with multiple projects', () => { const {stderr, exitCode} = run('node run-jest.js', dir); const {summary} = extractSummary(stripAnsi(stderr)); expect(exitCode).toBe(0); diff --git a/e2e/run-programmatically-multiple-projects/client/client.test.js b/e2e/run-programmatically-multiple-projects/client/client.test.js index ba0a17690228..63e5f8bc93c6 100644 --- a/e2e/run-programmatically-multiple-projects/client/client.test.js +++ b/e2e/run-programmatically-multiple-projects/client/client.test.js @@ -6,5 +6,7 @@ */ describe('client', () => { - it('should work', () => {}); + it('should work', () => { + expect(typeof document).toBe('object'); + }); }); diff --git a/e2e/run-programmatically-multiple-projects/client/jest.config.json b/e2e/run-programmatically-multiple-projects/client/jest.config.json new file mode 100644 index 000000000000..0436fa4fdfdd --- /dev/null +++ b/e2e/run-programmatically-multiple-projects/client/jest.config.json @@ -0,0 +1,4 @@ +{ + "testMatch": ["**/*.test.js"], + "testEnvironment": "jsdom" +} diff --git a/e2e/run-programmatically-multiple-projects/package.json b/e2e/run-programmatically-multiple-projects/package.json index 142909ad236a..104bbe68161b 100644 --- a/e2e/run-programmatically-multiple-projects/package.json +++ b/e2e/run-programmatically-multiple-projects/package.json @@ -2,5 +2,11 @@ "name": "runcli-multiple-projects", "version": "1.0.0", "dependencies": {}, - "jest": {} + "jest": { + "maxWorkers": 1, + "projects": [ + "/server", + "/client" + ] + } } diff --git a/e2e/run-programmatically-multiple-projects/run-cli.js b/e2e/run-programmatically-multiple-projects/run-cli.js new file mode 100644 index 000000000000..e5979dc80323 --- /dev/null +++ b/e2e/run-programmatically-multiple-projects/run-cli.js @@ -0,0 +1,24 @@ +/** + * 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. + */ + +const {runCLI} = require('jest'); + +const config = { + projects: [ + {testEnvironment: 'jsdom', testMatch: ['/client/**/*.test.js']}, + {testEnvironment: 'node', testMatch: ['/server/**/*.test.js']}, + ], +}; + +runCLI({config: JSON.stringify(config)}, [process.cwd()]) + .then(() => + console.log('run-programmatically-cli-multiple-projects completed'), + ) + .catch(err => { + console.error(err); + process.exitCode = 1; + }); diff --git a/e2e/run-programmatically-multiple-projects/run-jest.js b/e2e/run-programmatically-multiple-projects/run-jest.js index 7fff6f8ddc32..4a2beb3478dd 100644 --- a/e2e/run-programmatically-multiple-projects/run-jest.js +++ b/e2e/run-programmatically-multiple-projects/run-jest.js @@ -5,18 +5,20 @@ * LICENSE file in the root directory of this source tree. */ -const {runCLI} = require('@jest/core'); +const {createJest} = require('jest'); -const config = { - projects: [ - {testMatch: ['/client/**/*.test.js']}, - {testMatch: ['/server/**/*.test.js']}, - ], -}; +async function main() { + const jest = await createJest(); + jest.globalConfig = { + collectCoverage: false, + watch: false, + ...jest.globalConfig, + }; + await jest.run(); + console.log('run-programmatically-core-multiple-projects completed'); +} -runCLI({config: JSON.stringify(config)}, [process.cwd()]) - .then(() => console.log('run-programmatically-mutiple-projects completed')) - .catch(err => { - console.error(err); - process.exitCode = 1; - }); +main().catch(err => { + console.error(err); + process.exitCode = 1; +}); diff --git a/e2e/run-programmatically-multiple-projects/server/jest.config.json b/e2e/run-programmatically-multiple-projects/server/jest.config.json new file mode 100644 index 000000000000..873a47b09a4b --- /dev/null +++ b/e2e/run-programmatically-multiple-projects/server/jest.config.json @@ -0,0 +1,4 @@ +{ + "testMatch": ["**/*.test.js"], + "testEnvironment": "node" +} diff --git a/e2e/run-programmatically-multiple-projects/server/server.test.js b/e2e/run-programmatically-multiple-projects/server/server.test.js index a7b65f086540..cb92c746acd3 100644 --- a/e2e/run-programmatically-multiple-projects/server/server.test.js +++ b/e2e/run-programmatically-multiple-projects/server/server.test.js @@ -6,5 +6,7 @@ */ describe('server', () => { - it('should work', () => {}); + it('should work', () => { + expect(typeof document).toBe('undefined'); + }); }); diff --git a/e2e/run-programmatically/jest.mjs b/e2e/run-programmatically/jest.mjs new file mode 100644 index 000000000000..0d19cbca4b16 --- /dev/null +++ b/e2e/run-programmatically/jest.mjs @@ -0,0 +1,16 @@ +/** + * 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. + */ +import {createJest} from 'jest'; + +const jest = await createJest(); +jest.globalConfig = { + collectCoverage: false, + watch: false, + ...jest.globalConfig, +}; +const {results} = await jest.run(); +console.log(`run success, ${results.numPassedTests} passed tests.`); diff --git a/e2e/run-programmatically/src/app.spec.js b/e2e/run-programmatically/src/app.spec.js new file mode 100644 index 000000000000..08e8c9d2b086 --- /dev/null +++ b/e2e/run-programmatically/src/app.spec.js @@ -0,0 +1,11 @@ +/** + * 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. + */ +/* eslint-disable no-undef */ + +describe('jest-core', () => { + it('should run this test', () => {}); +}); diff --git a/packages/jest-core/src/__tests__/jest.test.ts b/packages/jest-core/src/__tests__/jest.test.ts new file mode 100644 index 000000000000..91c2b4ef2d1c --- /dev/null +++ b/packages/jest-core/src/__tests__/jest.test.ts @@ -0,0 +1,101 @@ +/** + * 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. + */ + +import {makeGlobalConfig, makeProjectConfig} from '@jest/test-utils'; +import type {Config} from '@jest/types'; +import * as jestConfig from 'jest-config'; +import * as jestUtil from 'jest-util'; +import {Jest, createJest} from '../jest'; +import runJest from '../runJest'; +import watch from '../watch'; + +jest.mock('jest-runtime', () => ({ + createHasteMap: () => ({ + build: jest.fn(), + }), +})); +jest.mock('../lib/createContext', () => jest.fn()); +jest.mock('../runJest', () => + jest.fn(({onComplete}) => { + onComplete({results: {success: true}}); + }), +); +jest.mock('../watch', () => jest.fn()); +jest.mock('jest-util', () => { + const original = jest.requireActual('jest-util'); + + return { + ...original, + createDirectory: jest.fn(), + }; +}); +jest.mock('jest-config', () => ({ + readConfigs: jest.fn(async () => { + const {makeProjectConfig, makeGlobalConfig} = await import( + '@jest/test-utils' + ); + return { + configs: [makeProjectConfig()], + globalConfig: makeGlobalConfig(), + }; + }), +})); + +describe(Jest, () => { + let globalConfig: Config.GlobalConfig; + beforeEach(() => { + globalConfig = makeGlobalConfig(); + jest.spyOn(jestUtil, 'createDirectory').mockReturnValue(); + jest.spyOn(jestConfig, 'readConfigs').mockReturnValue( + Promise.resolve({ + configs: [makeProjectConfig()], + globalConfig, + hasDeprecationWarnings: false, + }), + ); + }); + + describe(Jest.createJest, () => { + it('should provide default values when no args are passed', async () => { + await Jest.createJest(); + expect(jestConfig.readConfigs).toHaveBeenCalledWith( + expect.objectContaining({ + $0: 'programmatic', + _: [], + }), + ['.'], + ); + }); + }); + + describe(Jest.prototype.run, () => { + it('should run once and provide the result', async () => { + const jestInstance = await createJest(); + const actualResults = await jestInstance.run(); + expect(jest.mocked(runJest)).toHaveBeenCalled(); + expect(actualResults).toEqual({results: {results: {success: true}}}); + }); + + it('should provide stderr as output stream when useStderr is true', async () => { + globalConfig.useStderr = true; + const jestInstance = await createJest(); + + await jestInstance.run(); + + expect(jest.mocked(runJest)).toHaveBeenCalledWith( + expect.objectContaining({outputStream: process.stderr}), + ); + }); + + it('should not watch when watch is false', async () => { + globalConfig.watch = false; + const jestInstance = await createJest(); + await jestInstance.run(); + expect(jest.mocked(watch)).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/jest-core/src/cli/index.ts b/packages/jest-core/src/cli/index.ts index 962296c2b058..8ee4a057a221 100644 --- a/packages/jest-core/src/cli/index.ts +++ b/packages/jest-core/src/cli/index.ts @@ -6,34 +6,19 @@ */ import {performance} from 'perf_hooks'; -import type {WriteStream} from 'tty'; import chalk = require('chalk'); import exit = require('exit'); import * as fs from 'graceful-fs'; -import {CustomConsole} from '@jest/console'; -import type {AggregatedResult, TestContext} from '@jest/test-result'; +import type {AggregatedResult} from '@jest/test-result'; import type {Config} from '@jest/types'; -import type {ChangedFilesPromise} from 'jest-changed-files'; import {readConfigs} from 'jest-config'; -import type {IHasteMap} from 'jest-haste-map'; -import Runtime from 'jest-runtime'; -import {createDirectory, pluralize, preRunMessage} from 'jest-util'; -import {TestWatcher} from 'jest-watcher'; +import {pluralize} from 'jest-util'; import {formatHandleErrors} from '../collectHandles'; -import getChangedFilesPromise from '../getChangedFilesPromise'; import getConfigsOfProjectsToRun from '../getConfigsOfProjectsToRun'; import getProjectNamesMissingWarning from '../getProjectNamesMissingWarning'; import getSelectProjectsMessage from '../getSelectProjectsMessage'; -import createContext from '../lib/createContext'; -import handleDeprecationWarnings from '../lib/handleDeprecationWarnings'; +import {run} from '../jest'; import logDebugMessages from '../lib/logDebugMessages'; -import runJest from '../runJest'; -import type {Filter} from '../types'; -import watch from '../watch'; - -const {print: preRunMessagePrint} = preRunMessage; - -type OnCompleteCallback = (results: AggregatedResult) => void | undefined; export async function runCLI( argv: Config.Argv, @@ -43,7 +28,6 @@ export async function runCLI( globalConfig: Config.GlobalConfig; }> { performance.mark('jest/runCLI:start'); - let results: AggregatedResult | undefined; // If we output a JSON object, we can't write anything to stdout, since // it'll break the JSON structure and it won't be valid. @@ -97,24 +81,13 @@ export async function runCLI( ); } - await _run10000( + const results = await run( globalConfig, configsOfProjectsToRun, hasDeprecationWarnings, outputStream, - r => { - results = r; - }, ); - if (argv.watch || argv.watchAll) { - // If in watch mode, return the promise that will never resolve. - // If the watch mode is interrupted, watch should handle the process - // shutdown. - // eslint-disable-next-line @typescript-eslint/no-empty-function - return new Promise(() => {}); - } - if (!results) { throw new Error( 'AggregatedResult must be present after test run is complete', @@ -139,170 +112,3 @@ export async function runCLI( performance.mark('jest/runCLI:end'); return {globalConfig, results}; } - -const buildContextsAndHasteMaps = async ( - configs: Array, - globalConfig: Config.GlobalConfig, - outputStream: WriteStream, -) => { - const hasteMapInstances = Array(configs.length); - const contexts = await Promise.all( - configs.map(async (config, index) => { - createDirectory(config.cacheDirectory); - const hasteMapInstance = await Runtime.createHasteMap(config, { - console: new CustomConsole(outputStream, outputStream), - maxWorkers: Math.max( - 1, - Math.floor(globalConfig.maxWorkers / configs.length), - ), - resetCache: !config.cache, - watch: globalConfig.watch || globalConfig.watchAll, - watchman: globalConfig.watchman, - workerThreads: globalConfig.workerThreads, - }); - hasteMapInstances[index] = hasteMapInstance; - return createContext(config, await hasteMapInstance.build()); - }), - ); - - return {contexts, hasteMapInstances}; -}; - -const _run10000 = async ( - globalConfig: Config.GlobalConfig, - configs: Array, - hasDeprecationWarnings: boolean, - outputStream: WriteStream, - onComplete: OnCompleteCallback, -) => { - // Queries to hg/git can take a while, so we need to start the process - // as soon as possible, so by the time we need the result it's already there. - const changedFilesPromise = getChangedFilesPromise(globalConfig, configs); - if (changedFilesPromise) { - performance.mark('jest/getChangedFiles:start'); - changedFilesPromise.finally(() => { - performance.mark('jest/getChangedFiles:end'); - }); - } - - // Filter may need to do an HTTP call or something similar to setup. - // We will wait on an async response from this before using the filter. - let filter: Filter | undefined; - if (globalConfig.filter && !globalConfig.skipFilter) { - const rawFilter = require(globalConfig.filter); - let filterSetupPromise: Promise | undefined; - if (rawFilter.setup) { - // Wrap filter setup Promise to avoid "uncaught Promise" error. - // If an error is returned, we surface it in the return value. - filterSetupPromise = (async () => { - try { - await rawFilter.setup(); - } catch (err) { - return err; - } - return undefined; - })(); - } - filter = async (testPaths: Array) => { - if (filterSetupPromise) { - // Expect an undefined return value unless there was an error. - const err = await filterSetupPromise; - if (err) { - throw err; - } - } - return rawFilter(testPaths); - }; - } - - performance.mark('jest/buildContextsAndHasteMaps:start'); - const {contexts, hasteMapInstances} = await buildContextsAndHasteMaps( - configs, - globalConfig, - outputStream, - ); - performance.mark('jest/buildContextsAndHasteMaps:end'); - - globalConfig.watch || globalConfig.watchAll - ? await runWatch( - contexts, - configs, - hasDeprecationWarnings, - globalConfig, - outputStream, - hasteMapInstances, - filter, - ) - : await runWithoutWatch( - globalConfig, - contexts, - outputStream, - onComplete, - changedFilesPromise, - filter, - ); -}; - -const runWatch = async ( - contexts: Array, - _configs: Array, - hasDeprecationWarnings: boolean, - globalConfig: Config.GlobalConfig, - outputStream: WriteStream, - hasteMapInstances: Array, - filter?: Filter, -) => { - if (hasDeprecationWarnings) { - try { - await handleDeprecationWarnings(outputStream, process.stdin); - return await watch( - globalConfig, - contexts, - outputStream, - hasteMapInstances, - undefined, - undefined, - filter, - ); - } catch { - exit(0); - } - } - - return watch( - globalConfig, - contexts, - outputStream, - hasteMapInstances, - undefined, - undefined, - filter, - ); -}; - -const runWithoutWatch = async ( - globalConfig: Config.GlobalConfig, - contexts: Array, - outputStream: WriteStream, - onComplete: OnCompleteCallback, - changedFilesPromise?: ChangedFilesPromise, - filter?: Filter, -) => { - const startRun = async (): Promise => { - if (!globalConfig.listTests) { - preRunMessagePrint(outputStream); - } - return runJest({ - changedFilesPromise, - contexts, - failedTestsCache: undefined, - filter, - globalConfig, - onComplete, - outputStream, - startRun, - testWatcher: new TestWatcher({isWatchMode: false}), - }); - }; - return startRun(); -}; diff --git a/packages/jest-core/src/index.ts b/packages/jest-core/src/index.ts index df1ea6070487..a78ba08386a5 100644 --- a/packages/jest-core/src/index.ts +++ b/packages/jest-core/src/index.ts @@ -8,4 +8,6 @@ export {default as SearchSource} from './SearchSource'; export {createTestScheduler} from './TestScheduler'; export {runCLI} from './cli'; +export {Jest, createJest} from './jest'; export {default as getVersion} from './version'; +export {readConfigs, readInitialOptions} from 'jest-config'; diff --git a/packages/jest-core/src/jest.ts b/packages/jest-core/src/jest.ts new file mode 100644 index 000000000000..3e11f12eec2f --- /dev/null +++ b/packages/jest-core/src/jest.ts @@ -0,0 +1,254 @@ +/** + * 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. + */ + +import {performance} from 'perf_hooks'; +import type {WriteStream} from 'tty'; +import exit = require('exit'); +import {CustomConsole} from '@jest/console'; +import type {AggregatedResult, TestContext} from '@jest/test-result'; +import type {Config} from '@jest/types'; +import type {ChangedFilesPromise} from 'jest-changed-files'; +import {readConfigs} from 'jest-config'; +import type {IHasteMap} from 'jest-haste-map'; +import Runtime from 'jest-runtime'; +import {createDirectory, preRunMessage} from 'jest-util'; +import {TestWatcher} from 'jest-watcher'; +import getChangedFilesPromise from './getChangedFilesPromise'; +import createContext from './lib/createContext'; +import handleDeprecationWarnings from './lib/handleDeprecationWarnings'; +import runJest from './runJest'; +import type {Filter, JestRunResult, OnCompleteCallback} from './types'; +import watch from './watch'; + +const {print: preRunMessagePrint} = preRunMessage; + +/** + * `Jest` class as a convenient API for programmatic use. + */ +export class Jest { + private constructor( + public globalConfig: Readonly, + public projectConfigs: Array>, + ) {} + + public static async createJest( + args: Partial = {}, + projectPaths = ['.'], + ): Promise { + const {globalConfig, configs} = await readConfigs( + {$0: 'programmatic', _: [], ...args}, + projectPaths, + ); + return new Jest(globalConfig, configs); + } + + /** + * Runs Jest either in watch mode or as a one-off. This is a lower-level API than `runCLI` and is intended for internal use by `runCLI` or externally. + * Note that `process.exit` might be called when using `globalConfig.watch`, `globalConfig.watchAll` or `globalConfig.bail` are set. + * + * @param globalConfig The global configuration to use for this run. + * @param projectConfigs The project configurations to run. + * @returns A Promise that resolves to the result, or never resolves when `globalConfig.watch` or `globalConfig.watchAll` are set. + * @example + * import {createJest} from 'jest'; + * + * const jest = await createJest(); + * const {results} = await jest.run(); + * console.log(results); + */ + async run(): Promise { + const outputStream = this.globalConfig.useStderr + ? process.stderr + : process.stdout; + const results = await run( + this.globalConfig, + this.projectConfigs, + false, + outputStream, + ); + return {results}; + } +} + +export const createJest = Jest.createJest; + +const buildContextsAndHasteMaps = async ( + configs: Array, + globalConfig: Config.GlobalConfig, + outputStream: WriteStream, +) => { + const hasteMapInstances = Array(configs.length); + const contexts = await Promise.all( + configs.map(async (config, index) => { + createDirectory(config.cacheDirectory); + const hasteMapInstance = await Runtime.createHasteMap(config, { + console: new CustomConsole(outputStream, outputStream), + maxWorkers: Math.max( + 1, + Math.floor(globalConfig.maxWorkers / configs.length), + ), + resetCache: !config.cache, + watch: globalConfig.watch || globalConfig.watchAll, + watchman: globalConfig.watchman, + workerThreads: globalConfig.workerThreads, + }); + hasteMapInstances[index] = hasteMapInstance; + return createContext(config, await hasteMapInstance.build()); + }), + ); + + return {contexts, hasteMapInstances}; +}; + +export const run = async ( + globalConfig: Config.GlobalConfig, + configs: Array, + hasDeprecationWarnings: boolean, + outputStream: WriteStream, +): Promise => { + // Queries to hg/git can take a while, so we need to start the process + // as soon as possible, so by the time we need the result it's already there. + const changedFilesPromise = getChangedFilesPromise(globalConfig, configs); + if (changedFilesPromise) { + performance.mark('jest/getChangedFiles:start'); + changedFilesPromise.finally(() => { + performance.mark('jest/getChangedFiles:end'); + }); + } + + // Filter may need to do an HTTP call or something similar to setup. + // We will wait on an async response from this before using the filter. + let filter: Filter | undefined; + if (globalConfig.filter && !globalConfig.skipFilter) { + const rawFilter = require(globalConfig.filter); + let filterSetupPromise: Promise | undefined; + if (rawFilter.setup) { + // Wrap filter setup Promise to avoid "uncaught Promise" error. + // If an error is returned, we surface it in the return value. + filterSetupPromise = (async () => { + try { + await rawFilter.setup(); + } catch (err) { + return err; + } + return undefined; + })(); + } + filter = async (testPaths: Array) => { + if (filterSetupPromise) { + // Expect an undefined return value unless there was an error. + const err = await filterSetupPromise; + if (err) { + throw err; + } + } + return rawFilter(testPaths); + }; + } + + performance.mark('jest/buildContextsAndHasteMaps:start'); + const {contexts, hasteMapInstances} = await buildContextsAndHasteMaps( + configs, + globalConfig, + outputStream, + ); + performance.mark('jest/buildContextsAndHasteMaps:end'); + + if (globalConfig.watch || globalConfig.watchAll) { + await runWatch( + contexts, + configs, + hasDeprecationWarnings, + globalConfig, + outputStream, + hasteMapInstances, + filter, + ); + // If in watch mode, return the promise that will never resolve. + // If the watch mode is interrupted, watch should handle the process + // shutdown. + // eslint-disable-next-line @typescript-eslint/no-empty-function + return new Promise(() => {}); + } else { + let result: AggregatedResult; + await runWithoutWatch( + globalConfig, + contexts, + outputStream, + r => { + result = r; + }, + changedFilesPromise, + filter, + ); + return result!; + } +}; + +const runWatch = async ( + contexts: Array, + _configs: Array, + hasDeprecationWarnings: boolean, + globalConfig: Config.GlobalConfig, + outputStream: WriteStream, + hasteMapInstances: Array, + filter?: Filter, +) => { + if (hasDeprecationWarnings) { + try { + await handleDeprecationWarnings(outputStream, process.stdin); + return await watch( + globalConfig, + contexts, + outputStream, + hasteMapInstances, + undefined, + undefined, + filter, + ); + } catch { + exit(0); + } + } + + return watch( + globalConfig, + contexts, + outputStream, + hasteMapInstances, + undefined, + undefined, + filter, + ); +}; + +const runWithoutWatch = async ( + globalConfig: Config.GlobalConfig, + contexts: Array, + outputStream: WriteStream, + onComplete: OnCompleteCallback, + changedFilesPromise?: ChangedFilesPromise, + filter?: Filter, +) => { + const startRun = async (): Promise => { + if (!globalConfig.listTests) { + preRunMessagePrint(outputStream); + } + return runJest({ + changedFilesPromise, + contexts, + failedTestsCache: undefined, + filter, + globalConfig, + onComplete, + outputStream, + startRun, + testWatcher: new TestWatcher({isWatchMode: false}), + }); + }; + return startRun(); +}; diff --git a/packages/jest-core/src/types.ts b/packages/jest-core/src/types.ts index 3aaece51f060..101264a3bd7a 100644 --- a/packages/jest-core/src/types.ts +++ b/packages/jest-core/src/types.ts @@ -5,7 +5,7 @@ * LICENSE file in the root directory of this source tree. */ -import type {Test, TestContext} from '@jest/test-result'; +import type {AggregatedResult, Test, TestContext} from '@jest/test-result'; export type Stats = { roots: number; @@ -42,3 +42,14 @@ export type FilterResult = { export type Filter = (testPaths: Array) => Promise<{ filtered: Array; }>; + +export type OnCompleteCallback = ( + results: AggregatedResult, +) => void | undefined; + +/** + * The result of running runCore. + */ +export type JestRunResult = { + results: AggregatedResult; +}; diff --git a/packages/jest/src/index.ts b/packages/jest/src/index.ts index 0391990e7846..c373b57c7a86 100644 --- a/packages/jest/src/index.ts +++ b/packages/jest/src/index.ts @@ -12,6 +12,8 @@ export { createTestScheduler, getVersion, runCLI, + Jest, + createJest, } from '@jest/core'; export {run} from 'jest-cli'; diff --git a/website/sidebars.json b/website/sidebars.json index 72f8506cbd5d..91b46c5ea1f2 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -45,6 +45,7 @@ "configuration", "cli", "environment-variables", - "code-transformation" + "code-transformation", + "programmatic-api" ] }