Skip to content

Commit

Permalink
feat: interactive failure runs (#10858)
Browse files Browse the repository at this point in the history
  • Loading branch information
NullDivision authored Feb 18, 2021
1 parent 66629be commit 15010f1
Show file tree
Hide file tree
Showing 7 changed files with 390 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
195 changes: 195 additions & 0 deletions packages/jest-core/src/FailedTestsInteractiveMode.ts
Original file line number Diff line number Diff line change
@@ -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<AssertionLocation> = [];
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<AssertionLocation>,
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<string> = [
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();
}
}
Original file line number Diff line number Diff line change
@@ -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();
});
});
100 changes: 100 additions & 0 deletions packages/jest-core/src/plugins/FailedTestsInteractive.ts
Original file line number Diff line number Diff line change
@@ -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<AssertionLocation>;
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<void> {
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<AssertionLocation> {
const failedTestPaths: Array<AssertionLocation> = [];

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;
}
}
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
Loading

0 comments on commit 15010f1

Please sign in to comment.