Skip to content

Commit f8f966b

Browse files
tddthymikee
authored andcommitted
Check watch plugins for key conflicts (#6697)
## Summary 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 #6693. Refs #6473. ## Test plan Additional tests are provided that check every conflict / overwritable scenario discussed. ## Request for feedback / “spec” evolution The “spec” is an ongoing discussion in #6693 — in particular, the overwritability of some built-in keys, such as `a`, `f` and `o`, may be subject to discussion. This PR tracks the decisions in there and may evolve a bit still. Ping @SimenB @thymikee @rogeliog for review and discussion.
1 parent 08cb885 commit f8f966b

File tree

5 files changed

+250
-9
lines changed

5 files changed

+250
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- `[jest-each]` introduces `%#` option to add index of the test to its title ([#6414](https://github.com/facebook/jest/pull/6414))
88
- `[pretty-format]` Support serializing `DocumentFragment` ([#6705](https://github.com/facebook/jest/pull/6705))
99
- `[jest-validate]` Add `recursive` and `recursiveBlacklist` options for deep config checks ([#6802](https://github.com/facebook/jest/pull/6802))
10+
- `[jest-cli]` Check watch plugins for key conflicts ([#6697](https://github.com/facebook/jest/pull/6697))
1011

1112
### Fixes
1213

docs/WatchPlugins.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,3 +202,30 @@ class MyWatchPlugin {
202202
constructor({config}) {}
203203
}
204204
```
205+
206+
## Choosing a good key
207+
208+
Jest allows third-party plugins to override some of its built-in feature keys, but not all. Specifically, the following keys are **not overwritable** :
209+
210+
- `c` (clears filter patterns)
211+
- `i` (updates non-matching snapshots interactively)
212+
- `q` (quits)
213+
- `u` (updates all non-matching snapshots)
214+
- `w` (displays watch mode usage / available actions)
215+
216+
The following keys for built-in functionality **can be overwritten** :
217+
218+
- `p` (test filename pattern)
219+
- `t` (test name pattern)
220+
221+
Any key not used by built-in functionality can be claimed, as you would expect. Try to avoid using keys that are difficult to obtain on various keyboards (e.g. `é`, ``), or not visible by default (e.g. many Mac keyboards do not have visual hints for characters such as `|`, `\`, `[`, etc.)
222+
223+
### When a conflict happens
224+
225+
Should your plugin attempt to overwrite a reserved key, Jest will error out with a descriptive message, something like:
226+
227+
> Watch plugin YourFaultyPlugin attempted to register key <q>, that is reserved internally for quitting watch mode. Please change the configuration key for this plugin.
228+
229+
Third-party plugins are also forbidden to overwrite a key reserved already by another third-party plugin present earlier in the configured plugins list (`watchPlugins` array setting). When this happens, you’ll also get an error message that tries to help you fix that:
230+
231+
> Watch plugins YourFaultyPlugin and TheirFaultyPlugin both attempted to register key <x>. Please change the key configuration for one of the conflicting plugins to avoid overlap.

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: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ jest.doMock(
7878
class WatchPlugin2 {
7979
getUsageInfo() {
8080
return {
81-
key: 'u',
81+
key: 'r',
8282
prompt: 'do something else',
8383
};
8484
}
@@ -323,7 +323,7 @@ describe('Watch mode flows', () => {
323323
expect(apply).toHaveBeenCalled();
324324
});
325325

326-
it('allows WatchPlugins to override internal plugins', async () => {
326+
it('allows WatchPlugins to override eligible internal plugins', async () => {
327327
const run = jest.fn(() => Promise.resolve());
328328
const pluginPath = `${__dirname}/__fixtures__/plugin_path_override`;
329329
jest.doMock(
@@ -364,6 +364,138 @@ describe('Watch mode flows', () => {
364364
expect(run).toHaveBeenCalled();
365365
});
366366

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

packages/jest-cli/src/watch.js

Lines changed: 86 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {
3737
getSortedUsageRows,
3838
filterInteractivePlugins,
3939
} from './lib/watch_plugins_helpers';
40+
import {ValidationError} from 'jest-validate';
4041
import activeFilters from './lib/active_filters_message';
4142

4243
let hasExitListener = false;
@@ -49,6 +50,18 @@ const INTERNAL_PLUGINS = [
4950
QuitPlugin,
5051
];
5152

53+
const RESERVED_KEY_PLUGINS = new Map([
54+
[
55+
UpdateSnapshotsPlugin,
56+
{forbiddenOverwriteMessage: 'updating snapshots', key: 'u'},
57+
],
58+
[
59+
UpdateSnapshotsInteractivePlugin,
60+
{forbiddenOverwriteMessage: 'updating snapshots interactively', key: 'i'},
61+
],
62+
[QuitPlugin, {forbiddenOverwriteMessage: 'quitting watch mode'}],
63+
]);
64+
5265
export default function watch(
5366
initialGlobalConfig: GlobalConfig,
5467
contexts: Array<Context>,
@@ -122,6 +135,21 @@ export default function watch(
122135
});
123136

124137
if (globalConfig.watchPlugins != null) {
138+
const watchPluginKeys = new Map();
139+
for (const plugin of watchPlugins) {
140+
const reservedInfo = RESERVED_KEY_PLUGINS.get(plugin.constructor) || {};
141+
const key = reservedInfo.key || getPluginKey(plugin, globalConfig);
142+
if (!key) {
143+
continue;
144+
}
145+
const {forbiddenOverwriteMessage} = reservedInfo;
146+
watchPluginKeys.set(key, {
147+
forbiddenOverwriteMessage,
148+
overwritable: forbiddenOverwriteMessage == null,
149+
plugin,
150+
});
151+
}
152+
125153
for (const pluginWithConfig of globalConfig.watchPlugins) {
126154
// $FlowFixMe dynamic require
127155
const ThirdPartyPlugin = require(pluginWithConfig.path);
@@ -130,6 +158,8 @@ export default function watch(
130158
stdin,
131159
stdout: outputStream,
132160
});
161+
checkForConflicts(watchPluginKeys, plugin, globalConfig);
162+
133163
const hookSubscriber = hooks.getSubscriber();
134164
if (plugin.apply) {
135165
plugin.apply(hookSubscriber);
@@ -286,11 +316,7 @@ export default function watch(
286316
const matchingWatchPlugin = filterInteractivePlugins(
287317
watchPlugins,
288318
globalConfig,
289-
).find(plugin => {
290-
const usageData =
291-
(plugin.getUsageInfo && plugin.getUsageInfo(globalConfig)) || {};
292-
return usageData.key === key;
293-
});
319+
).find(plugin => getPluginKey(plugin, globalConfig) === key);
294320

295321
if (matchingWatchPlugin != null) {
296322
// "activate" the plugin, which has jest ignore keystrokes so the plugin
@@ -379,6 +405,61 @@ export default function watch(
379405
return Promise.resolve();
380406
}
381407

408+
const checkForConflicts = (watchPluginKeys, plugin, globalConfig) => {
409+
const key = getPluginKey(plugin, globalConfig);
410+
if (!key) {
411+
return;
412+
}
413+
414+
const conflictor = watchPluginKeys.get(key);
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 = `
426+
Watch plugin ${chalk.bold.red(
427+
getPluginIdentifier(plugin),
428+
)} attempted to register key ${chalk.bold.red(`<${key}>`)},
429+
that is reserved internally for ${chalk.bold.red(
430+
conflictor.forbiddenOverwriteMessage,
431+
)}.
432+
Please change the configuration key for this plugin.`.trim();
433+
} else {
434+
const plugins = [conflictor.plugin, plugin]
435+
.map(p => chalk.bold.red(getPluginIdentifier(p)))
436+
.join(' and ');
437+
error = `
438+
Watch plugins ${plugins} both attempted to register key ${chalk.bold.red(
439+
`<${key}>`,
440+
)}.
441+
Please change the key configuration for one of the conflicting plugins to avoid overlap.`.trim();
442+
}
443+
444+
throw new ValidationError('Watch plugin configuration error', error);
445+
};
446+
447+
const getPluginIdentifier = plugin =>
448+
// This breaks as `displayName` is not defined as a static, but since
449+
// WatchPlugin is an interface, and it is my understanding interface
450+
// static fields are not definable anymore, no idea how to circumvent
451+
// this :-(
452+
// $FlowFixMe: leave `displayName` be.
453+
plugin.constructor.displayName || plugin.constructor.name;
454+
455+
const getPluginKey = (plugin, globalConfig) => {
456+
if (typeof plugin.getUsageInfo === 'function') {
457+
return (plugin.getUsageInfo(globalConfig) || {}).key;
458+
}
459+
460+
return null;
461+
};
462+
382463
const usage = (
383464
globalConfig,
384465
watchPlugins: Array<WatchPlugin>,

0 commit comments

Comments
 (0)