diff --git a/CHANGELOG.md b/CHANGELOG.md index bf101e20789a..024f5b662f56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - `[jest-config, jest-runtime]` Support ESM for files other than `.js` and `.mjs` ([#10823](https://github.com/facebook/jest/pull/10823)) - `[jest-config, jest-runtime]` [**BREAKING**] Use "modern" implementation as default for fake timers ([#10874](https://github.com/facebook/jest/pull/10874)) - `[jest-core]` make `TestWatcher` extend `emittery` ([#10324](https://github.com/facebook/jest/pull/10324)) +- `[jest-core]` Run failed tests interactively the same way we do with snapshots ([#10858](https://github.com/facebook/jest/pull/10858)) - `[jest-core]` more `TestSequencer` methods can be async ([#10980](https://github.com/facebook/jest/pull/10980)) - `[jest-haste-map]` Handle injected scm clocks ([#10966](https://github.com/facebook/jest/pull/10966)) - `[jest-repl, jest-runner]` [**BREAKING**] Run transforms over environment ([#8751](https://github.com/facebook/jest/pull/8751)) diff --git a/packages/jest-core/src/FailedTestsInteractiveMode.ts b/packages/jest-core/src/FailedTestsInteractiveMode.ts new file mode 100644 index 000000000000..7fe7d8d681f2 --- /dev/null +++ b/packages/jest-core/src/FailedTestsInteractiveMode.ts @@ -0,0 +1,195 @@ +/** + * 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 ansiEscapes = require('ansi-escapes'); +import chalk = require('chalk'); +import type {AggregatedResult, AssertionLocation} from '@jest/test-result'; +import {pluralize, specialChars} from 'jest-util'; +import {KEYS} from 'jest-watcher'; + +type RunnerUpdateFunction = (failure?: AssertionLocation) => void; + +const {ARROW, CLEAR} = specialChars; + +function describeKey(key: string, description: string) { + return `${chalk.dim(ARROW + 'Press')} ${key} ${chalk.dim(description)}`; +} + +const TestProgressLabel = chalk.bold('Interactive Test Progress'); + +export default class FailedTestsInteractiveMode { + private _isActive = false; + private _countPaths = 0; + private _skippedNum = 0; + private _testAssertions: Array = []; + private _updateTestRunnerConfig?: RunnerUpdateFunction; + + constructor(private _pipe: NodeJS.WritableStream) {} + + isActive(): boolean { + return this._isActive; + } + + put(key: string): void { + switch (key) { + case 's': + if (this._skippedNum === this._testAssertions.length) { + break; + } + + this._skippedNum += 1; + // move skipped test to the end + this._testAssertions.push(this._testAssertions.shift()!); + if (this._testAssertions.length - this._skippedNum > 0) { + this._run(); + } else { + this._drawUIDoneWithSkipped(); + } + + break; + case 'q': + case KEYS.ESCAPE: + this.abort(); + break; + case 'r': + this.restart(); + break; + case KEYS.ENTER: + if (this._testAssertions.length === 0) { + this.abort(); + } else { + this._run(); + } + break; + default: + } + } + + run( + failedTestAssertions: Array, + updateConfig: RunnerUpdateFunction, + ): void { + if (failedTestAssertions.length === 0) return; + + this._testAssertions = [...failedTestAssertions]; + this._countPaths = this._testAssertions.length; + this._updateTestRunnerConfig = updateConfig; + this._isActive = true; + this._run(); + } + + updateWithResults(results: AggregatedResult): void { + if (!results.snapshot.failure && results.numFailedTests > 0) { + return this._drawUIOverlay(); + } + + this._testAssertions.shift(); + if (this._testAssertions.length === 0) { + return this._drawUIOverlay(); + } + + // Go to the next test + return this._run(); + } + + private _clearTestSummary() { + this._pipe.write(ansiEscapes.cursorUp(6)); + this._pipe.write(ansiEscapes.eraseDown); + } + + private _drawUIDone() { + this._pipe.write(CLEAR); + + const messages: Array = [ + chalk.bold('Watch Usage'), + describeKey('Enter', 'to return to watch mode.'), + ]; + + this._pipe.write(messages.join('\n') + '\n'); + } + + private _drawUIDoneWithSkipped() { + this._pipe.write(CLEAR); + + let stats = `${pluralize('test', this._countPaths)} reviewed`; + + if (this._skippedNum > 0) { + const skippedText = chalk.bold.yellow( + pluralize('test', this._skippedNum) + ' skipped', + ); + + stats = `${stats}, ${skippedText}`; + } + + const message = [ + TestProgressLabel, + `${ARROW}${stats}`, + '\n', + chalk.bold('Watch Usage'), + describeKey('r', 'to restart Interactive Mode.'), + describeKey('q', 'to quit Interactive Mode.'), + describeKey('Enter', 'to return to watch mode.'), + ]; + + this._pipe.write(`\n${message.join('\n')}`); + } + + private _drawUIProgress() { + this._clearTestSummary(); + + const numPass = this._countPaths - this._testAssertions.length; + const numRemaining = this._countPaths - numPass - this._skippedNum; + let stats = `${pluralize('test', numRemaining)} remaining`; + + if (this._skippedNum > 0) { + const skippedText = chalk.bold.yellow( + pluralize('test', this._skippedNum) + ' skipped', + ); + + stats = `${stats}, ${skippedText}`; + } + + const message = [ + TestProgressLabel, + `${ARROW}${stats}`, + '\n', + chalk.bold('Watch Usage'), + describeKey('s', 'to skip the current test.'), + describeKey('q', 'to quit Interactive Mode.'), + describeKey('Enter', 'to return to watch mode.'), + ]; + + this._pipe.write(`\n${message.join('\n')}`); + } + + private _drawUIOverlay() { + if (this._testAssertions.length === 0) return this._drawUIDone(); + + return this._drawUIProgress(); + } + + private _run() { + if (this._updateTestRunnerConfig) { + this._updateTestRunnerConfig(this._testAssertions[0]); + } + } + + private abort() { + this._isActive = false; + this._skippedNum = 0; + + if (this._updateTestRunnerConfig) { + this._updateTestRunnerConfig(); + } + } + + private restart(): void { + this._skippedNum = 0; + this._countPaths = this._testAssertions.length; + this._run(); + } +} diff --git a/packages/jest-core/src/__tests__/FailedTestsInteractiveMode.test.js b/packages/jest-core/src/__tests__/FailedTestsInteractiveMode.test.js new file mode 100644 index 000000000000..0f369d342e9e --- /dev/null +++ b/packages/jest-core/src/__tests__/FailedTestsInteractiveMode.test.js @@ -0,0 +1,45 @@ +/** + * 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 from 'chalk'; +import {specialChars} from 'jest-util'; +import FailedTestsInteractiveMode from '../FailedTestsInteractiveMode'; + +const {ARROW} = specialChars; + +describe('FailedTestsInteractiveMode', () => { + describe('updateWithResults', () => { + it('renders usage information when all failures resolved', () => { + const mockWrite = jest.fn(); + + new FailedTestsInteractiveMode({write: mockWrite}).updateWithResults({ + numFailedTests: 1, + snapshot: {}, + }); + + expect(mockWrite).toHaveBeenCalledWith( + `${chalk.bold('Watch Usage')}\n${chalk.dim( + ARROW + 'Press', + )} Enter ${chalk.dim('to return to watch mode.')}\n`, + ); + }); + }); + + it('is inactive at construction', () => { + expect(new FailedTestsInteractiveMode().isActive()).toBeFalsy(); + }); + + it('skips activation when no failed tests are present', () => { + const plugin = new FailedTestsInteractiveMode(); + + plugin.run([]); + expect(plugin.isActive()).toBeFalsy(); + + plugin.run([{}]); + expect(plugin.isActive()).toBeTruthy(); + }); +}); diff --git a/packages/jest-core/src/plugins/FailedTestsInteractive.ts b/packages/jest-core/src/plugins/FailedTestsInteractive.ts new file mode 100644 index 000000000000..8fcb9b3f9c88 --- /dev/null +++ b/packages/jest-core/src/plugins/FailedTestsInteractive.ts @@ -0,0 +1,100 @@ +/** + * 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 {AggregatedResult, AssertionLocation} from '@jest/test-result'; +import type {Config} from '@jest/types'; +import { + BaseWatchPlugin, + JestHookSubscriber, + UpdateConfigCallback, + UsageData, +} from 'jest-watcher'; +import FailedTestsInteractiveMode from '../FailedTestsInteractiveMode'; + +export default class FailedTestsInteractivePlugin extends BaseWatchPlugin { + private _failedTestAssertions?: Array; + private readonly _manager = new FailedTestsInteractiveMode(this._stdout); + + apply(hooks: JestHookSubscriber): void { + hooks.onTestRunComplete(results => { + this._failedTestAssertions = this.getFailedTestAssertions(results); + + if (this._manager.isActive()) this._manager.updateWithResults(results); + }); + } + + getUsageInfo(): UsageData | null { + if (this._failedTestAssertions?.length) { + return {key: 'i', prompt: 'run failing tests interactively'}; + } + + return null; + } + + onKey(key: string): void { + if (this._manager.isActive()) { + this._manager.put(key); + } + } + + run( + _: Config.GlobalConfig, + updateConfigAndRun: UpdateConfigCallback, + ): Promise { + return new Promise(resolve => { + if ( + !this._failedTestAssertions || + this._failedTestAssertions.length === 0 + ) { + resolve(); + return; + } + + this._manager.run(this._failedTestAssertions, failure => { + updateConfigAndRun({ + mode: 'watch', + testNamePattern: failure ? `^${failure.fullName}$` : '', + testPathPattern: failure?.path || '', + }); + + if (!this._manager.isActive()) { + resolve(); + } + }); + }); + } + + private getFailedTestAssertions( + results: AggregatedResult, + ): Array { + const failedTestPaths: Array = []; + + if ( + // skip if no failed tests + results.numFailedTests === 0 || + // skip if missing test results + !results.testResults || + // skip if unmatched snapshots are present + results.snapshot.unmatched + ) { + return failedTestPaths; + } + + results.testResults.forEach(testResult => { + testResult.testResults.forEach(result => { + if (result.status === 'failed') { + failedTestPaths.push({ + fullName: result.fullName, + path: testResult.testFilePath, + }); + } + }); + }); + + return failedTestPaths; + } +} diff --git a/packages/jest-core/src/plugins/__tests__/FailedTestsInteractive.test.js b/packages/jest-core/src/plugins/__tests__/FailedTestsInteractive.test.js new file mode 100644 index 000000000000..f8327540237f --- /dev/null +++ b/packages/jest-core/src/plugins/__tests__/FailedTestsInteractive.test.js @@ -0,0 +1,46 @@ +/** + * 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 FailedTestsInteractivePlugin from '../FailedTestsInteractive'; + +describe('FailedTestsInteractive', () => { + it('returns usage info when failing tests are present', () => { + expect(new FailedTestsInteractivePlugin({}).getUsageInfo()).toBeNull(); + + const mockUpdate = jest.fn(); + const activateablePlugin = new FailedTestsInteractivePlugin({}); + const testAggregate = { + snapshot: {}, + testResults: [ + { + testFilePath: '/tmp/mock-path', + testResults: [{fullName: 'test-name', status: 'failed'}], + }, + ], + }; + let mockCallback; + + activateablePlugin.apply({ + onTestRunComplete: callback => { + mockCallback = callback; + }, + }); + + mockCallback(testAggregate); + activateablePlugin.run(null, mockUpdate); + + expect(activateablePlugin.getUsageInfo()).toEqual({ + key: 'i', + prompt: 'run failing tests interactively', + }); + expect(mockUpdate).toHaveBeenCalledWith({ + mode: 'watch', + testNamePattern: `^${testAggregate.testResults[0].testResults[0].fullName}$`, + testPathPattern: testAggregate.testResults[0].testFilePath, + }); + }); +}); diff --git a/packages/jest-core/src/watch.ts b/packages/jest-core/src/watch.ts index 60de233b8039..1beddd08e82c 100644 --- a/packages/jest-core/src/watch.ts +++ b/packages/jest-core/src/watch.ts @@ -39,6 +39,7 @@ import { filterInteractivePlugins, getSortedUsageRows, } from './lib/watchPluginsHelpers'; +import FailedTestsInteractivePlugin from './plugins/FailedTestsInteractive'; import QuitPlugin from './plugins/Quit'; import TestNamePatternPlugin from './plugins/TestNamePattern'; import TestPathPatternPlugin from './plugins/TestPathPattern'; @@ -61,6 +62,7 @@ const {print: preRunMessagePrint} = preRunMessage; let hasExitListener = false; const INTERNAL_PLUGINS = [ + FailedTestsInteractivePlugin, TestPathPatternPlugin, TestNamePatternPlugin, UpdateSnapshotsPlugin, diff --git a/packages/jest-watcher/src/BaseWatchPlugin.ts b/packages/jest-watcher/src/BaseWatchPlugin.ts index 5f59a98a249e..99d3c9e331ac 100644 --- a/packages/jest-watcher/src/BaseWatchPlugin.ts +++ b/packages/jest-watcher/src/BaseWatchPlugin.ts @@ -13,7 +13,7 @@ import type { WatchPlugin, } from './types'; -class BaseWatchPlugin implements WatchPlugin { +abstract class BaseWatchPlugin implements WatchPlugin { protected _stdin: NodeJS.ReadStream; protected _stdout: NodeJS.WriteStream;