Skip to content

Commit e1f7be6

Browse files
tddthymikee
authored andcommitted
Watch plugins now are checked for key conflicts…
- Some built-in keys remain overridable (specificically `t` and `p`). - Any key registered by a third-party watch plugin cannot be overridden by one listed later in the plugins list config. Fixes jestjs#6693. Refs jestjs#6473.
1 parent 0591f22 commit e1f7be6

File tree

4 files changed

+241
-9
lines changed

4 files changed

+241
-9
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020

2121
- `[jest-changed-files]` limit git and hg commands to specified roots ([#6732](https://github.com/facebook/jest/pull/6732))
2222

23+
### Features
24+
25+
- `[jest-cli]` Watch plugin key registrations are now checked for conflicts. Some built-in keys, such as `p` and `t` for test limitation, are overridable, but most built-in keys are reserved (e.g. `q`, `a` or `o`), and any key registered by a third-party plugin cannot be overriden by another third-party plugin. Explicit error messages are logged to the console and Jest exits with code 64 (`EX_USAGE`).
26+
2327
### Fixes
2428

2529
- `[jest-circus]` Fix retryTimes so errors are reset before re-running ([#6762](https://github.com/facebook/jest/pull/6762))

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ Watch Usage
3636
]
3737
`;
3838

39-
exports[`Watch mode flows allows WatchPlugins to override internal plugins 1`] = `
39+
exports[`Watch mode flows allows WatchPlugins to override eligible internal plugins 1`] = `
4040
Array [
4141
"
4242
Watch Usage
@@ -60,8 +60,8 @@ Watch Usage
6060
› Press p to filter by a filename regex pattern.
6161
› Press t to filter by a test name regex pattern.
6262
› Press q to quit watch mode.
63+
› Press r to do something else.
6364
› Press s to do nothing.
64-
› Press u to do something else.
6565
› Press Enter to trigger a test run.
6666
",
6767
],

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

Lines changed: 156 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import chalk from 'chalk';
1212
import TestWatcher from '../TestWatcher';
1313
import {JestHook, KEYS} from 'jest-watcher';
1414

15+
const exitMock = jest.fn();
1516
const runJestMock = jest.fn();
1617
const watchPluginPath = `${__dirname}/__fixtures__/watch_plugin`;
1718
const watchPlugin2Path = `${__dirname}/__fixtures__/watch_plugin2`;
@@ -43,6 +44,7 @@ jest.mock(
4344
);
4445

4546
jest.doMock('chalk', () => new chalk.constructor({enabled: false}));
47+
jest.doMock('exit', () => exitMock);
4648
jest.doMock(
4749
'../runJest',
4850
() =>
@@ -78,7 +80,7 @@ jest.doMock(
7880
class WatchPlugin2 {
7981
getUsageInfo() {
8082
return {
81-
key: 'u',
83+
key: 'r',
8284
prompt: 'do something else',
8385
};
8486
}
@@ -96,6 +98,7 @@ const watch = require('../watch').default;
9698
const nextTick = () => new Promise(res => process.nextTick(res));
9799

98100
afterEach(runJestMock.mockReset);
101+
afterEach(exitMock.mockReset);
99102

100103
describe('Watch mode flows', () => {
101104
let pipe;
@@ -323,7 +326,7 @@ describe('Watch mode flows', () => {
323326
expect(apply).toHaveBeenCalled();
324327
});
325328

326-
it('allows WatchPlugins to override internal plugins', async () => {
329+
it('allows WatchPlugins to override eligible internal plugins', async () => {
327330
const run = jest.fn(() => Promise.resolve());
328331
const pluginPath = `${__dirname}/__fixtures__/plugin_path_override`;
329332
jest.doMock(
@@ -364,6 +367,157 @@ describe('Watch mode flows', () => {
364367
expect(run).toHaveBeenCalled();
365368
});
366369

370+
describe('when dealing with potential watch plugin key conflicts', () => {
371+
beforeEach(() => {
372+
jest.spyOn(console, 'error');
373+
console.error.mockImplementation(() => {});
374+
});
375+
376+
afterEach(() => {
377+
console.error.mockRestore();
378+
});
379+
380+
it.each`
381+
key | plugin
382+
${'q'} | ${'Quit'}
383+
${'u'} | ${'UpdateSnapshots'}
384+
${'i'} | ${'UpdateSnapshotsInteractive'}
385+
`(
386+
'forbids WatchPlugins overriding reserved internal plugins',
387+
({key, plugin}) => {
388+
const run = jest.fn(() => Promise.resolve());
389+
const pluginPath = `${__dirname}/__fixtures__/plugin_bad_override_${key}`;
390+
jest.doMock(
391+
pluginPath,
392+
() =>
393+
class OffendingWatchPlugin {
394+
constructor() {
395+
this.run = run;
396+
}
397+
getUsageInfo() {
398+
return {
399+
key,
400+
prompt: `custom "${key.toUpperCase()}" plugin`,
401+
};
402+
}
403+
},
404+
{virtual: true},
405+
);
406+
407+
watch(
408+
Object.assign({}, globalConfig, {
409+
rootDir: __dirname,
410+
watchPlugins: [{config: {}, path: pluginPath}],
411+
}),
412+
contexts,
413+
pipe,
414+
hasteMapInstances,
415+
stdin,
416+
);
417+
418+
expect(console.error).toHaveBeenLastCalledWith(
419+
expect.stringMatching(
420+
new RegExp(
421+
`Jest configuration error: watch plugin OffendingWatchPlugin attempted to register key <${key}>, that is reserved internally for .+\\.\\s+Please change the configuration key for this plugin\\.`,
422+
'm',
423+
),
424+
),
425+
);
426+
427+
expect(exitMock).toHaveBeenCalled();
428+
},
429+
);
430+
431+
// The jury's still out on 'a', 'c', 'f', 'o', 'w' and '?'…
432+
// See https://github.com/facebook/jest/issues/6693
433+
it.each`
434+
key | plugin
435+
${'t'} | ${'TestNamePattern'}
436+
${'p'} | ${'TestPathPattern'}
437+
`(
438+
'allows WatchPlugins to override non-reserved internal plugins',
439+
({key, plugin}) => {
440+
const run = jest.fn(() => Promise.resolve());
441+
const pluginPath = `${__dirname}/__fixtures__/plugin_valid_override_${key}`;
442+
jest.doMock(
443+
pluginPath,
444+
() =>
445+
class ValidWatchPlugin {
446+
constructor() {
447+
this.run = run;
448+
}
449+
getUsageInfo() {
450+
return {
451+
key,
452+
prompt: `custom "${key.toUpperCase()}" plugin`,
453+
};
454+
}
455+
},
456+
{virtual: true},
457+
);
458+
459+
watch(
460+
Object.assign({}, globalConfig, {
461+
rootDir: __dirname,
462+
watchPlugins: [{config: {}, path: pluginPath}],
463+
}),
464+
contexts,
465+
pipe,
466+
hasteMapInstances,
467+
stdin,
468+
);
469+
470+
expect(exitMock).not.toHaveBeenCalled();
471+
},
472+
);
473+
474+
it('forbids third-party WatchPlugins overriding each other', () => {
475+
const pluginPaths = ['Foo', 'Bar'].map(ident => {
476+
const run = jest.fn(() => Promise.resolve());
477+
const pluginPath = `${__dirname}/__fixtures__/plugin_bad_override_${ident.toLowerCase()}`;
478+
jest.doMock(
479+
pluginPath,
480+
() => {
481+
class OffendingThirdPartyWatchPlugin {
482+
constructor() {
483+
this.run = run;
484+
}
485+
getUsageInfo() {
486+
return {
487+
key: '!',
488+
prompt: `custom "!" plugin ${ident}`,
489+
};
490+
}
491+
}
492+
OffendingThirdPartyWatchPlugin.displayName = `Offending${ident}ThirdPartyWatchPlugin`;
493+
return OffendingThirdPartyWatchPlugin;
494+
},
495+
{virtual: true},
496+
);
497+
return pluginPath;
498+
});
499+
500+
watch(
501+
Object.assign({}, globalConfig, {
502+
rootDir: __dirname,
503+
watchPlugins: pluginPaths.map(path => ({config: {}, path})),
504+
}),
505+
contexts,
506+
pipe,
507+
hasteMapInstances,
508+
stdin,
509+
);
510+
511+
expect(console.error).toHaveBeenLastCalledWith(
512+
expect.stringMatching(
513+
/Jest configuration error: watch plugins OffendingFooThirdPartyWatchPlugin and OffendingBarThirdPartyWatchPlugin both attempted to register key <!>\.\s+Please change the key configuration for one of the conflicting plugins to avoid overlap\./m,
514+
),
515+
);
516+
517+
expect(exitMock).toHaveBeenCalled();
518+
});
519+
});
520+
367521
it('allows WatchPlugins to be configured', async () => {
368522
const pluginPath = `${__dirname}/__fixtures__/plugin_path_with_config`;
369523
jest.doMock(

packages/jest-cli/src/watch.js

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ const INTERNAL_PLUGINS = [
4949
QuitPlugin,
5050
];
5151

52+
const RESERVED_KEY_PLUGINS = new Map([
53+
[
54+
UpdateSnapshotsPlugin,
55+
{forbiddenOverwriteMessage: 'updating snapshots ballpark', key: 'u'},
56+
],
57+
[
58+
UpdateSnapshotsInteractivePlugin,
59+
{forbiddenOverwriteMessage: 'updating snapshots interactively', key: 'i'},
60+
],
61+
[QuitPlugin, {forbiddenOverwriteMessage: 'quitting watch mode'}],
62+
]);
63+
5264
export default function watch(
5365
initialGlobalConfig: GlobalConfig,
5466
contexts: Array<Context>,
@@ -122,6 +134,21 @@ export default function watch(
122134
});
123135

124136
if (globalConfig.watchPlugins != null) {
137+
const watchPluginKeys = new Map();
138+
for (const plugin of watchPlugins) {
139+
const reservedInfo = RESERVED_KEY_PLUGINS.get(plugin.constructor) || {};
140+
const key = reservedInfo.key || getPluginKey(plugin, globalConfig);
141+
if (!key) {
142+
continue;
143+
}
144+
const {forbiddenOverwriteMessage} = reservedInfo;
145+
watchPluginKeys.set(key, {
146+
forbiddenOverwriteMessage,
147+
overwritable: forbiddenOverwriteMessage == null,
148+
plugin,
149+
});
150+
}
151+
125152
for (const pluginWithConfig of globalConfig.watchPlugins) {
126153
// $FlowFixMe dynamic require
127154
const ThirdPartyPlugin = require(pluginWithConfig.path);
@@ -130,6 +157,8 @@ export default function watch(
130157
stdin,
131158
stdout: outputStream,
132159
});
160+
checkForConflicts(watchPluginKeys, plugin, globalConfig);
161+
133162
const hookSubscriber = hooks.getSubscriber();
134163
if (plugin.apply) {
135164
plugin.apply(hookSubscriber);
@@ -286,11 +315,7 @@ export default function watch(
286315
const matchingWatchPlugin = filterInteractivePlugins(
287316
watchPlugins,
288317
globalConfig,
289-
).find(plugin => {
290-
const usageData =
291-
(plugin.getUsageInfo && plugin.getUsageInfo(globalConfig)) || {};
292-
return usageData.key === key;
293-
});
318+
).find(plugin => getPluginKey(plugin, globalConfig) === key);
294319

295320
if (matchingWatchPlugin != null) {
296321
// "activate" the plugin, which has jest ignore keystrokes so the plugin
@@ -379,6 +404,55 @@ export default function watch(
379404
return Promise.resolve();
380405
}
381406

407+
const checkForConflicts = (watchPluginKeys, plugin, globalConfig) => {
408+
const key = getPluginKey(plugin, globalConfig);
409+
if (!key) {
410+
return;
411+
}
412+
413+
const conflictor = watchPluginKeys.get(key);
414+
415+
if (!conflictor || conflictor.overwritable) {
416+
watchPluginKeys.set(key, {
417+
overwritable: false,
418+
plugin,
419+
});
420+
return;
421+
}
422+
423+
let error;
424+
if (conflictor.forbiddenOverwriteMessage) {
425+
error = `Jest configuration error: watch plugin ${getPluginIdentifier(
426+
plugin,
427+
)} attempted to register key <${key}>, that is reserved internally for ${
428+
conflictor.forbiddenOverwriteMessage
429+
}. Please change the configuration key for this plugin.`;
430+
} else {
431+
const plugins = [conflictor.plugin, plugin]
432+
.map(getPluginIdentifier)
433+
.join(' and ');
434+
error = `Jest configuration error: watch plugins ${plugins} both attempted to register key <${key}>. Please change the key configuration for one of the conflicting plugins to avoid overlap.`;
435+
}
436+
console.error('\n\n' + chalk.red(error));
437+
exit(64); // EX_USAGE
438+
};
439+
440+
const getPluginIdentifier = plugin =>
441+
// This breaks as `displayName` is not defined as a static, but since
442+
// WatchPlugin is an interface, and it is my understanding interface
443+
// static fields are not definable anymore, no idea how to circumvent
444+
// this :-(
445+
// $FlowFixMe: leave `displayName` be.
446+
plugin.constructor.displayName || plugin.constructor.name;
447+
448+
const getPluginKey = (plugin, globalConfig) => {
449+
if (typeof plugin.getUsageInfo === 'function') {
450+
return (plugin.getUsageInfo(globalConfig) || {}).key;
451+
}
452+
453+
return null;
454+
};
455+
382456
const usage = (
383457
globalConfig,
384458
watchPlugins: Array<WatchPlugin>,

0 commit comments

Comments
 (0)