Skip to content

Commit 4f757f7

Browse files
author
Will Binns-Smith
committed
Implement pluggable watch mode
Resolves #4824 This is my first nontrivial contribution to Jest so please be gentle :) This makes watch mode pluggable, allowing for arbitrary watch plugin modules to be registered in the global config and used to generate prompt messages and key listeners when watch mode is activated. When a particular plugin is activated, Jest no longer handles keypresses until the plugin calls the `end` callback (see #4824 for design details) This currently does not implement any existing watch functionality as a plugin and is rather adding the infrastructure to do so. That will follow soon :) @cpojer @aaronabramov I'm not sure if the globalConfig is the best place for this (to be honest I can't make sense of the the sheer number of config types). I also haven't gotten around to running a manual test for this quite yet. More interested in design feedback at this point :) Test Plan: `jest`
1 parent 065c7b8 commit 4f757f7

File tree

12 files changed

+266
-4
lines changed

12 files changed

+266
-4
lines changed

packages/jest-cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"jest-changed-files": "^21.2.0",
1717
"jest-config": "^21.2.1",
1818
"jest-environment-jsdom": "^21.2.1",
19+
"jest-get-type": "^21.2.0",
1920
"jest-haste-map": "^21.2.0",
2021
"jest-message-util": "^21.2.1",
2122
"jest-regex-util": "^21.2.0",

packages/jest-cli/src/__tests__/__fixtures__/watch_plugin.js

Whitespace-only changes.

packages/jest-cli/src/__tests__/__fixtures__/watch_plugin2.js

Whitespace-only changes.

packages/jest-cli/src/__tests__/__snapshots__/watch.test.js.snap

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,20 @@ Watch Usage
1212
",
1313
]
1414
`;
15+
16+
exports[`Watch mode flows shows prompts for WatchPlugins in alphabetical order 1`] = `
17+
Array [
18+
Array [
19+
"
20+
Watch Usage
21+
› Press a to run all tests.
22+
› Press p to filter by a filename regex pattern.
23+
› Press t to filter by a test name regex pattern.
24+
› Press s to do nothing.
25+
› Press u to do something else.
26+
› Press q to quit watch mode.
27+
› Press Enter to trigger a test run.
28+
",
29+
],
30+
]
31+
`;

packages/jest-cli/src/__tests__/watch.test.js

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ jest.doMock(
3030
},
3131
);
3232

33+
jest.doMock('./__fixtures__/watch_plugin', () => ({
34+
enter: jest.fn(),
35+
key: 0x73, // 's' key
36+
prompt: 'do nothing',
37+
}));
38+
39+
jest.doMock('./__fixtures__/watch_plugin2', () => ({
40+
enter: jest.fn(),
41+
key: 0x75, // 'u' key
42+
prompt: 'do something else',
43+
}));
44+
3345
const watch = require('../watch').default;
3446
afterEach(runJestMock.mockReset);
3547

@@ -89,6 +101,88 @@ describe('Watch mode flows', () => {
89101
expect(pipe.write.mock.calls.reverse()[0]).toMatchSnapshot();
90102
});
91103

104+
it('resolves relative to the package root', () => {
105+
expect(async () => {
106+
await watch(
107+
Object.assign({}, globalConfig, {
108+
rootDir: __dirname,
109+
watchPlugins: ['./__fixtures__/watch_plugin'],
110+
}),
111+
contexts,
112+
pipe,
113+
hasteMapInstances,
114+
stdin,
115+
);
116+
}).not.toThrow();
117+
});
118+
119+
it('shows prompts for WatchPlugins in alphabetical order', async () => {
120+
watch(
121+
Object.assign({}, globalConfig, {
122+
rootDir: __dirname,
123+
watchPlugins: [
124+
'./__fixtures__/watch_plugin2',
125+
'./__fixtures__/watch_plugin',
126+
],
127+
}),
128+
contexts,
129+
pipe,
130+
hasteMapInstances,
131+
stdin,
132+
);
133+
134+
expect(pipe.write.mock.calls).toMatchSnapshot();
135+
});
136+
137+
it('triggers enter on a WatchPlugin when its key is pressed', () => {
138+
const plugin = require('./__fixtures__/watch_plugin');
139+
140+
watch(
141+
Object.assign({}, globalConfig, {
142+
rootDir: __dirname,
143+
watchPlugins: ['./__fixtures__/watch_plugin'],
144+
}),
145+
contexts,
146+
pipe,
147+
hasteMapInstances,
148+
stdin,
149+
);
150+
151+
stdin.emit(plugin.key.toString(16));
152+
153+
expect(plugin.enter).toHaveBeenCalled();
154+
});
155+
156+
it('prevents Jest from handling keys when active and returns control when end is called', () => {
157+
const plugin = require('./__fixtures__/watch_plugin');
158+
const plugin2 = require('./__fixtures__/watch_plugin2');
159+
160+
let pluginEnd;
161+
plugin.enter = jest.fn((globalConfig, end) => (pluginEnd = end));
162+
163+
watch(
164+
Object.assign({}, globalConfig, {
165+
rootDir: __dirname,
166+
watchPlugins: [
167+
'./__fixtures__/watch_plugin',
168+
'./__fixtures__/watch_plugin2',
169+
],
170+
}),
171+
contexts,
172+
pipe,
173+
hasteMapInstances,
174+
stdin,
175+
);
176+
177+
stdin.emit(plugin.key.toString(16));
178+
expect(plugin.enter).toHaveBeenCalled();
179+
stdin.emit(plugin2.key.toString(16));
180+
expect(plugin2.enter).not.toHaveBeenCalled();
181+
pluginEnd();
182+
stdin.emit(plugin2.key.toString(16));
183+
expect(plugin2.enter).toHaveBeenCalled();
184+
});
185+
92186
it('Pressing "o" runs test in "only changed files" mode', () => {
93187
watch(globalConfig, contexts, pipe, hasteMapInstances, stdin);
94188
runJestMock.mockReset();
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* Copyright (c) 2014-present, Facebook, Inc. All rights reserved.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {WatchPlugin} from '../types';
11+
import getType from 'jest-get-type';
12+
import defaultResolver from '../../../jest-resolve/src/default_resolver';
13+
14+
const RESERVED_KEYS = [
15+
0x03, // Jest should handle ctrl-c interrupt
16+
0x71, // 'q' is reserved for quit
17+
];
18+
19+
export default class WatchPluginRegistry {
20+
_rootDir: string;
21+
_watchPluginsByKey: Map<number, WatchPlugin>;
22+
23+
constructor(rootDir: string) {
24+
this._rootDir = rootDir;
25+
this._watchPluginsByKey = new Map();
26+
}
27+
28+
loadPluginPath(pluginModulePath: string) {
29+
// $FlowFixMe dynamic require
30+
const maybePlugin = require(defaultResolver(pluginModulePath, {
31+
basedir: this._rootDir,
32+
}));
33+
34+
// Since we're loading the module from a dynamic path, assert its shape
35+
// before assuming it's a valid watch plugin.
36+
if (getType(maybePlugin) !== 'object') {
37+
throw new Error(
38+
`Jest watch plugin ${pluginModulePath} must be an ES Module or export an object`,
39+
);
40+
}
41+
if (getType(maybePlugin.key) !== 'number') {
42+
throw new Error(
43+
`Jest watch plugin ${pluginModulePath} must export 'key' as a number`,
44+
);
45+
}
46+
if (getType(maybePlugin.prompt) !== 'string') {
47+
throw new Error(
48+
`Jest watch plugin ${pluginModulePath} must export 'prompt' as a string`,
49+
);
50+
}
51+
if (getType(maybePlugin.enter) !== 'function') {
52+
throw new Error(
53+
`Jest watch plugin ${pluginModulePath} must export 'enter' as a function`,
54+
);
55+
}
56+
57+
const plugin: WatchPlugin = ((maybePlugin: any): WatchPlugin);
58+
59+
if (RESERVED_KEYS.includes(maybePlugin.key)) {
60+
throw new Error(
61+
`Jest watch plugin ${pluginModulePath} tried to register reserved key ${String.fromCodePoint(
62+
maybePlugin.key,
63+
)}`,
64+
);
65+
}
66+
// TODO: Reject registering when another plugin has claimed the key?
67+
this._watchPluginsByKey.set(plugin.key, plugin);
68+
}
69+
70+
getPluginByPressedKey(pressedKey: number): ?WatchPlugin {
71+
return this._watchPluginsByKey.get(pressedKey);
72+
}
73+
74+
getPluginsOrderedByKey(): Array<WatchPlugin> {
75+
return Array.from(this._watchPluginsByKey.values()).sort(
76+
(a, b) => a.key - b.key,
77+
);
78+
}
79+
}

packages/jest-cli/src/types.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Copyright (c) 2014-present, Facebook, Inc. All rights reserved.
3+
*
4+
* This source code is licensed under the MIT license found in the
5+
* LICENSE file in the root directory of this source tree.
6+
*
7+
* @flow
8+
*/
9+
10+
import type {GlobalConfig} from 'types/Config';
11+
12+
export type WatchPlugin = {
13+
key: number,
14+
prompt: string,
15+
enter: (globalConfig: GlobalConfig, end: () => mixed) => mixed,
16+
};

packages/jest-cli/src/watch.js

Lines changed: 54 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import type {GlobalConfig} from 'types/Config';
1111
import type {Context} from 'types/Context';
12+
import type {WatchPlugin} from './types';
1213

1314
import ansiEscapes from 'ansi-escapes';
1415
import chalk from 'chalk';
@@ -26,6 +27,7 @@ import TestWatcher from './test_watcher';
2627
import Prompt from './lib/Prompt';
2728
import TestPathPatternPrompt from './test_path_pattern_prompt';
2829
import TestNamePatternPrompt from './test_name_pattern_prompt';
30+
import WatchPluginRegistry from './lib/watch_plugin_registry';
2931
import {KEYS, CLEAR} from './constants';
3032

3133
const isInteractive = process.stdout.isTTY && !isCI;
@@ -47,6 +49,13 @@ export default function watch(
4749
passWithNoTests: true,
4850
});
4951

52+
const watchPlugins = new WatchPluginRegistry(globalConfig.rootDir);
53+
if (globalConfig.watchPlugins != null) {
54+
for (const pluginModulePath of globalConfig.watchPlugins) {
55+
watchPlugins.loadPluginPath(pluginModulePath);
56+
}
57+
}
58+
5059
const prompt = new Prompt();
5160
const testPathPatternPrompt = new TestPathPatternPrompt(outputStream, prompt);
5261
const testNamePatternPrompt = new TestNamePatternPrompt(outputStream, prompt);
@@ -121,7 +130,9 @@ export default function watch(
121130
// and prevent test runs from the previous run.
122131
testWatcher = new TestWatcher({isWatchMode: true});
123132
if (shouldDisplayWatchUsage) {
124-
outputStream.write(usage(globalConfig, hasSnapshotFailure));
133+
outputStream.write(
134+
usage(globalConfig, watchPlugins, hasSnapshotFailure),
135+
);
125136
shouldDisplayWatchUsage = false; // hide Watch Usage after first run
126137
isWatchUsageDisplayed = true;
127138
} else {
@@ -138,12 +149,19 @@ export default function watch(
138149
}).catch(error => console.error(chalk.red(error.stack)));
139150
};
140151

152+
let activePlugin: ?WatchPlugin;
141153
const onKeypress = (key: string) => {
142154
if (key === KEYS.CONTROL_C || key === KEYS.CONTROL_D) {
143155
process.exit(0);
144156
return;
145157
}
146158

159+
if (activePlugin != null) {
160+
// if a plugin is activate, Jest should let it handle keystrokes, so ignore
161+
// them here
162+
return;
163+
}
164+
147165
if (prompt.isEntering()) {
148166
prompt.put(key);
149167
return;
@@ -159,6 +177,20 @@ export default function watch(
159177
return;
160178
}
161179

180+
const matchingWatchPlugin = watchPlugins.getPluginByPressedKey(
181+
parseInt(key, 16),
182+
);
183+
if (matchingWatchPlugin != null) {
184+
// "activate" the plugin, which has jest ignore keystrokes so the plugin
185+
// can handle them
186+
activePlugin = matchingWatchPlugin;
187+
activePlugin.enter(
188+
globalConfig,
189+
// end callback -- returns control to jest to handle keystrokes
190+
() => (activePlugin = null),
191+
);
192+
}
193+
162194
switch (key) {
163195
case KEYS.Q:
164196
process.exit(0);
@@ -236,7 +268,9 @@ export default function watch(
236268
if (!shouldDisplayWatchUsage && !isWatchUsageDisplayed) {
237269
outputStream.write(ansiEscapes.cursorUp());
238270
outputStream.write(ansiEscapes.eraseDown);
239-
outputStream.write(usage(globalConfig, hasSnapshotFailure));
271+
outputStream.write(
272+
usage(globalConfig, watchPlugins, hasSnapshotFailure),
273+
);
240274
isWatchUsageDisplayed = true;
241275
shouldDisplayWatchUsage = false;
242276
}
@@ -247,7 +281,7 @@ export default function watch(
247281
const onCancelPatternPrompt = () => {
248282
outputStream.write(ansiEscapes.cursorHide);
249283
outputStream.write(ansiEscapes.clearScreen);
250-
outputStream.write(usage(globalConfig, hasSnapshotFailure));
284+
outputStream.write(usage(globalConfig, watchPlugins, hasSnapshotFailure));
251285
outputStream.write(ansiEscapes.cursorShow);
252286
};
253287

@@ -284,7 +318,12 @@ const activeFilters = (globalConfig: GlobalConfig, delimiter = '\n') => {
284318
return '';
285319
};
286320

287-
const usage = (globalConfig, snapshotFailure, delimiter = '\n') => {
321+
const usage = (
322+
globalConfig,
323+
watchPlugins: WatchPluginRegistry,
324+
snapshotFailure,
325+
delimiter = '\n',
326+
) => {
288327
const messages = [
289328
activeFilters(globalConfig),
290329

@@ -320,6 +359,17 @@ const usage = (globalConfig, snapshotFailure, delimiter = '\n') => {
320359
't' +
321360
chalk.dim(' to filter by a test name regex pattern.'),
322361

362+
...watchPlugins
363+
.getPluginsOrderedByKey()
364+
.map(
365+
plugin =>
366+
chalk.dim(' \u203A Press') +
367+
' ' +
368+
String.fromCodePoint(plugin.key) +
369+
' ' +
370+
chalk.dim(`to ${plugin.prompt}.`),
371+
),
372+
323373
chalk.dim(' \u203A Press ') + 'q' + chalk.dim(' to quit watch mode.'),
324374

325375
chalk.dim(' \u203A Press ') +

packages/jest-config/src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ const getConfigs = (
112112
verbose: options.verbose,
113113
watch: options.watch,
114114
watchAll: options.watchAll,
115+
watchPlugins: options.watchPlugins,
115116
watchman: options.watchman,
116117
}),
117118
projectConfig: Object.freeze({

packages/jest-config/src/valid_config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,5 +96,6 @@ export default ({
9696
verbose: false,
9797
watch: false,
9898
watchPathIgnorePatterns: [],
99+
watchPlugins: [],
99100
watchman: true,
100101
}: InitialOptions);

test_utils.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
5151
verbose: false,
5252
watch: false,
5353
watchAll: false,
54+
watchPlugins: [],
5455
watchman: false,
5556
};
5657

0 commit comments

Comments
 (0)