diff --git a/CHANGELOG.md b/CHANGELOG.md index ee46f754762b..46bf47a8d1f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### Features +- `[jest-core, jest-circus, jest-reporter, jest-runner]` Added support for reporting individual test cases using jest-circus ([#10227](https://github.com/facebook/jest/pull/10227)) - `[jest-config, jest-reporter, jest-runner, jest-test-sequencer]` Add `slowTestThreshold` configuration option ([#9366](https://github.com/facebook/jest/pull/9366)) - `[jest-worker]` Added support for workers to send custom messages to parent in jest-worker ([#10293](https://github.com/facebook/jest/pull/10293)) - `[pretty-format]` Added support for serializing custom elements (web components) ([#10217](https://github.com/facebook/jest/pull/10237)) diff --git a/packages/jest-circus/package.json b/packages/jest-circus/package.json index 5c618968dd2f..6ed60c32fcea 100644 --- a/packages/jest-circus/package.json +++ b/packages/jest-circus/package.json @@ -23,6 +23,7 @@ "jest-each": "^26.1.0", "jest-matcher-utils": "^26.1.0", "jest-message-util": "^26.1.0", + "jest-runner": "^26.1.0", "jest-runtime": "^26.1.0", "jest-snapshot": "^26.1.0", "jest-util": "^26.1.0", diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts index ff59c04981ba..e666b2230434 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapter.ts @@ -9,6 +9,7 @@ import * as path from 'path'; import type {Config} from '@jest/types'; import type {JestEnvironment} from '@jest/environment'; import type {TestResult} from '@jest/test-result'; +import type {TestFileEvent} from 'jest-runner'; import type {RuntimeType as Runtime} from 'jest-runtime'; import type {SnapshotStateType} from 'jest-snapshot'; import {deepCyclicCopy} from 'jest-util'; @@ -22,6 +23,7 @@ const jestAdapter = async ( environment: JestEnvironment, runtime: Runtime, testPath: string, + sendMessageToJest?: TestFileEvent, ): Promise => { const { initialize, @@ -46,6 +48,7 @@ const jestAdapter = async ( globalConfig, localRequire: runtime.requireModule.bind(runtime), parentProcess: process, + sendMessageToJest, testPath, }); diff --git a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts index eb0595455c4e..de8850dbee85 100644 --- a/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts +++ b/packages/jest-circus/src/legacy-code-todo-rewrite/jestAdapterInit.ts @@ -15,6 +15,7 @@ import { } from '@jest/test-result'; import {extractExpectedAssertionsErrors, getState, setState} from 'expect'; import {formatExecError, formatResultsErrors} from 'jest-message-util'; +import type {TestFileEvent} from 'jest-runner'; import { SnapshotState, SnapshotStateType, @@ -30,6 +31,7 @@ import { } from '../state'; import {getTestID} from '../utils'; import run from '../run'; +import testCaseReportHandler from '../testCaseReportHandler'; import globals from '..'; type Process = NodeJS.Process; @@ -45,6 +47,7 @@ export const initialize = async ({ localRequire, parentProcess, testPath, + sendMessageToJest, }: { config: Config.ProjectConfig; environment: JestEnvironment; @@ -54,6 +57,7 @@ export const initialize = async ({ localRequire: (path: Config.Path) => any; testPath: Config.Path; parentProcess: Process; + sendMessageToJest?: TestFileEvent; }) => { if (globalConfig.testTimeout) { getRunnerState().testTimeout = globalConfig.testTimeout; @@ -140,6 +144,9 @@ export const initialize = async ({ setState({snapshotState, testPath}); addEventHandler(handleSnapshotStateAfterRetry(snapshotState)); + if (sendMessageToJest) { + addEventHandler(testCaseReportHandler(testPath, sendMessageToJest)); + } // Return it back to the outer scope (test runner outside the VM). return {globals, snapshotState}; diff --git a/packages/jest-circus/src/testCaseReportHandler.ts b/packages/jest-circus/src/testCaseReportHandler.ts new file mode 100644 index 000000000000..4c7e7fe77609 --- /dev/null +++ b/packages/jest-circus/src/testCaseReportHandler.ts @@ -0,0 +1,26 @@ +/** + * 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 {Circus} from '@jest/types'; +import type {TestFileEvent} from 'jest-runner'; +import {makeSingleTestResult, parseSingleTestResult} from './utils'; + +const testCaseReportHandler = ( + testPath: string, + sendMessageToJest: TestFileEvent, +) => (event: Circus.Event) => { + switch (event.name) { + case 'test_done': { + const testResult = makeSingleTestResult(event.test); + const testCaseResult = parseSingleTestResult(testResult); + sendMessageToJest('test-case-result', [testPath, testCaseResult]); + break; + } + } +}; + +export default testCaseReportHandler; diff --git a/packages/jest-circus/src/utils.ts b/packages/jest-circus/src/utils.ts index ca9bebeef466..acf8c56579ec 100644 --- a/packages/jest-circus/src/utils.ts +++ b/packages/jest-circus/src/utils.ts @@ -12,7 +12,8 @@ import co from 'co'; import dedent = require('dedent'); import StackUtils = require('stack-utils'); import prettyFormat = require('pretty-format'); -import {getState} from './state'; +import type {AssertionResult, Status} from '@jest/test-result'; +import {ROOT_DESCRIBE_BLOCK_NAME, getState} from './state'; const stackUtils = new StackUtils({cwd: 'A path that does not exist'}); @@ -282,60 +283,61 @@ export const makeRunResult = ( unhandledErrors: unhandledErrors.map(_formatError), }); +export const makeSingleTestResult = ( + test: Circus.TestEntry, +): Circus.TestResult => { + const {includeTestLocationInResult} = getState(); + const testPath = []; + let parent: Circus.TestEntry | Circus.DescribeBlock | undefined = test; + + const {status} = test; + invariant(status, 'Status should be present after tests are run.'); + + do { + testPath.unshift(parent.name); + } while ((parent = parent.parent)); + + let location = null; + if (includeTestLocationInResult) { + const stackLine = test.asyncError.stack.split('\n')[1]; + const parsedLine = stackUtils.parseLine(stackLine); + if ( + parsedLine && + typeof parsedLine.column === 'number' && + typeof parsedLine.line === 'number' + ) { + location = { + column: parsedLine.column, + line: parsedLine.line, + }; + } + } + + return { + duration: test.duration, + errors: test.errors.map(_formatError), + invocations: test.invocations, + location, + status, + testPath: Array.from(testPath), + }; +}; + const makeTestResults = ( describeBlock: Circus.DescribeBlock, ): Circus.TestResults => { - const {includeTestLocationInResult} = getState(); const testResults: Circus.TestResults = []; + for (const child of describeBlock.children) { switch (child.type) { case 'describeBlock': { testResults.push(...makeTestResults(child)); break; } - case 'test': - { - const testPath = []; - let parent: - | Circus.TestEntry - | Circus.DescribeBlock - | undefined = child; - do { - testPath.unshift(parent.name); - } while ((parent = parent.parent)); - - const {status} = child; - - if (!status) { - throw new Error('Status should be present after tests are run.'); - } - - let location = null; - if (includeTestLocationInResult) { - const stackLine = child.asyncError.stack.split('\n')[1]; - const parsedLine = stackUtils.parseLine(stackLine); - if ( - parsedLine && - typeof parsedLine.column === 'number' && - typeof parsedLine.line === 'number' - ) { - location = { - column: parsedLine.column, - line: parsedLine.line, - }; - } - } - - testResults.push({ - duration: child.duration, - errors: child.errors.map(_formatError), - invocations: child.invocations, - location, - status, - testPath, - }); - } + case 'test': { + testResults.push(makeSingleTestResult(child)); break; + } } } @@ -408,3 +410,37 @@ export function invariant( throw new Error(message); } } + +export const parseSingleTestResult = ( + testResult: Circus.TestResult, +): AssertionResult => { + let status: Status; + if (testResult.status === 'skip') { + status = 'pending'; + } else if (testResult.status === 'todo') { + status = 'todo'; + } else if (testResult.errors.length > 0) { + status = 'failed'; + } else { + status = 'passed'; + } + + const ancestorTitles = testResult.testPath.filter( + name => name !== ROOT_DESCRIBE_BLOCK_NAME, + ); + const title = ancestorTitles.pop(); + + return { + ancestorTitles, + duration: testResult.duration, + failureMessages: Array.from(testResult.errors), + fullName: title + ? ancestorTitles.concat(title).join(' ') + : ancestorTitles.join(' '), + invocations: testResult.invocations, + location: testResult.location, + numPassingAsserts: 0, + status, + title: testResult.testPath[testResult.testPath.length - 1], + }; +}; diff --git a/packages/jest-circus/tsconfig.json b/packages/jest-circus/tsconfig.json index 10a995ffa2ba..cc427109b912 100644 --- a/packages/jest-circus/tsconfig.json +++ b/packages/jest-circus/tsconfig.json @@ -9,6 +9,7 @@ {"path": "../jest-environment"}, {"path": "../jest-matcher-utils"}, {"path": "../jest-message-util"}, + {"path": "../jest-runner"}, {"path": "../jest-runtime"}, {"path": "../jest-snapshot"}, {"path": "../jest-test-result"}, diff --git a/packages/jest-core/src/ReporterDispatcher.ts b/packages/jest-core/src/ReporterDispatcher.ts index e9bd47dbb319..32a358e49d5f 100644 --- a/packages/jest-core/src/ReporterDispatcher.ts +++ b/packages/jest-core/src/ReporterDispatcher.ts @@ -5,7 +5,11 @@ * LICENSE file in the root directory of this source tree. */ -import type {AggregatedResult, TestResult} from '@jest/test-result'; +import type { + AggregatedResult, + TestCaseResult, + TestResult, +} from '@jest/test-result'; import type {Test} from 'jest-runner'; import type {Context} from 'jest-runtime'; import type {Reporter, ReporterOnStartOptions} from '@jest/reporters'; @@ -27,14 +31,17 @@ export default class ReporterDispatcher { ); } - async onTestResult( + async onTestFileResult( test: Test, testResult: TestResult, results: AggregatedResult, ): Promise { for (const reporter of this._reporters) { - reporter.onTestResult && - (await reporter.onTestResult(test, testResult, results)); + if (reporter.onTestFileResult) { + await reporter.onTestFileResult(test, testResult, results); + } else if (reporter.onTestResult) { + await reporter.onTestResult(test, testResult, results); + } } // Release memory if unused later. @@ -43,9 +50,13 @@ export default class ReporterDispatcher { testResult.console = undefined; } - async onTestStart(test: Test): Promise { + async onTestFileStart(test: Test): Promise { for (const reporter of this._reporters) { - reporter.onTestStart && (await reporter.onTestStart(test)); + if (reporter.onTestFileStart) { + await reporter.onTestFileStart(test); + } else if (reporter.onTestStart) { + await reporter.onTestStart(test); + } } } @@ -58,13 +69,25 @@ export default class ReporterDispatcher { } } + async onTestCaseResult( + test: Test, + testCaseResult: TestCaseResult, + ): Promise { + for (const reporter of this._reporters) { + if (reporter.onTestCaseResult) { + await reporter.onTestCaseResult(test, testCaseResult); + } + } + } + async onRunComplete( contexts: Set, results: AggregatedResult, ): Promise { for (const reporter of this._reporters) { - reporter.onRunComplete && - (await reporter.onRunComplete(contexts, results)); + if (reporter.onRunComplete) { + await reporter.onRunComplete(contexts, results); + } } } diff --git a/packages/jest-core/src/TestScheduler.ts b/packages/jest-core/src/TestScheduler.ts index bf455a2e05c1..a1eac111ddce 100644 --- a/packages/jest-core/src/TestScheduler.ts +++ b/packages/jest-core/src/TestScheduler.ts @@ -47,10 +47,10 @@ export type TestSchedulerContext = { sourcesRelatedToTestsInChangedFiles?: Set; }; export default class TestScheduler { - private _dispatcher: ReporterDispatcher; - private _globalConfig: Config.GlobalConfig; - private _options: TestSchedulerOptions; - private _context: TestSchedulerContext; + private readonly _dispatcher: ReporterDispatcher; + private readonly _globalConfig: Config.GlobalConfig; + private readonly _options: TestSchedulerOptions; + private readonly _context: TestSchedulerContext; constructor( globalConfig: Config.GlobalConfig, @@ -76,7 +76,9 @@ export default class TestScheduler { tests: Array, watcher: TestWatcher, ): Promise { - const onStart = this._dispatcher.onTestStart.bind(this._dispatcher); + const onTestFileStart = this._dispatcher.onTestFileStart.bind( + this._dispatcher, + ); const timings: Array = []; const contexts = new Set(); tests.forEach(test => { @@ -125,7 +127,11 @@ export default class TestScheduler { } addResult(aggregatedResults, testResult); - await this._dispatcher.onTestResult(test, testResult, aggregatedResults); + await this._dispatcher.onTestFileResult( + test, + testResult, + aggregatedResults, + ); return this._bailIfNeeded(contexts, aggregatedResults, watcher); }; @@ -144,7 +150,11 @@ export default class TestScheduler { test.path, ); addResult(aggregatedResults, testResult); - await this._dispatcher.onTestResult(test, testResult, aggregatedResults); + await this._dispatcher.onTestFileResult( + test, + testResult, + aggregatedResults, + ); }; const updateSnapshotState = () => { @@ -177,14 +187,18 @@ export default class TestScheduler { }); const testRunners: {[key: string]: TestRunner} = Object.create(null); - contexts.forEach(({config}) => { + const contextsByTestRunner = new WeakMap(); + contexts.forEach(context => { + const {config} = context; if (!testRunners[config.runner]) { const Runner: typeof TestRunner = require(config.runner); - testRunners[config.runner] = new Runner(this._globalConfig, { + const runner = new Runner(this._globalConfig, { changedFiles: this._context?.changedFiles, sourcesRelatedToTestsInChangedFiles: this._context ?.sourcesRelatedToTestsInChangedFiles, }); + testRunners[config.runner] = runner; + contextsByTestRunner.set(runner, context); } }); @@ -193,16 +207,61 @@ export default class TestScheduler { if (testsByRunner) { try { for (const runner of Object.keys(testRunners)) { - await testRunners[runner].runTests( - testsByRunner[runner], - watcher, - onStart, - onResult, - onFailure, - { - serial: runInBand || Boolean(testRunners[runner].isSerial), - }, - ); + const testRunner = testRunners[runner]; + const context = contextsByTestRunner.get(testRunner); + + invariant(context); + + const tests = testsByRunner[runner]; + + const testRunnerOptions = { + serial: runInBand || Boolean(testRunner.isSerial), + }; + + /** + * Test runners with event emitters are still not supported + * for third party test runners. + */ + if (testRunner.__PRIVATE_UNSTABLE_API_supportsEventEmitters__) { + const unsubscribes = [ + testRunner.on('test-file-start', ([test]) => + onTestFileStart(test), + ), + testRunner.on('test-file-success', ([test, testResult]) => + onResult(test, testResult), + ), + testRunner.on('test-file-failure', ([test, error]) => + onFailure(test, error), + ), + testRunner.on( + 'test-case-result', + ([testPath, testCaseResult]) => { + const test: TestRunner.Test = {context, path: testPath}; + this._dispatcher.onTestCaseResult(test, testCaseResult); + }, + ), + ]; + + await testRunner.runTests( + tests, + watcher, + undefined, + undefined, + undefined, + testRunnerOptions, + ); + + unsubscribes.forEach(sub => sub()); + } else { + await testRunner.runTests( + tests, + watcher, + onTestFileStart, + onResult, + onFailure, + testRunnerOptions, + ); + } } } catch (error) { if (!watcher.isInterrupted()) { @@ -233,7 +292,7 @@ export default class TestScheduler { private _partitionTests( testRunners: Record, tests: Array, - ) { + ): Record> | null { if (Object.keys(testRunners).length > 1) { return tests.reduce((testRuns, test) => { const runner = test.context.config.runner; @@ -381,6 +440,12 @@ export default class TestScheduler { } } +function invariant(condition: unknown, message?: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + const createAggregatedResults = (numTotalTestSuites: number) => { const result = makeEmptyAggregatedTestResult(); result.numTotalTestSuites = numTotalTestSuites; diff --git a/packages/jest-reporters/src/Status.ts b/packages/jest-reporters/src/Status.ts index 95c407cb5cfa..8eb9a154a120 100644 --- a/packages/jest-reporters/src/Status.ts +++ b/packages/jest-reporters/src/Status.ts @@ -6,10 +6,14 @@ */ import type {Config} from '@jest/types'; -import type {AggregatedResult, TestResult} from '@jest/test-result'; +import type { + AggregatedResult, + TestCaseResult, + TestResult, +} from '@jest/test-result'; import chalk = require('chalk'); import stringLength = require('string-length'); -import type {ReporterOnStartOptions} from './types'; +import type {ReporterOnStartOptions, Test} from './types'; import { getSummary, printDisplayName, @@ -71,6 +75,10 @@ export default class Status { private _cache: Cache | null; private _callback?: () => void; private _currentTests: CurrentTestList; + private _currentTestCases: Array<{ + test: Test; + testCaseResult: TestCaseResult; + }>; private _done: boolean; private _emitScheduled: boolean; private _estimatedTime: number; @@ -81,6 +89,7 @@ export default class Status { constructor() { this._cache = null; this._currentTests = new CurrentTestList(); + this._currentTestCases = []; this._done = false; this._emitScheduled = false; this._estimatedTime = 0; @@ -108,6 +117,15 @@ export default class Status { this._emit(); } + addTestCaseResult(test: Test, testCaseResult: TestCaseResult): void { + this._currentTestCases.push({test, testCaseResult}); + if (!this._showStatus) { + this._emit(); + } else { + this._debouncedEmit(); + } + } + testStarted(testPath: Config.Path, config: Config.ProjectConfig): void { this._currentTests.add(testPath, config); if (!this._showStatus) { @@ -125,6 +143,12 @@ export default class Status { const {testFilePath} = testResult; this._aggregatedResults = aggregatedResults; this._currentTests.delete(testFilePath); + this._currentTestCases = this._currentTestCases.filter(({test}) => { + if (_config !== test.context.config) { + return true; + } + return test.path !== testFilePath; + }); this._debouncedEmit(); } @@ -161,6 +185,7 @@ export default class Status { content += '\n' + getSummary(this._aggregatedResults, { + currentTestCases: this._currentTestCases, estimatedTime: this._estimatedTime, roundTime: true, width, diff --git a/packages/jest-reporters/src/base_reporter.ts b/packages/jest-reporters/src/base_reporter.ts index e8ded25dd089..a40034f272b9 100644 --- a/packages/jest-reporters/src/base_reporter.ts +++ b/packages/jest-reporters/src/base_reporter.ts @@ -5,7 +5,11 @@ * LICENSE file in the root directory of this source tree. */ -import type {AggregatedResult, TestResult} from '@jest/test-result'; +import type { + AggregatedResult, + TestCaseResult, + TestResult, +} from '@jest/test-result'; import {preRunMessage} from 'jest-util'; import type {Context, Reporter, ReporterOnStartOptions, Test} from './types'; @@ -25,6 +29,8 @@ export default class BaseReporter implements Reporter { preRunMessageRemove(process.stderr); } + onTestCaseResult(_test: Test, _testCaseResult: TestCaseResult): void {} + onTestResult( _test?: Test, _testResult?: TestResult, diff --git a/packages/jest-reporters/src/default_reporter.ts b/packages/jest-reporters/src/default_reporter.ts index 4f0046b2111f..e39238841c58 100644 --- a/packages/jest-reporters/src/default_reporter.ts +++ b/packages/jest-reporters/src/default_reporter.ts @@ -6,7 +6,12 @@ */ import type {Config} from '@jest/types'; -import type {AggregatedResult, TestResult} from '@jest/test-result'; +import type { + AggregatedResult, + TestCaseResult, + TestResult, +} from '@jest/test-result'; + import {clearLine, isInteractive} from 'jest-util'; import {getConsoleOutput} from '@jest/console'; import chalk = require('chalk'); @@ -130,6 +135,10 @@ export default class DefaultReporter extends BaseReporter { this._status.testStarted(test.path, test.context.config); } + onTestCaseResult(test: Test, testCaseResult: TestCaseResult): void { + this._status.addTestCaseResult(test, testCaseResult); + } + onRunComplete(): void { this.forceFlushBufferedOutput(); this._status.runFinished(); diff --git a/packages/jest-reporters/src/types.ts b/packages/jest-reporters/src/types.ts index 1b13136f055b..62dede0afed8 100644 --- a/packages/jest-reporters/src/types.ts +++ b/packages/jest-reporters/src/types.ts @@ -9,6 +9,7 @@ import type {Config} from '@jest/types'; import type { AggregatedResult, SerializableError, + TestCaseResult, TestResult, } from '@jest/test-result'; import type {FS as HasteFS, ModuleMap} from 'jest-haste-map'; @@ -53,16 +54,26 @@ export type OnTestFailure = ( export type OnTestSuccess = (test: Test, result: TestResult) => Promise; export interface Reporter { - readonly onTestResult: ( + readonly onTestResult?: ( test: Test, testResult: TestResult, aggregatedResult: AggregatedResult, ) => Promise | void; + readonly onTestFileResult?: ( + test: Test, + testResult: TestResult, + aggregatedResult: AggregatedResult, + ) => Promise | void; + readonly onTestCaseResult?: ( + test: Test, + testCaseResult: TestCaseResult, + ) => Promise | void; readonly onRunStart: ( results: AggregatedResult, options: ReporterOnStartOptions, ) => Promise | void; - readonly onTestStart: (test: Test) => Promise | void; + readonly onTestStart?: (test: Test) => Promise | void; + readonly onTestFileStart?: (test: Test) => Promise | void; readonly onRunComplete: ( contexts: Set, results: AggregatedResult, @@ -71,6 +82,7 @@ export interface Reporter { } export type SummaryOptions = { + currentTestCases?: Array<{test: Test; testCaseResult: TestCaseResult}>; estimatedTime?: number; roundTime?: boolean; width?: number; diff --git a/packages/jest-reporters/src/utils.ts b/packages/jest-reporters/src/utils.ts index 752e7c9f60f2..956a1de4a063 100644 --- a/packages/jest-reporters/src/utils.ts +++ b/packages/jest-reporters/src/utils.ts @@ -7,11 +7,11 @@ import * as path from 'path'; import type {Config} from '@jest/types'; -import type {AggregatedResult} from '@jest/test-result'; +import type {AggregatedResult, TestCaseResult} from '@jest/test-result'; import chalk = require('chalk'); import slash = require('slash'); import {formatTime, pluralize} from 'jest-util'; -import type {SummaryOptions} from './types'; +import type {SummaryOptions, Test} from './types'; const PROGRESS_BAR_WIDTH = 40; @@ -90,6 +90,45 @@ export const relativePath = ( return {basename, dirname}; }; +const getValuesCurrentTestCases = ( + currentTestCases: Array<{test: Test; testCaseResult: TestCaseResult}> = [], +) => { + 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, + }; +}; + export const getSummary = ( aggregatedResults: AggregatedResult, options?: SummaryOptions, @@ -99,6 +138,10 @@ export const getSummary = ( runTime = Math.floor(runTime); } + const valuesForCurrentTestCases = getValuesCurrentTestCases( + options?.currentTestCases, + ); + const estimatedTime = (options && options.estimatedTime) || 0; const snapshotResults = aggregatedResults.snapshot; const snapshotsAdded = snapshotResults.added; @@ -133,13 +176,31 @@ export const getSummary = ( : 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: ') + - (testsFailed ? chalk.bold.red(`${testsFailed} failed`) + ', ' : '') + - (testsPending ? chalk.bold.yellow(`${testsPending} skipped`) + ', ' : '') + - (testsTodo ? chalk.bold.magenta(`${testsTodo} todo`) + ', ' : '') + - (testsPassed ? chalk.bold.green(`${testsPassed} passed`) + ', ' : '') + - `${testsTotal} total`; + (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: ') + diff --git a/packages/jest-runner/package.json b/packages/jest-runner/package.json index 9a1b9cf755dc..fa1a5b9a32e3 100644 --- a/packages/jest-runner/package.json +++ b/packages/jest-runner/package.json @@ -16,12 +16,12 @@ "@jest/types": "^26.1.0", "@types/node": "*", "chalk": "^4.0.0", + "emittery": "^0.7.1", "exit": "^0.1.2", "graceful-fs": "^4.2.4", "jest-config": "^26.1.0", "jest-docblock": "^26.0.0", "jest-haste-map": "^26.1.0", - "jest-jasmine2": "^26.1.0", "jest-leak-detector": "^26.1.0", "jest-message-util": "^26.1.0", "jest-resolve": "^26.1.0", diff --git a/packages/jest-runner/src/__tests__/testRunner.test.ts b/packages/jest-runner/src/__tests__/testRunner.test.ts index d602238a1a3d..df266697abaf 100644 --- a/packages/jest-runner/src/__tests__/testRunner.test.ts +++ b/packages/jest-runner/src/__tests__/testRunner.test.ts @@ -41,9 +41,9 @@ test('injects the serializable module map into each worker in watch mode', async {context, path: './file2.test.js'}, ], new TestWatcher({isWatchMode: globalConfig.watch}), - () => {}, - () => {}, - () => {}, + undefined, + undefined, + undefined, {serial: false}, ); @@ -75,9 +75,9 @@ test('assign process.env.JEST_WORKER_ID = 1 when in runInBand mode', async () => await new TestRunner(globalConfig).runTests( [{context, path: './file.test.js'}], new TestWatcher({isWatchMode: globalConfig.watch}), - () => {}, - () => {}, - () => {}, + undefined, + undefined, + undefined, {serial: true}, ); diff --git a/packages/jest-runner/src/index.ts b/packages/jest-runner/src/index.ts index 0f1d069c6af0..abdba02c39aa 100644 --- a/packages/jest-runner/src/index.ts +++ b/packages/jest-runner/src/index.ts @@ -6,11 +6,13 @@ */ import type {Config} from '@jest/types'; -import type {SerializableError} from '@jest/test-result'; +import type {SerializableError, TestResult} from '@jest/test-result'; import exit = require('exit'); import chalk = require('chalk'); +import Emittery = require('emittery'); import throat from 'throat'; -import Worker from 'jest-worker'; +import Worker, {PromiseWithCustomMessage} from 'jest-worker'; +import {deepCyclicCopy} from 'jest-util'; import runTest from './runTest'; import type {SerializableResolver, worker} from './testWorker'; import type { @@ -18,6 +20,8 @@ import type { OnTestStart as JestOnTestStart, OnTestSuccess as JestOnTestSuccess, Test as JestTest, + TestEvents as JestTestEvents, + TestFileEvent as JestTestFileEvent, TestRunnerContext as JestTestRunnerContext, TestRunnerOptions as JestTestRunnerOptions, TestWatcher as JestTestWatcher, @@ -38,12 +42,16 @@ namespace TestRunner { export type TestWatcher = JestTestWatcher; export type TestRunnerContext = JestTestRunnerContext; export type TestRunnerOptions = JestTestRunnerOptions; + export type TestFileEvent = JestTestFileEvent; } /* eslint-disable-next-line no-redeclare */ class TestRunner { - private _globalConfig: Config.GlobalConfig; - private _context: JestTestRunnerContext; + private readonly _globalConfig: Config.GlobalConfig; + private readonly _context: JestTestRunnerContext; + private readonly eventEmitter = new Emittery.Typed(); + readonly __PRIVATE_UNSTABLE_API_supportsEventEmitters__: boolean = true; + readonly isSerial?: boolean; constructor( @@ -57,9 +65,9 @@ class TestRunner { async runTests( tests: Array, watcher: JestTestWatcher, - onStart: JestOnTestStart, - onResult: JestOnTestSuccess, - onFailure: JestOnTestFailure, + onStart: JestOnTestStart | undefined, + onResult: JestOnTestSuccess | undefined, + onFailure: JestOnTestFailure | undefined, options: JestTestRunnerOptions, ): Promise { return await (options.serial @@ -76,9 +84,9 @@ class TestRunner { private async _createInBandTestRun( tests: Array, watcher: JestTestWatcher, - onStart: JestOnTestStart, - onResult: JestOnTestSuccess, - onFailure: JestOnTestFailure, + onStart?: JestOnTestStart, + onResult?: JestOnTestSuccess, + onFailure?: JestOnTestFailure, ) { process.env.JEST_WORKER_ID = '1'; const mutex = throat(1); @@ -90,18 +98,55 @@ class TestRunner { if (watcher.isInterrupted()) { throw new CancelRun(); } + let sendMessageToJest: JestTestFileEvent; + + // Remove `if(onStart)` in Jest 27 + if (onStart) { + await onStart(test); + return runTest( + test.path, + this._globalConfig, + test.context.config, + test.context.resolver, + this._context, + undefined, + ); + } else { + // `deepCyclicCopy` used here to avoid mem-leak + sendMessageToJest = (eventName, args) => + this.eventEmitter.emit( + eventName, + deepCyclicCopy(args, {keepPrototype: false}), + ); - await onStart(test); - return runTest( - test.path, - this._globalConfig, - test.context.config, - test.context.resolver, - this._context, - ); + await this.eventEmitter.emit('test-file-start', [test]); + return runTest( + test.path, + this._globalConfig, + test.context.config, + test.context.resolver, + this._context, + sendMessageToJest, + ); + } }) - .then(result => onResult(test, result)) - .catch(err => onFailure(test, err)), + .then(result => { + if (onResult) { + return onResult(test, result); + } else { + return this.eventEmitter.emit('test-file-success', [ + test, + result, + ]); + } + }) + .catch(err => { + if (onFailure) { + return onFailure(test, err); + } else { + return this.eventEmitter.emit('test-file-failure', [test, err]); + } + }), ), Promise.resolve(), ); @@ -110,9 +155,9 @@ class TestRunner { private async _createParallelTestRun( tests: Array, watcher: JestTestWatcher, - onStart: JestOnTestStart, - onResult: JestOnTestSuccess, - onFailure: JestOnTestFailure, + onStart?: JestOnTestStart, + onResult?: JestOnTestSuccess, + onFailure?: JestOnTestFailure, ) { const resolvers: Map = new Map(); for (const test of tests) { @@ -149,9 +194,14 @@ class TestRunner { return Promise.reject(); } - await onStart(test); + // Remove `if(onStart)` in Jest 27 + if (onStart) { + await onStart(test); + } else { + await this.eventEmitter.emit('test-file-start', [test]); + } - return worker.worker({ + const promise = worker.worker({ config: test.context.config, context: { ...this._context, @@ -164,11 +214,25 @@ class TestRunner { }, globalConfig: this._globalConfig, path: test.path, - }); + }) as PromiseWithCustomMessage; + + if (promise.UNSTABLE_onCustomMessage) { + // TODO: Get appropriate type for `onCustomMessage` + promise.UNSTABLE_onCustomMessage(([event, payload]: any) => { + this.eventEmitter.emit(event, payload); + }); + } + + return promise; }); const onError = async (err: SerializableError, test: JestTest) => { - await onFailure(test, err); + // Remove `if(onFailure)` in Jest 27 + if (onFailure) { + await onFailure(test, err); + } else { + await this.eventEmitter.emit('test-file-failure', [test, err]); + } if (err.type === 'ProcessTerminatedError') { console.error( 'A worker process has quit unexpectedly! ' + @@ -189,7 +253,16 @@ class TestRunner { const runAllTests = Promise.all( tests.map(test => runTestInWorker(test) - .then(testResult => onResult(test, testResult)) + .then(result => { + if (onResult) { + return onResult(test, result); + } else { + return this.eventEmitter.emit('test-file-success', [ + test, + result, + ]); + } + }) .catch(error => onError(error, test)), ), ); @@ -208,6 +281,8 @@ class TestRunner { }; return Promise.race([runAllTests, onInterrupt]).then(cleanup, cleanup); } + + on = this.eventEmitter.on.bind(this.eventEmitter); } class CancelRun extends Error { diff --git a/packages/jest-runner/src/runTest.ts b/packages/jest-runner/src/runTest.ts index 4fe7bcb0c995..e1745f1e8917 100644 --- a/packages/jest-runner/src/runTest.ts +++ b/packages/jest-runner/src/runTest.ts @@ -27,7 +27,7 @@ import * as docblock from 'jest-docblock'; import {formatExecError} from 'jest-message-util'; import sourcemapSupport = require('source-map-support'); import chalk = require('chalk'); -import type {TestFramework, TestRunnerContext} from './types'; +import type {TestFileEvent, TestFramework, TestRunnerContext} from './types'; type RunTestInternalResult = { leakDetector: LeakDetector | null; @@ -81,6 +81,7 @@ async function runTestInternal( config: Config.ProjectConfig, resolver: ResolverType, context?: TestRunnerContext, + sendMessageToJest?: TestFileEvent, ): Promise { const testSource = fs.readFileSync(path, 'utf8'); const docblockPragmas = docblock.parse(docblock.extract(testSource)); @@ -246,6 +247,7 @@ async function runTestInternal( environment, runtime, path, + sendMessageToJest, ); } catch (err) { // Access stack before uninstalling sourcemaps @@ -320,6 +322,7 @@ export default async function runTest( config: Config.ProjectConfig, resolver: ResolverType, context?: TestRunnerContext, + sendMessageToJest?: TestFileEvent, ): Promise { const {leakDetector, result} = await runTestInternal( path, @@ -327,6 +330,7 @@ export default async function runTest( config, resolver, context, + sendMessageToJest, ); if (leakDetector) { diff --git a/packages/jest-runner/src/testWorker.ts b/packages/jest-runner/src/testWorker.ts index 8e91bc12c2ce..10e2e3e0b318 100644 --- a/packages/jest-runner/src/testWorker.ts +++ b/packages/jest-runner/src/testWorker.ts @@ -13,7 +13,13 @@ import exit = require('exit'); import {separateMessageFromStack} from 'jest-message-util'; import Runtime = require('jest-runtime'); import type {ResolverType} from 'jest-resolve'; -import type {ErrorWithCode, TestRunnerSerializedContext} from './types'; +import {messageParent} from 'jest-worker'; +import type { + ErrorWithCode, + TestFileEvent, + TestRunnerSerializedContext, +} from './types'; + import runTest from './runTest'; export type SerializableResolver = { @@ -74,6 +80,10 @@ export function setup(setupData: { } } +const sendMessageToJest: TestFileEvent = (eventName, args) => { + messageParent([eventName, args]); +}; + export async function worker({ config, globalConfig, @@ -93,6 +103,7 @@ export async function worker({ context.sourcesRelatedToTestsInChangedFiles && new Set(context.sourcesRelatedToTestsInChangedFiles), }, + sendMessageToJest, ); } catch (error) { throw formatError(error); diff --git a/packages/jest-runner/src/types.ts b/packages/jest-runner/src/types.ts index 69d9525ff73c..4b20d260e910 100644 --- a/packages/jest-runner/src/types.ts +++ b/packages/jest-runner/src/types.ts @@ -7,7 +7,11 @@ import type {EventEmitter} from 'events'; import type {Config} from '@jest/types'; -import type {SerializableError, TestResult} from '@jest/test-result'; +import type { + AssertionResult, + SerializableError, + TestResult, +} from '@jest/test-result'; import type {JestEnvironment} from '@jest/environment'; import type {FS as HasteFS, ModuleMap} from 'jest-haste-map'; import type {ResolverType} from 'jest-resolve'; @@ -37,12 +41,26 @@ export type OnTestSuccess = ( testResult: TestResult, ) => Promise; +// Typings for `sendMessageToJest` events +export type TestEvents = { + 'test-file-start': [Test]; + 'test-file-success': [Test, TestResult]; + 'test-file-failure': [Test, SerializableError]; + 'test-case-result': [Config.Path, AssertionResult]; +}; + +export type TestFileEvent = ( + eventName: T, + args: TestEvents[T], +) => unknown; + export type TestFramework = ( globalConfig: Config.GlobalConfig, config: Config.ProjectConfig, environment: JestEnvironment, runtime: RuntimeType, testPath: string, + sendMessageToJest?: TestFileEvent, ) => Promise; export type TestRunnerOptions = { diff --git a/packages/jest-test-result/src/index.ts b/packages/jest-test-result/src/index.ts index d6b85ee6ab59..6f43aba84ce3 100644 --- a/packages/jest-test-result/src/index.ts +++ b/packages/jest-test-result/src/index.ts @@ -24,5 +24,6 @@ export type { Status, Suite, TestResult, + TestCaseResult, V8CoverageResult, } from './types'; diff --git a/packages/jest-test-result/src/types.ts b/packages/jest-test-result/src/types.ts index a38e3f287db8..24f351feccf4 100644 --- a/packages/jest-test-result/src/types.ts +++ b/packages/jest-test-result/src/types.ts @@ -78,6 +78,8 @@ export type Suite = { tests: Array; }; +export type TestCaseResult = AssertionResult; + export type TestResult = { console?: ConsoleBuffer; coverage?: CoverageMapData; diff --git a/packages/jest-worker/src/Farm.ts b/packages/jest-worker/src/Farm.ts index e06df7d7c2fd..151f2783f5a5 100644 --- a/packages/jest-worker/src/Farm.ts +++ b/packages/jest-worker/src/Farm.ts @@ -48,7 +48,7 @@ export default class Farm { doWork( method: string, - ...args: Array + ...args: Array ): PromiseWithCustomMessage { const customMessageListeners = new Set(); diff --git a/packages/jest-worker/src/types.ts b/packages/jest-worker/src/types.ts index 08e992f1172b..23421d6a8306 100644 --- a/packages/jest-worker/src/types.ts +++ b/packages/jest-worker/src/types.ts @@ -162,7 +162,7 @@ export type ParentMessage = export type OnStart = (worker: WorkerInterface) => void; export type OnEnd = (err: Error | null, result: unknown) => void; -export type OnCustomMessage = (message: unknown) => void; +export type OnCustomMessage = (message: Array | unknown) => void; export type QueueChildMessage = { request: ChildMessage; diff --git a/yarn.lock b/yarn.lock index 4569c6275937..317fba10e69d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7224,6 +7224,13 @@ __metadata: languageName: node linkType: hard +"emittery@npm:^0.7.1": + version: 0.7.1 + resolution: "emittery@npm:0.7.1" + checksum: 917b0995126e004ddf175e7d0a74ae8608083846c3f3608e964bf13caba220a003b7455ced5bf813a40e977605be48e53c74f6150fbe587a47ef6b985b8a447e + languageName: node + linkType: hard + "emoji-regex@npm:>=6.0.0 <=6.1.1": version: 6.1.1 resolution: "emoji-regex@npm:6.1.1" @@ -11055,6 +11062,7 @@ fsevents@^1.2.7: jest-each: ^26.1.0 jest-matcher-utils: ^26.1.0 jest-message-util: ^26.1.0 + jest-runner: ^26.1.0 jest-runtime: ^26.1.0 jest-snapshot: ^26.1.0 jest-util: ^26.1.0 @@ -11453,13 +11461,13 @@ fsevents@^1.2.7: "@types/node": "*" "@types/source-map-support": ^0.5.0 chalk: ^4.0.0 + emittery: ^0.7.1 exit: ^0.1.2 graceful-fs: ^4.2.4 jest-circus: ^26.1.0 jest-config: ^26.1.0 jest-docblock: ^26.0.0 jest-haste-map: ^26.1.0 - jest-jasmine2: ^26.1.0 jest-leak-detector: ^26.1.0 jest-message-util: ^26.1.0 jest-resolve: ^26.1.0