Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: interactive failure runs #10858

Merged
merged 3 commits into from Feb 18, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
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
@@ -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();
}
}
@@ -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
@@ -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;
}
}
@@ -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,
});
});
});