diff --git a/packages/jest/executors.json b/packages/jest/executors.json index 32ee933857..170432ffef 100644 --- a/packages/jest/executors.json +++ b/packages/jest/executors.json @@ -9,6 +9,7 @@ "executors": { "jest": { "implementation": "./src/executors/jest/jest.impl", + "batchImplementation": "./src/executors/jest/jest.impl#batchJest", "schema": "./src/executors/jest/schema.json", "description": "Run Jest unit tests" } diff --git a/packages/jest/package.json b/packages/jest/package.json index a8be879fd8..9b41b291f7 100644 --- a/packages/jest/package.json +++ b/packages/jest/package.json @@ -34,8 +34,13 @@ "dependencies": { "@nrwl/devkit": "*", "identity-obj-proxy": "3.0.0", - "jest-resolve": "^26.6.2", + "jest-resolve": "27.0.6", "rxjs": "^6.5.4", - "tslib": "^2.0.0" + "tslib": "^2.0.0", + "@jest/reporters": "27.0.6", + "@jest/test-result": "27.0.6", + "chalk": "4.1.0", + "jest-config": "27.0.6", + "jest-util": "27.0.6" } } diff --git a/packages/jest/src/executors/jest/jest.impl.ts b/packages/jest/src/executors/jest/jest.impl.ts index 1971d0bad7..ae9cc7fd52 100644 --- a/packages/jest/src/executors/jest/jest.impl.ts +++ b/packages/jest/src/executors/jest/jest.impl.ts @@ -1,8 +1,13 @@ import { runCLI } from 'jest'; +import { readConfig } from 'jest-config'; +import { utils as jestReporterUtils } from '@jest/reporters'; +import { makeEmptyAggregatedTestResult, addResult } from '@jest/test-result'; import * as path from 'path'; import { JestExecutorOptions } from './schema'; import { Config } from '@jest/types'; -import { ExecutorContext } from '@nrwl/devkit'; +import { ExecutorContext, TaskGraph } from '@nrwl/devkit'; +import { join } from 'path'; +import { getSummary } from './summary'; try { require('dotenv').config(); @@ -27,16 +32,22 @@ export async function jestExecutor( export function jestConfigParser( options: JestExecutorOptions, - context: ExecutorContext + context: ExecutorContext, + multiProjects = false ): Config.Argv { - options.jestConfig = path.resolve(context.root, options.jestConfig); + let jestConfig: + | { + transform: any; + globals: any; + setupFilesAfterEnv: any; + } + | undefined; - const jestConfig: { - transform: any; - globals: any; - setupFilesAfterEnv: any; - // eslint-disable-next-line @typescript-eslint/no-var-requires - } = require(options.jestConfig); + if (!multiProjects) { + options.jestConfig = path.resolve(context.root, options.jestConfig); + + jestConfig = require(options.jestConfig); + } const config: Config.Argv = { $0: undefined, @@ -69,7 +80,7 @@ export function jestConfigParser( }; // for backwards compatibility - if (options.setupFile) { + if (options.setupFile && !multiProjects) { const setupFilesAfterEnvSet = new Set([ ...(jestConfig.setupFilesAfterEnv ?? []), path.resolve(context.root, options.setupFile), @@ -115,3 +126,58 @@ export function jestConfigParser( } export default jestExecutor; + +export async function batchJest( + taskGraph: TaskGraph, + inputs: Record, + overrides: JestExecutorOptions, + context: ExecutorContext +): Promise> { + const configPaths = taskGraph.roots.map((root) => + path.resolve(context.root, inputs[root].jestConfig) + ); + + const { globalConfig, results } = await runCLI( + jestConfigParser(overrides, context, true), + [...configPaths] + ); + + const jestTaskExecutionResults: Record< + string, + { success: boolean; terminalOutput: string } + > = {}; + + const configs = await Promise.all( + configPaths.map(async (path) => readConfig({ $0: '', _: undefined }, path)) + ); + + for (let i = 0; i < taskGraph.roots.length; i++) { + let root = taskGraph.roots[i]; + const aggregatedResults = makeEmptyAggregatedTestResult(); + aggregatedResults.startTime = results.startTime; + + const projectRoot = join(context.root, taskGraph.tasks[root].projectRoot); + + let resultOutput = ''; + for (const testResult of results.testResults) { + if (testResult.testFilePath.startsWith(projectRoot)) { + addResult(aggregatedResults, testResult); + resultOutput += + '\n\r' + + jestReporterUtils.getResultHeader( + testResult, + globalConfig, + configs[i].projectConfig + ); + } + } + aggregatedResults.numTotalTestSuites = aggregatedResults.testResults.length; + + jestTaskExecutionResults[root] = { + success: aggregatedResults.numFailedTests === 0, + terminalOutput: resultOutput + '\n\r\n\r' + getSummary(aggregatedResults), + }; + } + + return jestTaskExecutionResults; +} diff --git a/packages/jest/src/executors/jest/summary.ts b/packages/jest/src/executors/jest/summary.ts new file mode 100644 index 0000000000..99adf121f4 --- /dev/null +++ b/packages/jest/src/executors/jest/summary.ts @@ -0,0 +1,190 @@ +import { AggregatedResult } from '@jest/reporters'; +import { pluralize, formatTime } from 'jest-util'; +import * as chalk from 'chalk'; + +/** + * Copied from the jest repo because these utility functions are not exposed through the package + * https://github.com/facebook/jest/blob/7a64ede2163eba4ecc725f448cd92102cd8c14aa/packages/jest-reporters/src/utils.ts + */ + +const PROGRESS_BAR_WIDTH = 40; + +const getValuesCurrentTestCases = (currentTestCases = []) => { + let numFailingTests = 0; + let numPassingTests = 0; + let numPendingTests = 0; + let numTodoTests = 0; + let numTotalTests = 0; + currentTestCases.forEach((testCase) => { + switch (testCase.testCaseResult.status) { + case 'failed': { + numFailingTests++; + break; + } + case 'passed': { + numPassingTests++; + break; + } + case 'skipped': { + numPendingTests++; + break; + } + case 'todo': { + numTodoTests++; + break; + } + } + numTotalTests++; + }); + + return { + numFailingTests, + numPassingTests, + numPendingTests, + numTodoTests, + numTotalTests, + }; +}; + +const renderTime = (runTime: number, estimatedTime: number, width: number) => { + // If we are more than one second over the estimated time, highlight it. + const renderedTime = + estimatedTime && runTime >= estimatedTime + 1 + ? chalk.bold.yellow(formatTime(runTime, 0)) + : formatTime(runTime, 0); + let time = chalk.bold(`Time:`) + ` ${renderedTime}`; + if (runTime < estimatedTime) { + time += `, estimated ${formatTime(estimatedTime, 0)}`; + } + + // Only show a progress bar if the test run is actually going to take + // some time. + if (estimatedTime > 2 && runTime < estimatedTime && width) { + const availableWidth = Math.min(PROGRESS_BAR_WIDTH, width); + const length = Math.min( + Math.floor((runTime / estimatedTime) * availableWidth), + availableWidth + ); + if (availableWidth >= 2) { + time += + '\n' + + chalk.green('█').repeat(length) + + chalk.white('█').repeat(availableWidth - length); + } + } + return time; +}; + +export const getSummary = ( + aggregatedResults: AggregatedResult, + options?: { + currentTestCases?: any; + estimatedTime?: number; + roundTime?: boolean; + width?: number; + } +): string => { + let runTime = (Date.now() - aggregatedResults.startTime) / 1000; + if (options && options.roundTime) { + runTime = Math.floor(runTime); + } + + const valuesForCurrentTestCases = getValuesCurrentTestCases( + options?.currentTestCases + ); + + const estimatedTime = (options && options.estimatedTime) || 0; + const snapshotResults = aggregatedResults.snapshot; + const snapshotsAdded = snapshotResults.added; + const snapshotsFailed = snapshotResults.unmatched; + const snapshotsOutdated = snapshotResults.unchecked; + const snapshotsFilesRemoved = snapshotResults.filesRemoved; + const snapshotsDidUpdate = snapshotResults.didUpdate; + const snapshotsPassed = snapshotResults.matched; + const snapshotsTotal = snapshotResults.total; + const snapshotsUpdated = snapshotResults.updated; + const suitesFailed = aggregatedResults.numFailedTestSuites; + const suitesPassed = aggregatedResults.numPassedTestSuites; + const suitesPending = aggregatedResults.numPendingTestSuites; + const suitesRun = suitesFailed + suitesPassed; + const suitesTotal = aggregatedResults.numTotalTestSuites; + const testsFailed = aggregatedResults.numFailedTests; + const testsPassed = aggregatedResults.numPassedTests; + const testsPending = aggregatedResults.numPendingTests; + const testsTodo = aggregatedResults.numTodoTests; + const testsTotal = aggregatedResults.numTotalTests; + const width = (options && options.width) || 0; + + const suites = + chalk.bold('Test Suites: ') + + (suitesFailed ? chalk.bold.red(`${suitesFailed} failed`) + ', ' : '') + + (suitesPending + ? chalk.bold.yellow(`${suitesPending} skipped`) + ', ' + : '') + + (suitesPassed ? chalk.bold.green(`${suitesPassed} passed`) + ', ' : '') + + (suitesRun !== suitesTotal + ? suitesRun + ' of ' + suitesTotal + : suitesTotal) + + ` total`; + + const updatedTestsFailed = + testsFailed + valuesForCurrentTestCases.numFailingTests; + const updatedTestsPending = + testsPending + valuesForCurrentTestCases.numPendingTests; + const updatedTestsTodo = testsTodo + valuesForCurrentTestCases.numTodoTests; + const updatedTestsPassed = + testsPassed + valuesForCurrentTestCases.numPassingTests; + const updatedTestsTotal = + testsTotal + valuesForCurrentTestCases.numTotalTests; + + const tests = + chalk.bold('Tests: ') + + (updatedTestsFailed > 0 + ? chalk.bold.red(`${updatedTestsFailed} failed`) + ', ' + : '') + + (updatedTestsPending > 0 + ? chalk.bold.yellow(`${updatedTestsPending} skipped`) + ', ' + : '') + + (updatedTestsTodo > 0 + ? chalk.bold.magenta(`${updatedTestsTodo} todo`) + ', ' + : '') + + (updatedTestsPassed > 0 + ? chalk.bold.green(`${updatedTestsPassed} passed`) + ', ' + : '') + + `${updatedTestsTotal} total`; + + const snapshots = + chalk.bold('Snapshots: ') + + (snapshotsFailed + ? chalk.bold.red(`${snapshotsFailed} failed`) + ', ' + : '') + + (snapshotsOutdated && !snapshotsDidUpdate + ? chalk.bold.yellow(`${snapshotsOutdated} obsolete`) + ', ' + : '') + + (snapshotsOutdated && snapshotsDidUpdate + ? chalk.bold.green(`${snapshotsOutdated} removed`) + ', ' + : '') + + (snapshotsFilesRemoved && !snapshotsDidUpdate + ? chalk.bold.yellow( + pluralize('file', snapshotsFilesRemoved) + ' obsolete' + ) + ', ' + : '') + + (snapshotsFilesRemoved && snapshotsDidUpdate + ? chalk.bold.green( + pluralize('file', snapshotsFilesRemoved) + ' removed' + ) + ', ' + : '') + + (snapshotsUpdated + ? chalk.bold.green(`${snapshotsUpdated} updated`) + ', ' + : '') + + (snapshotsAdded + ? chalk.bold.green(`${snapshotsAdded} written`) + ', ' + : '') + + (snapshotsPassed + ? chalk.bold.green(`${snapshotsPassed} passed`) + ', ' + : '') + + `${snapshotsTotal} total`; + + const time = renderTime(runTime, estimatedTime, width); + return [suites, tests, snapshots, time].join('\n'); +};