diff --git a/packages/jest-cli/src/__tests__/__snapshots__/snapshot_interactive_mode.test.js.snap b/packages/jest-cli/src/__tests__/__snapshots__/snapshot_interactive_mode.test.js.snap new file mode 100644 index 000000000000..9b8e2e9a04fb --- /dev/null +++ b/packages/jest-cli/src/__tests__/__snapshots__/snapshot_interactive_mode.test.js.snap @@ -0,0 +1,36 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SnapshotInteractiveMode updateWithResults last test success, trigger end of interactive mode 1`] = `"TEST RESULTS CONTENTS"`; + +exports[`SnapshotInteractiveMode updateWithResults overlay handle progress UI 1`] = ` +"TEST RESULTS CONTENTS +[MOCK - cursorUp] +[MOCK - eraseDown] + +Interactive Snapshot Progress + › 2 suites failed, 1 suite passed + +Watch Usage + › Press u to update failing snapshots for this test. + › Press s to skip the current snapshot. + › Press q to quit Interactive Snapshot Update Mode. + › Press Enter to trigger a test run. +" +`; + +exports[`SnapshotInteractiveMode updateWithResults with a test failure simply update UI 1`] = ` +"TEST RESULTS CONTENTS +[MOCK - cursorUp] +[MOCK - eraseDown] + +Interactive Snapshot Progress + › 1 suite failed + +Watch Usage + › Press u to update failing snapshots for this test. + › Press q to quit Interactive Snapshot Update Mode. + › Press Enter to trigger a test run. +" +`; + +exports[`SnapshotInteractiveMode updateWithResults with a test success, call the next test 1`] = `"TEST RESULTS CONTENTS"`; diff --git a/packages/jest-cli/src/__tests__/snapshot_interactive_mode.test.js b/packages/jest-cli/src/__tests__/snapshot_interactive_mode.test.js new file mode 100644 index 000000000000..638bb8c3e7a8 --- /dev/null +++ b/packages/jest-cli/src/__tests__/snapshot_interactive_mode.test.js @@ -0,0 +1,142 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. 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 {KEYS} from '../constants'; +import SnapshotInteractiveMode from '../snapshot_interactive_mode'; + +jest.mock('../lib/terminal_utils', () => ({ + getTerminalWidth: () => 80, + rightPad: () => { + ''; + }, +})); + +jest.mock('ansi-escapes', () => ({ + cursorRestorePosition: '[MOCK - cursorRestorePosition]', + cursorSavePosition: '[MOCK - cursorSavePosition]', + cursorScrollDown: '[MOCK - cursorScrollDown]', + cursorTo: (x, y) => `[MOCK - cursorTo(${x}, ${y})]`, + cursorUp: () => '[MOCK - cursorUp]', + eraseDown: '[MOCK - eraseDown]', +})); + +jest.doMock('chalk', () => + Object.assign(new chalk.constructor({enabled: false}), { + stripColor: str => str, + }), +); + +describe('SnapshotInteractiveMode', () => { + let pipe; + let instance; + + beforeEach(() => { + pipe = {write: jest.fn()}; + instance = new SnapshotInteractiveMode(pipe); + }); + + test('is inactive at construction', () => { + expect(instance.isActive()).toBeFalsy(); + }); + + test('call to run process the first file', () => { + const mockCallback = jest.fn(); + instance.run(['first.js', 'second.js'], mockCallback); + expect(instance.isActive()).toBeTruthy(); + expect(mockCallback).toBeCalledWith('first.js', false); + }); + + test('call to abort', () => { + const mockCallback = jest.fn(); + instance.run(['first.js', 'second.js'], mockCallback); + expect(instance.isActive()).toBeTruthy(); + instance.abort(); + expect(instance.isActive()).toBeFalsy(); + expect(mockCallback).toBeCalledWith('', false); + }); + describe('key press handler', () => { + test('call to skip trigger a processing of next file', () => { + const mockCallback = jest.fn(); + instance.run(['first.js', 'second.js'], mockCallback); + expect(mockCallback.mock.calls[0]).toEqual(['first.js', false]); + instance.put(KEYS.S); + expect(mockCallback.mock.calls[1]).toEqual(['second.js', false]); + instance.put(KEYS.S); + expect(mockCallback.mock.calls[2]).toEqual(['first.js', false]); + }); + + test('call to skip works with 1 file', () => { + const mockCallback = jest.fn(); + instance.run(['first.js'], mockCallback); + expect(mockCallback.mock.calls[0]).toEqual(['first.js', false]); + instance.put(KEYS.S); + expect(mockCallback.mock.calls[1]).toEqual(['first.js', false]); + }); + + test('press U trigger a snapshot update call', () => { + const mockCallback = jest.fn(); + instance.run(['first.js'], mockCallback); + expect(mockCallback.mock.calls[0]).toEqual(['first.js', false]); + instance.put(KEYS.U); + expect(mockCallback.mock.calls[1]).toEqual(['first.js', true]); + }); + + test('press Q or ESC triggers an abort', () => { + instance.abort = jest.fn(); + instance.put(KEYS.Q); + instance.put(KEYS.ESCAPE); + expect(instance.abort).toHaveBeenCalledTimes(2); + }); + + test('press ENTER trigger a run', () => { + const mockCallback = jest.fn(); + instance.run(['first.js'], mockCallback); + instance.put(KEYS.ENTER); + expect(mockCallback).toHaveBeenCalledTimes(2); + expect(mockCallback).toHaveBeenCalledWith('first.js', false); + }); + }); + describe('updateWithResults', () => { + test('with a test failure simply update UI', () => { + const mockCallback = jest.fn(); + instance.run(['first.js'], mockCallback); + pipe.write('TEST RESULTS CONTENTS'); + instance.updateWithResults({snapshot: {failure: true}}); + expect(pipe.write.mock.calls.join('\n')).toMatchSnapshot(); + expect(mockCallback).toHaveBeenCalledTimes(1); + }); + + test('with a test success, call the next test', () => { + const mockCallback = jest.fn(); + instance.run(['first.js', 'second.js'], mockCallback); + pipe.write('TEST RESULTS CONTENTS'); + instance.updateWithResults({snapshot: {failure: false}}); + expect(pipe.write.mock.calls.join('\n')).toMatchSnapshot(); + expect(mockCallback.mock.calls[1]).toEqual(['second.js', false]); + }); + + test('overlay handle progress UI', () => { + const mockCallback = jest.fn(); + instance.run(['first.js', 'second.js', 'third.js'], mockCallback); + pipe.write('TEST RESULTS CONTENTS'); + instance.updateWithResults({snapshot: {failure: false}}); + instance.updateWithResults({snapshot: {failure: true}}); + expect(pipe.write.mock.calls.join('\n')).toMatchSnapshot(); + }); + + test('last test success, trigger end of interactive mode', () => { + const mockCallback = jest.fn(); + instance.abort = jest.fn(); + instance.run(['first.js'], mockCallback); + pipe.write('TEST RESULTS CONTENTS'); + instance.updateWithResults({snapshot: {failure: false}}); + expect(pipe.write.mock.calls.join('\n')).toMatchSnapshot(); + expect(instance.abort).toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/jest-cli/src/constants.js b/packages/jest-cli/src/constants.js index 6341694c41e1..b98b2b7c71d4 100644 --- a/packages/jest-cli/src/constants.js +++ b/packages/jest-cli/src/constants.js @@ -24,10 +24,12 @@ export const KEYS = { ENTER: '0d', ESCAPE: '1b', F: '66', + I: '69', O: '6f', P: '70', Q: '71', QUESTION_MARK: '3f', + S: '73', T: '74', U: '75', W: '77', diff --git a/packages/jest-cli/src/snapshot_interactive_mode.js b/packages/jest-cli/src/snapshot_interactive_mode.js new file mode 100644 index 000000000000..ce97d15cbafa --- /dev/null +++ b/packages/jest-cli/src/snapshot_interactive_mode.js @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @flow + */ + +import type {AggregatedResult} from 'types/TestResult'; + +const chalk = require('chalk'); +const ansiEscapes = require('ansi-escapes'); +const {pluralize} = require('./reporters/utils'); +const {KEYS} = require('./constants'); + +export default class SnapshotInteractiveMode { + _pipe: stream$Writable | tty$WriteStream; + _isActive: boolean; + _updateTestRunnerConfig: (path: string, shouldUpdateSnapshot: boolean) => *; + _testFilePaths: Array; + _countPaths: number; + + constructor(pipe: stream$Writable | tty$WriteStream) { + this._pipe = pipe; + this._isActive = false; + } + + isActive() { + return this._isActive; + } + + _drawUIOverlay() { + this._pipe.write(ansiEscapes.cursorUp(6)); + this._pipe.write(ansiEscapes.eraseDown); + + const numFailed = this._testFilePaths.length; + const numPass = this._countPaths - this._testFilePaths.length; + + let stats = chalk.bold.red(pluralize('suite', numFailed) + ' failed'); + if (numPass) { + stats += ', ' + chalk.bold.green(pluralize('suite', numPass) + ' passed'); + } + const messages = [ + '\n' + chalk.bold('Interactive Snapshot Progress'), + ' \u203A ' + stats, + '\n' + chalk.bold('Watch Usage'), + + chalk.dim(' \u203A Press ') + + 'u' + + chalk.dim(' to update failing snapshots for this test.'), + + this._testFilePaths.length > 1 + ? chalk.dim(' \u203A Press ') + + 's' + + chalk.dim(' to skip the current snapshot.') + : '', + + chalk.dim(' \u203A Press ') + + 'q' + + chalk.dim(' to quit Interactive Snapshot Update Mode.'), + + chalk.dim(' \u203A Press ') + + 'Enter' + + chalk.dim(' to trigger a test run.'), + ]; + + this._pipe.write(messages.filter(Boolean).join('\n') + '\n'); + } + + put(key: string) { + switch (key) { + case KEYS.S: + const testFilePath = this._testFilePaths.shift(); + this._testFilePaths.push(testFilePath); + this._run(false); + break; + case KEYS.U: + this._run(true); + break; + case KEYS.Q: + case KEYS.ESCAPE: + this.abort(); + break; + case KEYS.ENTER: + this._run(false); + break; + default: + break; + } + } + + abort() { + this._isActive = false; + this._updateTestRunnerConfig('', false); + } + + updateWithResults(results: AggregatedResult) { + const hasSnapshotFailure = !!results.snapshot.failure; + if (hasSnapshotFailure) { + this._drawUIOverlay(); + return; + } + + this._testFilePaths.shift(); + if (this._testFilePaths.length === 0) { + this.abort(); + return; + } + this._run(false); + } + + _run(shouldUpdateSnapshot: boolean) { + const testFilePath = this._testFilePaths[0]; + this._updateTestRunnerConfig(testFilePath, shouldUpdateSnapshot); + } + + run( + failedSnapshotTestPaths: Array, + onConfigChange: (path: string, shouldUpdateSnapshot: boolean) => *, + ) { + if (!failedSnapshotTestPaths.length) { + return; + } + + this._testFilePaths = [].concat(failedSnapshotTestPaths); + this._countPaths = this._testFilePaths.length; + this._updateTestRunnerConfig = onConfigChange; + this._isActive = true; + this._run(false); + } +} diff --git a/packages/jest-cli/src/watch.js b/packages/jest-cli/src/watch.js index e94c02b129cf..cae60f5bb149 100644 --- a/packages/jest-cli/src/watch.js +++ b/packages/jest-cli/src/watch.js @@ -17,12 +17,13 @@ import getChangedFilesPromise from './get_changed_files_promise'; import {replacePathSepForRegex} from 'jest-regex-util'; import HasteMap from 'jest-haste-map'; import isValidPath from './lib/is_valid_path'; -import {isInteractive} from 'jest-util'; +import {getFailedSnapshotTests, isInteractive} from 'jest-util'; import {print as preRunMessagePrint} from './pre_run_message'; import createContext from './lib/create_context'; import runJest from './run_jest'; import updateGlobalConfig from './lib/update_global_config'; import SearchSource from './search_source'; +import SnapshotInteractiveMode from './snapshot_interactive_mode'; import TestWatcher from './test_watcher'; import Prompt from './lib/Prompt'; import TestPathPatternPrompt from './test_path_pattern_prompt'; @@ -60,6 +61,8 @@ export default function watch( const prompt = new Prompt(); const testPathPatternPrompt = new TestPathPatternPrompt(outputStream, prompt); const testNamePatternPrompt = new TestNamePatternPrompt(outputStream, prompt); + const snapshotInteractiveMode = new SnapshotInteractiveMode(outputStream); + let failedSnapshotTestPaths = []; let searchSources = contexts.map(context => ({ context, searchSource: new SearchSource(context), @@ -127,14 +130,22 @@ export default function watch( onComplete: results => { isRunning = false; hasSnapshotFailure = !!results.snapshot.failure; + failedSnapshotTestPaths = getFailedSnapshotTests(results); + // Create a new testWatcher instance so that re-runs won't be blocked. // The old instance that was passed to Jest will still be interrupted // and prevent test runs from the previous run. testWatcher = new TestWatcher({isWatchMode: true}); + testNamePatternPrompt.updateCachedTestResults(results.testResults); + // Do not show any Watch Usage related stuff when running in a // non-interactive environment if (isInteractive) { + if (snapshotInteractiveMode.isActive()) { + snapshotInteractiveMode.updateWithResults(results); + return; + } if (shouldDisplayWatchUsage) { outputStream.write( usage(globalConfig, watchPlugins, hasSnapshotFailure), @@ -176,6 +187,11 @@ export default function watch( return; } + if (snapshotInteractiveMode.isActive()) { + snapshotInteractiveMode.put(key); + return; + } + // Abort test run if ( isRunning && @@ -219,6 +235,26 @@ export default function watch( updateSnapshot: 'none', }); break; + case KEYS.I: + if (hasSnapshotFailure) { + snapshotInteractiveMode.run( + failedSnapshotTestPaths, + (path: string, shouldUpdateSnapshot: boolean) => { + globalConfig = updateGlobalConfig(globalConfig, { + mode: 'watch', + testNamePattern: '', + testPathPattern: replacePathSepForRegex(path), + updateSnapshot: shouldUpdateSnapshot ? 'all' : 'none', + }); + startRun(globalConfig); + globalConfig = updateGlobalConfig(globalConfig, { + // updateSnapshot is not sticky after a run. + updateSnapshot: 'none', + }); + }, + ); + } + break; case KEYS.A: globalConfig = updateGlobalConfig(globalConfig, { mode: 'watchAll', @@ -374,6 +410,12 @@ const usage = ( chalk.dim(' to update failing snapshots.') : null, + snapshotFailure + ? chalk.dim(' \u203A Press ') + + 'i' + + chalk.dim(' to update failing snapshots interactively.') + : null, + chalk.dim(' \u203A Press ') + 'p' + chalk.dim(' to filter by a filename regex pattern.'), diff --git a/packages/jest-util/src/__tests__/get_failed_snapshot.test.js b/packages/jest-util/src/__tests__/get_failed_snapshot.test.js new file mode 100644 index 000000000000..ca9992978bb3 --- /dev/null +++ b/packages/jest-util/src/__tests__/get_failed_snapshot.test.js @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2014-present, Facebook, Inc. 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 getFailedSnapshotTests from '../get_failed_snapshot_tests'; + +test('return a list of path', () => { + const targetFilename = 'somewhere.js'; + const param = { + numFailedTests: 1, + testResults: [ + { + snapshot: { + unmatched: 1, + }, + testFilePath: targetFilename, + }, + ], + }; + expect(getFailedSnapshotTests(param)).toEqual([targetFilename]); +}); + +test('handle missing snapshot object', () => { + const targetFilename = 'somewhere.js'; + const param = { + numFailedTests: 1, + testResults: [ + { + testFilePath: targetFilename, + }, + ], + }; + expect(getFailedSnapshotTests(param)).toEqual([]); +}); + +test('handle missing testResults object', () => { + const param = { + numFailedTests: 1, + }; + expect(getFailedSnapshotTests(param)).toEqual([]); +}); + +test('return empty if not failed tests', () => { + const param = { + numFailedTests: 0, + }; + expect(getFailedSnapshotTests(param)).toEqual([]); +}); + +test('return empty if not failed snapshot tests', () => { + const targetFilename = 'somewhere.js'; + const param = { + numFailedTests: 0, + testResults: [ + { + snapshot: { + unmatched: 0, + }, + testFilePath: targetFilename, + }, + ], + }; + expect(getFailedSnapshotTests(param)).toEqual([]); +}); diff --git a/packages/jest-util/src/get_failed_snapshot_tests.js b/packages/jest-util/src/get_failed_snapshot_tests.js new file mode 100644 index 000000000000..eadf09212c47 --- /dev/null +++ b/packages/jest-util/src/get_failed_snapshot_tests.js @@ -0,0 +1,22 @@ +/** + * @flow + */ + +import type {AggregatedResult} from 'types/TestResult'; + +function getFailedSnapshotTests(testResults: AggregatedResult) { + const failedTestPaths = []; + if (testResults.numFailedTests === 0 || !testResults.testResults) { + return failedTestPaths; + } + + testResults.testResults.forEach(testResult => { + if (testResult.snapshot && testResult.snapshot.unmatched) { + failedTestPaths.push(testResult.testFilePath); + } + }); + + return failedTestPaths; +} + +export default getFailedSnapshotTests; diff --git a/packages/jest-util/src/index.js b/packages/jest-util/src/index.js index b39dc6113f2b..7d3736bdefe8 100644 --- a/packages/jest-util/src/index.js +++ b/packages/jest-util/src/index.js @@ -14,6 +14,7 @@ import clearLine from './clear_line'; import Console from './Console'; import FakeTimers from './fake_timers'; import formatTestResults from './format_test_results'; +import getFailedSnapshotTests from './get_failed_snapshot_tests'; import getConsoleOutput from './get_console_output'; import installCommonGlobals from './install_common_globals'; import NullConsole from './null_console'; @@ -40,6 +41,7 @@ module.exports = { createDirectory, formatTestResults, getConsoleOutput, + getFailedSnapshotTests, installCommonGlobals, isInteractive, setGlobal,