diff --git a/CHANGELOG.md b/CHANGELOG.md index 9531b5f7f67f..da567d2d384f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,11 @@ to the absolute path. Additionally, this fixes functionality in Windows OS. ([#5398](https://github.com/facebook/jest/pull/5398)) +### Chore & Maintenance + +* `[jest-util]` Implement watch plugins + ([#5399](https://github.com/facebook/jest/pull/5399)) + ## jest 22.1.4 ### Fixes diff --git a/packages/jest-cli/src/__tests__/__snapshots__/watch.test.js.snap b/packages/jest-cli/src/__tests__/__snapshots__/watch.test.js.snap index fc19800cbe6b..d1619091990f 100644 --- a/packages/jest-cli/src/__tests__/__snapshots__/watch.test.js.snap +++ b/packages/jest-cli/src/__tests__/__snapshots__/watch.test.js.snap @@ -30,9 +30,9 @@ Watch Usage › Press f to run only failed tests. › Press p to filter by a filename regex pattern. › Press t to filter by a test name regex pattern. + › Press q to quit watch mode. › Press s to do nothing. › Press u to do something else. - › Press q to quit watch mode. › Press Enter to trigger a test run. ", ], @@ -46,10 +46,10 @@ Array [ Watch Usage › Press a to run all tests. › Press f to run only failed tests. - › Press u to update failing snapshots. - › Press i to update failing snapshots interactively. › Press p to filter by a filename regex pattern. › Press t to filter by a test name regex pattern. + › Press u to update failing snapshots. + › Press i to update failing snapshots interactively. › Press q to quit watch mode. › Press Enter to trigger a test run. ", @@ -64,9 +64,9 @@ Array [ Watch Usage › Press a to run all tests. › Press f to run only failed tests. - › Press u to update failing snapshots. › Press p to filter by a filename regex pattern. › Press t to filter by a test name regex pattern. + › Press u to update failing snapshots. › Press q to quit watch mode. › Press Enter to trigger a test run. ", diff --git a/packages/jest-cli/src/__tests__/watch.test.js b/packages/jest-cli/src/__tests__/watch.test.js index 99f994077470..01ea92f2df51 100644 --- a/packages/jest-cli/src/__tests__/watch.test.js +++ b/packages/jest-cli/src/__tests__/watch.test.js @@ -35,25 +35,36 @@ jest.doMock( jest.doMock( watchPluginPath, - () => ({ - enter: jest.fn(), - key: 's'.codePointAt(0), - prompt: 'do nothing', - }), + () => + class WatchPlugin1 { + getUsageRow() { + return { + key: 's'.codePointAt(0), + prompt: 'do nothing', + }; + } + }, {virtual: true}, ); jest.doMock( watchPlugin2Path, - () => ({ - enter: jest.fn(), - key: 'u'.codePointAt(0), - prompt: 'do something else', - }), + () => + class WatchPlugin2 { + getUsageRow() { + return { + key: 'u'.codePointAt(0), + prompt: 'do something else', + }; + } + }, {virtual: true}, ); const watch = require('../watch').default; + +const nextTick = () => new Promise(res => process.nextTick(res)); + afterEach(runJestMock.mockReset); describe('Watch mode flows', () => { @@ -231,13 +242,30 @@ describe('Watch mode flows', () => { expect(pipeMockCalls.slice(determiningTestsToRun + 1)).toMatchSnapshot(); }); - it('triggers enter on a WatchPlugin when its key is pressed', () => { - const plugin = require(watchPluginPath); + it('triggers enter on a WatchPlugin when its key is pressed', async () => { + const showPrompt = jest.fn(() => Promise.resolve()); + const pluginPath = `${__dirname}/__fixtures__/plugin_path`; + jest.doMock( + pluginPath, + () => + class WatchPlugin1 { + constructor() { + this.showPrompt = showPrompt; + } + getUsageRow() { + return { + key: 's'.codePointAt(0), + prompt: 'do nothing', + }; + } + }, + {virtual: true}, + ); watch( Object.assign({}, globalConfig, { rootDir: __dirname, - watchPlugins: [watchPluginPath], + watchPlugins: [pluginPath], }), contexts, pipe, @@ -245,22 +273,61 @@ describe('Watch mode flows', () => { stdin, ); - stdin.emit(plugin.key.toString(16)); + stdin.emit(Number('s'.charCodeAt(0)).toString(16)); + + await nextTick(); - expect(plugin.enter).toHaveBeenCalled(); + expect(showPrompt).toHaveBeenCalled(); }); - it('prevents Jest from handling keys when active and returns control when end is called', () => { - const plugin = require(watchPluginPath); - const plugin2 = require(watchPlugin2Path); + it('prevents Jest from handling keys when active and returns control when end is called', async () => { + let resolveShowPrompt; + const showPrompt = jest.fn( + () => new Promise(res => (resolveShowPrompt = res)), + ); + const pluginPath = `${__dirname}/__fixtures__/plugin_path_1`; + jest.doMock( + pluginPath, + () => + class WatchPlugin1 { + constructor() { + this.showPrompt = showPrompt; + } + onData() {} + getUsageRow() { + return { + key: 's'.codePointAt(0), + prompt: 'do nothing', + }; + } + }, + {virtual: true}, + ); - let pluginEnd; - plugin.enter = jest.fn((globalConfig, end) => (pluginEnd = end)); + const showPrompt2 = jest.fn(() => Promise.resolve()); + const pluginPath2 = `${__dirname}/__fixtures__/plugin_path_2`; + jest.doMock( + pluginPath2, + () => + class WatchPlugin1 { + constructor() { + this.showPrompt = showPrompt2; + } + onData() {} + getUsageRow() { + return { + key: 'z'.codePointAt(0), + prompt: 'also do nothing', + }; + } + }, + {virtual: true}, + ); watch( Object.assign({}, globalConfig, { rootDir: __dirname, - watchPlugins: [watchPluginPath, watchPlugin2Path], + watchPlugins: [pluginPath, pluginPath2], }), contexts, pipe, @@ -268,13 +335,15 @@ describe('Watch mode flows', () => { stdin, ); - stdin.emit(plugin.key.toString(16)); - expect(plugin.enter).toHaveBeenCalled(); - stdin.emit(plugin2.key.toString(16)); - expect(plugin2.enter).not.toHaveBeenCalled(); - pluginEnd(); - stdin.emit(plugin2.key.toString(16)); - expect(plugin2.enter).toHaveBeenCalled(); + stdin.emit(Number('s'.charCodeAt(0)).toString(16)); + await nextTick(); + expect(showPrompt).toHaveBeenCalled(); + stdin.emit(Number('z'.charCodeAt(0)).toString(16)); + await nextTick(); + expect(showPrompt2).not.toHaveBeenCalled(); + await resolveShowPrompt(); + stdin.emit(Number('z'.charCodeAt(0)).toString(16)); + expect(showPrompt2).toHaveBeenCalled(); }); it('Pressing "o" runs test in "only changed files" mode', () => { @@ -312,13 +381,14 @@ describe('Watch mode flows', () => { expect(runJestMock).toHaveBeenCalledTimes(2); }); - it('Pressing "u" reruns the tests in "update snapshot" mode', () => { + it('Pressing "u" reruns the tests in "update snapshot" mode', async () => { globalConfig.updateSnapshot = 'new'; watch(globalConfig, contexts, pipe, hasteMapInstances, stdin); runJestMock.mockReset(); stdin.emit(KEYS.U); + await nextTick(); expect(runJestMock.mock.calls[0][0].globalConfig).toMatchObject({ updateSnapshot: 'all', @@ -326,6 +396,7 @@ describe('Watch mode flows', () => { }); stdin.emit(KEYS.A); + await nextTick(); // updateSnapshot is not sticky after a run. expect(runJestMock.mock.calls[1][0].globalConfig).toMatchObject({ updateSnapshot: 'new', diff --git a/packages/jest-cli/src/__tests__/watch_filename_pattern_mode.test.js b/packages/jest-cli/src/__tests__/watch_filename_pattern_mode.test.js index e5e52bb84b6d..2d3e8b7d281f 100644 --- a/packages/jest-cli/src/__tests__/watch_filename_pattern_mode.test.js +++ b/packages/jest-cli/src/__tests__/watch_filename_pattern_mode.test.js @@ -85,6 +85,8 @@ jest.doMock('../lib/terminal_utils', () => ({ const watch = require('../watch').default; +const nextTick = () => new Promise(res => process.nextTick(res)); + const toHex = char => Number(char.charCodeAt(0)).toString(16); const globalConfig = {watch: true}; @@ -142,11 +144,12 @@ describe('Watch mode flows', () => { }); }); - it('Pressing "c" clears the filters', () => { + it('Pressing "c" clears the filters', async () => { contexts[0].config = {rootDir: ''}; watch(globalConfig, contexts, pipe, hasteMapInstances, stdin); stdin.emit(KEYS.P); + await nextTick(); ['p', '.', '*', '1', '0'] .map(toHex) @@ -154,15 +157,22 @@ describe('Watch mode flows', () => { .forEach(key => stdin.emit(key)); stdin.emit(KEYS.T); + await nextTick(); + ['t', 'e', 's', 't'] .map(toHex) .concat(KEYS.ENTER) .forEach(key => stdin.emit(key)); + await nextTick(); + stdin.emit(KEYS.C); + await nextTick(); pipe.write.mockReset(); stdin.emit(KEYS.P); + await nextTick(); + expect(pipe.write.mock.calls.join('\n')).toMatchSnapshot(); }); }); diff --git a/packages/jest-cli/src/jest_hooks.js b/packages/jest-cli/src/jest_hooks.js new file mode 100644 index 000000000000..15b787ce00a2 --- /dev/null +++ b/packages/jest-cli/src/jest_hooks.js @@ -0,0 +1,65 @@ +/** + * 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. + * + * @flow + */ + +import type {AggregatedResult} from 'types/TestResult'; + +type ShouldRunTestSuite = (testPath: string) => Promise; +type TestRunComplete = (results: AggregatedResult) => void; + +export type JestHookSubscriber = { + shouldRunTestSuite: (fn: ShouldRunTestSuite) => void, + testRunComplete: (fn: TestRunComplete) => void, +}; + +export type JestHookEmitter = { + shouldRunTestSuite: (testPath: string) => Promise, + testRunComplete: (results: AggregatedResult) => void, +}; + +class JestHooks { + _listeners: { + shouldRunTestSuite: Array, + testRunComplete: Array, + }; + + constructor() { + this._listeners = { + shouldRunTestSuite: [], + testRunComplete: [], + }; + } + + getSubscriber(): JestHookSubscriber { + return { + shouldRunTestSuite: fn => { + this._listeners.shouldRunTestSuite.push(fn); + }, + testRunComplete: fn => { + this._listeners.testRunComplete.push(fn); + }, + }; + } + + getEmitter(): JestHookEmitter { + return { + shouldRunTestSuite: async testPath => + Promise.all( + this._listeners.shouldRunTestSuite.map(listener => + listener(testPath), + ), + ).then(result => + result.every(shouldRunTestSuite => shouldRunTestSuite), + ), + testRunComplete: results => + this._listeners.testRunComplete.forEach(listener => listener(results)), + }; + } +} + +export default JestHooks; diff --git a/packages/jest-cli/src/lib/active_filters_message.js b/packages/jest-cli/src/lib/active_filters_message.js new file mode 100644 index 000000000000..b6d1bc4a1d54 --- /dev/null +++ b/packages/jest-cli/src/lib/active_filters_message.js @@ -0,0 +1,37 @@ +/** + * 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. + * + * @flow + */ +import type {GlobalConfig} from 'types/Config'; +import chalk from 'chalk'; + +const activeFilters = ( + globalConfig: GlobalConfig, + delimiter: string = '\n', +) => { + const {testNamePattern, testPathPattern} = globalConfig; + if (testNamePattern || testPathPattern) { + const filters = [ + testPathPattern + ? chalk.dim('filename ') + chalk.yellow('/' + testPathPattern + '/') + : null, + testNamePattern + ? chalk.dim('test name ') + chalk.yellow('/' + testNamePattern + '/') + : null, + ] + .filter(f => f) + .join(', '); + + const messages = ['\n' + chalk.bold('Active Filters: ') + filters]; + + return messages.filter(message => !!message).join(delimiter); + } + + return ''; +}; + +export default activeFilters; diff --git a/packages/jest-cli/src/lib/watch_plugin_registry.js b/packages/jest-cli/src/lib/watch_plugin_registry.js deleted file mode 100644 index 642918306166..000000000000 --- a/packages/jest-cli/src/lib/watch_plugin_registry.js +++ /dev/null @@ -1,76 +0,0 @@ -/** - * 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. - * - * @flow - */ - -import type {WatchPlugin} from '../types'; -import getType from 'jest-get-type'; - -const RESERVED_KEYS = [ - 0x03, // Jest should handle ctrl-c interrupt - 'q'.codePointAt(0), // 'q' is reserved for quit -]; - -export default class WatchPluginRegistry { - _rootDir: string; - _watchPluginsByKey: Map; - - constructor(rootDir: string) { - this._rootDir = rootDir; - this._watchPluginsByKey = new Map(); - } - - loadPluginPath(pluginModulePath: string) { - // $FlowFixMe dynamic require - const maybePlugin = require(pluginModulePath); - - // Since we're loading the module from a dynamic path, assert its shape - // before assuming it's a valid watch plugin. - if (getType(maybePlugin) !== 'object') { - throw new Error( - `Jest watch plugin ${pluginModulePath} must be an ES Module or export an object`, - ); - } - if (getType(maybePlugin.key) !== 'number') { - throw new Error( - `Jest watch plugin ${pluginModulePath} must export 'key' as a number`, - ); - } - if (getType(maybePlugin.prompt) !== 'string') { - throw new Error( - `Jest watch plugin ${pluginModulePath} must export 'prompt' as a string`, - ); - } - if (getType(maybePlugin.enter) !== 'function') { - throw new Error( - `Jest watch plugin ${pluginModulePath} must export 'enter' as a function`, - ); - } - - const plugin: WatchPlugin = ((maybePlugin: any): WatchPlugin); - - if (RESERVED_KEYS.includes(maybePlugin.key)) { - throw new Error( - `Jest watch plugin ${pluginModulePath} tried to register reserved key ${String.fromCodePoint( - maybePlugin.key, - )}`, - ); - } - // TODO: Reject registering when another plugin has claimed the key? - this._watchPluginsByKey.set(plugin.key, plugin); - } - - getPluginByPressedKey(pressedKey: number): ?WatchPlugin { - return this._watchPluginsByKey.get(pressedKey); - } - - getPluginsOrderedByKey(): Array { - return Array.from(this._watchPluginsByKey.values()).sort( - (a, b) => a.key - b.key, - ); - } -} diff --git a/packages/jest-cli/src/plugins/quit.js b/packages/jest-cli/src/plugins/quit.js new file mode 100644 index 000000000000..44e64941c4df --- /dev/null +++ b/packages/jest-cli/src/plugins/quit.js @@ -0,0 +1,25 @@ +/** + * 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. + * + * @flow + */ +import WatchPlugin from '../watch_plugin'; + +class QuitPlugin extends WatchPlugin { + async showPrompt() { + this._stdout.write('\n'); + process.exit(0); + } + + getUsageRow() { + return { + key: 'q'.codePointAt(0), + prompt: 'quit watch mode', + }; + } +} + +export default QuitPlugin; diff --git a/packages/jest-cli/src/plugins/test_name_pattern.js b/packages/jest-cli/src/plugins/test_name_pattern.js new file mode 100644 index 000000000000..70aa1e65834b --- /dev/null +++ b/packages/jest-cli/src/plugins/test_name_pattern.js @@ -0,0 +1,61 @@ +/** + * 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. + * + * @flow + */ +import type {GlobalConfig} from 'types/Config'; +import WatchPlugin from '../watch_plugin'; +import TestNamePatternPrompt from '../test_name_pattern_prompt'; +import activeFilters from '../lib/active_filters_message'; +import Prompt from '../lib/Prompt'; + +class TestNamePatternPlugin extends WatchPlugin { + _prompt: Prompt; + + constructor(options: { + stdin: stream$Readable | tty$ReadStream, + stdout: stream$Writable | tty$WriteStream, + }) { + super(options); + this._prompt = new Prompt(); + } + + getUsageRow() { + return { + key: 't'.codePointAt(0), + prompt: 'filter by a test name regex pattern', + }; + } + + onData(key: string) { + this._prompt.put(key); + } + + showPrompt( + globalConfig: GlobalConfig, + updateConfigAndRun: Function, + ): Promise { + return new Promise((res, rej) => { + const testPathPatternPrompt = new TestNamePatternPrompt( + this._stdout, + this._prompt, + ); + + testPathPatternPrompt.run( + (value: string) => { + updateConfigAndRun({testNamePattern: value}); + res(); + }, + rej, + { + header: activeFilters(globalConfig), + }, + ); + }); + } +} + +export default TestNamePatternPlugin; diff --git a/packages/jest-cli/src/plugins/test_path_pattern.js b/packages/jest-cli/src/plugins/test_path_pattern.js new file mode 100644 index 000000000000..aa27e9ac9452 --- /dev/null +++ b/packages/jest-cli/src/plugins/test_path_pattern.js @@ -0,0 +1,62 @@ +/** + * 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. + * + * @flow + */ + +import type {GlobalConfig} from 'types/Config'; +import WatchPlugin from '../watch_plugin'; +import TestPathPatternPrompt from '../test_path_pattern_prompt'; +import activeFilters from '../lib/active_filters_message'; +import Prompt from '../lib/Prompt'; + +class TestPathPatternPlugin extends WatchPlugin { + _prompt: Prompt; + + constructor(options: { + stdin: stream$Readable | tty$ReadStream, + stdout: stream$Writable | tty$WriteStream, + }) { + super(options); + this._prompt = new Prompt(); + } + + getUsageRow() { + return { + key: 'p'.codePointAt(0), + prompt: 'filter by a filename regex pattern', + }; + } + + onData(key: string) { + this._prompt.put(key); + } + + showPrompt( + globalConfig: GlobalConfig, + updateConfigAndRun: Function, + ): Promise { + return new Promise((res, rej) => { + const testPathPatternPrompt = new TestPathPatternPrompt( + this._stdout, + this._prompt, + ); + + testPathPatternPrompt.run( + (value: string) => { + updateConfigAndRun({testPathPattern: value}); + res(); + }, + rej, + { + header: activeFilters(globalConfig), + }, + ); + }); + } +} + +export default TestPathPatternPlugin; diff --git a/packages/jest-cli/src/plugins/update_snapshots.js b/packages/jest-cli/src/plugins/update_snapshots.js new file mode 100644 index 000000000000..46e5e8c8cdd8 --- /dev/null +++ b/packages/jest-cli/src/plugins/update_snapshots.js @@ -0,0 +1,38 @@ +/** + * 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. + * + * @flow + */ +import type {GlobalConfig} from 'types/Config'; +import WatchPlugin from '../watch_plugin'; +import type {JestHookSubscriber} from '../jest_hooks'; + +class UpdateSnapshotsPlugin extends WatchPlugin { + _hasSnapshotFailure: boolean; + showPrompt( + globalConfig: GlobalConfig, + updateConfigAndRun: Function, + ): Promise { + updateConfigAndRun({updateSnapshot: 'all'}); + return Promise.resolve(false); + } + + registerHooks(hooks: JestHookSubscriber) { + hooks.testRunComplete(results => { + this._hasSnapshotFailure = results.snapshot.failure; + }); + } + + getUsageRow(globalConfig: GlobalConfig) { + return { + hide: !this._hasSnapshotFailure, + key: 'u'.codePointAt(0), + prompt: 'update failing snapshots', + }; + } +} + +export default UpdateSnapshotsPlugin; diff --git a/packages/jest-cli/src/plugins/update_snapshots_interactive.js b/packages/jest-cli/src/plugins/update_snapshots_interactive.js new file mode 100644 index 000000000000..95ee20378cd3 --- /dev/null +++ b/packages/jest-cli/src/plugins/update_snapshots_interactive.js @@ -0,0 +1,78 @@ +/** + * 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. + * + * @flow + */ +import type {JestHookSubscriber} from '../jest_hooks'; +import type {GlobalConfig} from 'types/Config'; +import WatchPlugin from '../watch_plugin'; +import {getFailedSnapshotTests} from 'jest-util'; +import SnapshotInteractiveMode from '../snapshot_interactive_mode'; + +class UpdateSnapshotInteractivePlugin extends WatchPlugin { + _snapshotInteractiveMode: SnapshotInteractiveMode; + _failedSnapshotTestPaths: Array<*>; + + constructor(options: { + stdin: stream$Readable | tty$ReadStream, + stdout: stream$Writable | tty$WriteStream, + }) { + super(options); + this._snapshotInteractiveMode = new SnapshotInteractiveMode(this._stdout); + } + + registerHooks(hooks: JestHookSubscriber) { + hooks.testRunComplete(results => { + this._failedSnapshotTestPaths = getFailedSnapshotTests(results); + if (this._snapshotInteractiveMode.isActive()) { + this._snapshotInteractiveMode.updateWithResults(results); + } + }); + } + + onData(key: string) { + if (this._snapshotInteractiveMode.isActive()) { + this._snapshotInteractiveMode.put(key); + } + } + + showPrompt( + globalConfig: GlobalConfig, + updateConfigAndRun: Function, + ): Promise { + if (this._failedSnapshotTestPaths.length) { + return new Promise(res => { + this._snapshotInteractiveMode.run( + this._failedSnapshotTestPaths, + (path: string, shouldUpdateSnapshot: boolean) => { + updateConfigAndRun({ + testNamePattern: '', + testPathPattern: path, + updateSnapshot: shouldUpdateSnapshot ? 'all' : 'none', + }); + if (!this._snapshotInteractiveMode.isActive()) { + res(); + } + }, + ); + }); + } else { + return Promise.resolve(); + } + } + + getUsageRow(globalConfig: GlobalConfig) { + return { + hide: + !this._failedSnapshotTestPaths || + this._failedSnapshotTestPaths.length === 0, + key: 'i'.codePointAt(0), + prompt: 'update failing snapshots interactively', + }; + } +} + +export default UpdateSnapshotInteractivePlugin; diff --git a/packages/jest-cli/src/run_jest.js b/packages/jest-cli/src/run_jest.js index 22d2803c1158..9ef2fc74ff7b 100644 --- a/packages/jest-cli/src/run_jest.js +++ b/packages/jest-cli/src/run_jest.js @@ -24,6 +24,7 @@ import TestScheduler from './test_scheduler'; import TestSequencer from './test_sequencer'; import {makeEmptyAggregatedTestResult} from './test_result_helpers'; import FailedTestsCache from './failed_tests_cache'; +import JestHooks, {type JestHookEmitter} from './jest_hooks'; const setConfig = (contexts, newConfig) => contexts.forEach( @@ -38,6 +39,7 @@ const getTestPaths = async ( context, outputStream, changedFilesPromise, + jestHooks, ) => { const source = new SearchSource(context); const data = await source.getTestPaths(globalConfig, changedFilesPromise); @@ -51,7 +53,17 @@ const getTestPaths = async ( 'commit.', ); } - return data; + + const shouldTestArray = await Promise.all( + data.tests.map(test => jestHooks.shouldRunTestSuite(test.path)), + ); + + const filteredTests = data.tests.filter((test, i) => shouldTestArray[i]); + + return Object.assign({}, data, { + allTests: filteredTests.length, + tests: filteredTests, + }); }; const processResults = (runResults, options) => { @@ -81,6 +93,7 @@ export default (async function runJest({ globalConfig, outputStream, testWatcher, + jestHooks = new JestHooks().getEmitter(), startRun, changedFilesPromise, onComplete, @@ -90,6 +103,7 @@ export default (async function runJest({ contexts: Array, outputStream: stream$Writable | tty$WriteStream, testWatcher: TestWatcher, + jestHooks?: JestHookEmitter, startRun: (globalConfig: GlobalConfig) => *, changedFilesPromise: ?ChangedFilesPromise, onComplete: (testResults: AggregatedResult) => any, @@ -119,6 +133,7 @@ export default (async function runJest({ context, outputStream, changedFilesPromise, + jestHooks, ); allTests = allTests.concat(matches.tests); return {context, matches}; diff --git a/packages/jest-cli/src/types.js b/packages/jest-cli/src/types.js index 1bbecadae645..1a2070779c56 100644 --- a/packages/jest-cli/src/types.js +++ b/packages/jest-cli/src/types.js @@ -6,11 +6,31 @@ * * @flow */ - import type {GlobalConfig} from 'types/Config'; +export type UsageRow = { + key: number, + prompt: string, + hide?: boolean, +}; + +export type JestHooks = { + testRunComplete: any, +}; + export type WatchPlugin = { key: number, + name: string, prompt: string, - enter: (globalConfig: GlobalConfig, end: () => mixed) => mixed, + apply: ( + jestHooks: JestHooks, + { + stdin: stream$Readable | tty$ReadStream, + stdout: stream$Writable | tty$WriteStream, + }, + ) => void, + shouldShowUsage?: ( + globalConfig: GlobalConfig, + hasSnapshotFailures: boolean, + ) => boolean, }; diff --git a/packages/jest-cli/src/watch.js b/packages/jest-cli/src/watch.js index 027fc8ab79a2..ef55242c249b 100644 --- a/packages/jest-cli/src/watch.js +++ b/packages/jest-cli/src/watch.js @@ -7,9 +7,8 @@ * @flow */ -import type {GlobalConfig} from 'types/Config'; +import type {GlobalConfig, SnapshotUpdateState} from 'types/Config'; import type {Context} from 'types/Context'; -import type {WatchPlugin} from './types'; import ansiEscapes from 'ansi-escapes'; import chalk from 'chalk'; @@ -18,23 +17,52 @@ import exit from 'exit'; import {replacePathSepForRegex} from 'jest-regex-util'; import HasteMap from 'jest-haste-map'; import isValidPath from './lib/is_valid_path'; -import {getFailedSnapshotTests, isInteractive} from 'jest-util'; +import {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'; -import TestNamePatternPrompt from './test_name_pattern_prompt'; import FailedTestsCache from './failed_tests_cache'; -import WatchPluginRegistry from './lib/watch_plugin_registry'; import {KEYS, CLEAR} from './constants'; +import JestHooks from './jest_hooks'; +import WatchPlugin from './watch_plugin'; +import TestPathPatternPlugin from './plugins/test_path_pattern'; +import TestNamePatternPlugin from './plugins/test_name_pattern'; +import UpdateSnapshotsPlugin from './plugins/update_snapshots'; +import UpdateSnapshotsInteractivePlugin from './plugins/update_snapshots_interactive'; +import QuitPlugin from './plugins/quit'; +import activeFilters from './lib/active_filters_message'; let hasExitListener = false; +const INTERNAL_PLUGINS = [ + TestPathPatternPlugin, + TestNamePatternPlugin, + UpdateSnapshotsPlugin, + UpdateSnapshotsInteractivePlugin, + QuitPlugin, +]; + +const getSortedUsageRows = ( + watchPlugins: Array, + globalConfig: GlobalConfig, +) => { + const internalPlugins = watchPlugins + .slice(0, INTERNAL_PLUGINS.length) + .map(p => p.getUsageRow(globalConfig)) + .filter(usage => !usage.hide); + + const thirdPartyPlugins = watchPlugins + .slice(INTERNAL_PLUGINS.length) + .map(p => p.getUsageRow(globalConfig)) + .filter(usage => !usage.hide) + .sort((a, b) => a.key - b.key); + + return internalPlugins.concat(thirdPartyPlugins); +}; + export default function watch( initialGlobalConfig: GlobalConfig, contexts: Array, @@ -45,38 +73,75 @@ export default function watch( // `globalConfig` will be constantly updated and reassigned as a result of // watch mode interactions. let globalConfig = initialGlobalConfig; + let activePlugin: ?WatchPlugin; globalConfig = updateGlobalConfig(globalConfig, { mode: globalConfig.watch ? 'watch' : 'watchAll', passWithNoTests: true, }); - const watchPlugins = new WatchPluginRegistry(globalConfig.rootDir); + const hooks = new JestHooks(); + + const updateConfigAndRun = ({ + testNamePattern, + testPathPattern, + updateSnapshot, + }: { + testNamePattern?: string, + testPathPattern?: string, + updateSnapshot?: SnapshotUpdateState, + } = {}) => { + const previousUpdateSnapshot = globalConfig.updateSnapshot; + globalConfig = updateGlobalConfig(globalConfig, { + mode: 'watch', + testNamePattern: + testNamePattern !== undefined + ? testNamePattern + : globalConfig.testNamePattern, + testPathPattern: + testPathPattern !== undefined + ? replacePathSepForRegex(testPathPattern) + : globalConfig.testPathPattern, + updateSnapshot: + updateSnapshot !== undefined + ? updateSnapshot + : globalConfig.updateSnapshot, + }); + + startRun(globalConfig); + globalConfig = updateGlobalConfig(globalConfig, { + // updateSnapshot is not sticky after a run. + updateSnapshot: + previousUpdateSnapshot === 'all' ? 'none' : previousUpdateSnapshot, + }); + }; + + const watchPlugins: Array = INTERNAL_PLUGINS.map( + InternalPlugin => new InternalPlugin({stdin, stdout: outputStream}), + ); + + watchPlugins.forEach((plugin: WatchPlugin) => { + plugin.registerHooks(hooks.getSubscriber()); + }); + if (globalConfig.watchPlugins != null) { for (const pluginModulePath of globalConfig.watchPlugins) { - watchPlugins.loadPluginPath(pluginModulePath); + // $FlowFixMe dynamic require + const ThirdPluginPath = require(pluginModulePath); + watchPlugins.push(new ThirdPluginPath({stdin, stdout: outputStream})); } } const failedTestsCache = new FailedTestsCache(); - 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), })); - let hasSnapshotFailure = false; - let hasSnapshotFailureInteractive = false; let isRunning = false; let testWatcher; let shouldDisplayWatchUsage = true; let isWatchUsageDisplayed = false; - testPathPatternPrompt.updateSearchSources(searchSources); - hasteMapInstances.forEach((hasteMapInstance, index) => { hasteMapInstance.on('change', ({eventsQueue, hasteFS, moduleMap}) => { const validPaths = eventsQueue.filter(({filePath}) => { @@ -91,13 +156,14 @@ export default function watch( moduleMap, }, )); - prompt.abort(); + + activePlugin = null; + searchSources = searchSources.slice(); searchSources[index] = { context, searchSource: new SearchSource(context), }; - testPathPatternPrompt.updateSearchSources(searchSources); startRun(globalConfig); } }); @@ -106,7 +172,7 @@ export default function watch( if (!hasExitListener) { hasExitListener = true; process.on('exit', () => { - if (prompt.isEntering()) { + if (activePlugin) { outputStream.write(ansiEscapes.cursorDown()); outputStream.write(ansiEscapes.eraseDown); } @@ -129,35 +195,21 @@ export default function watch( contexts, failedTestsCache, globalConfig, + jestHooks: hooks.getEmitter(), onComplete: results => { isRunning = false; - failedSnapshotTestPaths = getFailedSnapshotTests(results); - hasSnapshotFailure = !!results.snapshot.failure; - hasSnapshotFailureInteractive = failedSnapshotTestPaths.length > 0; + hooks.getEmitter().testRunComplete(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, - hasSnapshotFailureInteractive, - ), - ); + outputStream.write(usage(globalConfig, watchPlugins)); shouldDisplayWatchUsage = false; // hide Watch Usage after first run isWatchUsageDisplayed = true; } else { @@ -169,7 +221,6 @@ export default function watch( outputStream.write('\n'); } failedTestsCache.setTestResults(results.testResults); - testNamePatternPrompt.updateCachedTestResults(results.testResults); }, outputStream, startRun, @@ -177,7 +228,6 @@ export default function watch( }).catch(error => console.error(chalk.red(error.stack))); }; - let activePlugin: ?WatchPlugin; const onKeypress = (key: string) => { if (key === KEYS.CONTROL_C || key === KEYS.CONTROL_D) { outputStream.write('\n'); @@ -188,85 +238,53 @@ export default function watch( if (activePlugin != null) { // if a plugin is activate, Jest should let it handle keystrokes, so ignore // them here - return; - } - - if (prompt.isEntering()) { - prompt.put(key); - return; - } - - if (snapshotInteractiveMode.isActive()) { - snapshotInteractiveMode.put(key); + activePlugin.onData(key); return; } // Abort test run + const pluginKeys = getSortedUsageRows(watchPlugins, globalConfig).map( + usage => Number(usage.key).toString(16), + ); if ( isRunning && testWatcher && - [KEYS.Q, KEYS.ENTER, KEYS.A, KEYS.O, KEYS.P, KEYS.T, KEYS.F].indexOf( - key, - ) !== -1 + [KEYS.Q, KEYS.ENTER, KEYS.A, KEYS.O, KEYS.F] + .concat(pluginKeys) + .indexOf(key) !== -1 ) { testWatcher.setState({interrupted: true}); return; } - const matchingWatchPlugin = watchPlugins.getPluginByPressedKey( - parseInt(key, 16), - ); + const matchingWatchPlugin = watchPlugins.find(plugin => { + const usageRow = plugin.getUsageRow(globalConfig) || {}; + + return usageRow.key === parseInt(key, 16); + }); + if (matchingWatchPlugin != null) { // "activate" the plugin, which has jest ignore keystrokes so the plugin // can handle them activePlugin = matchingWatchPlugin; - activePlugin.enter( - globalConfig, - // end callback -- returns control to jest to handle keystrokes - () => (activePlugin = null), + activePlugin.showPrompt(globalConfig, updateConfigAndRun).then( + shouldRerun => { + activePlugin = null; + if (shouldRerun) { + updateConfigAndRun(); + } + }, + () => { + activePlugin = null; + onCancelPatternPrompt(); + }, ); } switch (key) { - case KEYS.Q: - outputStream.write('\n'); - exit(0); - return; case KEYS.ENTER: startRun(globalConfig); break; - case KEYS.U: - const previousUpdateSnapshot = globalConfig.updateSnapshot; - - globalConfig = updateGlobalConfig(globalConfig, { - updateSnapshot: 'all', - }); - startRun(globalConfig); - globalConfig = updateGlobalConfig(globalConfig, { - // updateSnapshot is not sticky after a run. - updateSnapshot: previousUpdateSnapshot, - }); - 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', @@ -276,12 +294,10 @@ export default function watch( startRun(globalConfig); break; case KEYS.C: - globalConfig = updateGlobalConfig(globalConfig, { - mode: 'watch', + updateConfigAndRun({ testNamePattern: '', testPathPattern: '', }); - startRun(globalConfig); break; case KEYS.F: globalConfig = updateGlobalConfig(globalConfig, { @@ -297,50 +313,13 @@ export default function watch( }); startRun(globalConfig); break; - case KEYS.P: - testPathPatternPrompt.run( - testPathPattern => { - globalConfig = updateGlobalConfig(globalConfig, { - mode: 'watch', - testNamePattern: '', - testPathPattern: replacePathSepForRegex(testPathPattern), - }); - - startRun(globalConfig); - }, - onCancelPatternPrompt, - {header: activeFilters(globalConfig)}, - ); - break; - case KEYS.T: - testNamePatternPrompt.run( - testNamePattern => { - globalConfig = updateGlobalConfig(globalConfig, { - mode: 'watch', - testNamePattern, - testPathPattern: globalConfig.testPathPattern, - }); - - startRun(globalConfig); - }, - onCancelPatternPrompt, - {header: activeFilters(globalConfig)}, - ); - break; case KEYS.QUESTION_MARK: break; case KEYS.W: if (!shouldDisplayWatchUsage && !isWatchUsageDisplayed) { outputStream.write(ansiEscapes.cursorUp()); outputStream.write(ansiEscapes.eraseDown); - outputStream.write( - usage( - globalConfig, - watchPlugins, - hasSnapshotFailure, - hasSnapshotFailureInteractive, - ), - ); + outputStream.write(usage(globalConfig, watchPlugins)); isWatchUsageDisplayed = true; shouldDisplayWatchUsage = false; } @@ -351,14 +330,7 @@ export default function watch( const onCancelPatternPrompt = () => { outputStream.write(ansiEscapes.cursorHide); outputStream.write(ansiEscapes.clearScreen); - outputStream.write( - usage( - globalConfig, - watchPlugins, - hasSnapshotFailure, - hasSnapshotFailureInteractive, - ), - ); + outputStream.write(usage(globalConfig, watchPlugins)); outputStream.write(ansiEscapes.cursorShow); }; @@ -373,33 +345,9 @@ export default function watch( return Promise.resolve(); } -const activeFilters = (globalConfig: GlobalConfig, delimiter = '\n') => { - const {testNamePattern, testPathPattern} = globalConfig; - if (testNamePattern || testPathPattern) { - const filters = [ - testPathPattern - ? chalk.dim('filename ') + chalk.yellow('/' + testPathPattern + '/') - : null, - testNamePattern - ? chalk.dim('test name ') + chalk.yellow('/' + testNamePattern + '/') - : null, - ] - .filter(f => !!f) - .join(', '); - - const messages = ['\n' + chalk.bold('Active Filters: ') + filters]; - - return messages.filter(message => !!message).join(delimiter); - } - - return ''; -}; - const usage = ( globalConfig, - watchPlugins: WatchPluginRegistry, - snapshotFailure, - snapshotFailureInteractive, + watchPlugins: Array, delimiter = '\n', ) => { const messages = [ @@ -429,38 +377,14 @@ const usage = ( chalk.dim(' to only run tests related to changed files.') : null, - snapshotFailure - ? chalk.dim(' \u203A Press ') + - 'u' + - chalk.dim(' to update failing snapshots.') - : null, - - snapshotFailureInteractive - ? 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.'), - - chalk.dim(' \u203A Press ') + - 't' + - chalk.dim(' to filter by a test name regex pattern.'), - - ...watchPlugins - .getPluginsOrderedByKey() - .map( - plugin => - chalk.dim(' \u203A Press') + - ' ' + - String.fromCodePoint(plugin.key) + - ' ' + - chalk.dim(`to ${plugin.prompt}.`), - ), - - chalk.dim(' \u203A Press ') + 'q' + chalk.dim(' to quit watch mode.'), + ...getSortedUsageRows(watchPlugins, globalConfig).map( + plugin => + chalk.dim(' \u203A Press') + + ' ' + + String.fromCodePoint(plugin.key) + + ' ' + + chalk.dim(`to ${plugin.prompt}.`), + ), chalk.dim(' \u203A Press ') + 'Enter' + diff --git a/packages/jest-cli/src/watch_plugin.js b/packages/jest-cli/src/watch_plugin.js new file mode 100644 index 000000000000..3a666d8b05c5 --- /dev/null +++ b/packages/jest-cli/src/watch_plugin.js @@ -0,0 +1,43 @@ +/** + * 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. + * + * @flow + */ +import type {GlobalConfig} from 'types/Config'; +import type {JestHookSubscriber} from './jest_hooks'; +import type {UsageRow} from './types'; + +class WatchPlugin { + _stdin: stream$Readable | tty$ReadStream; + _stdout: stream$Writable | tty$WriteStream; + constructor({ + stdin, + stdout, + }: { + stdin: stream$Readable | tty$ReadStream, + stdout: stream$Writable | tty$WriteStream, + }) { + this._stdin = stdin; + this._stdout = stdout; + } + + registerHooks(hooks: JestHookSubscriber) {} + + getUsageRow(globalConfig: GlobalConfig): UsageRow { + return {hide: true, key: 0, prompt: ''}; + } + + onData(value: string) {} + + showPrompt( + globalConfig: GlobalConfig, + updateConfigAndRun: Function, + ): Promise { + return Promise.resolve(); + } +} + +export default WatchPlugin;