Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Check watch plugins for key conflicts #6697

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- `[jest-each]` introduces `%#` option to add index of the test to its title ([#6414](https://github.com/facebook/jest/pull/6414))
- `[pretty-format]` Support serializing `DocumentFragment` ([#6705](https://github.com/facebook/jest/pull/6705))
- `[jest-validate]` Add `recursive` and `recursiveBlacklist` options for deep config checks ([#6802](https://github.com/facebook/jest/pull/6802))
- `[jest-cli]` Check watch plugins for key conflicts ([#6697](https://github.com/facebook/jest/pull/6697))

### Fixes

Expand Down
27 changes: 27 additions & 0 deletions docs/WatchPlugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,3 +202,30 @@ class MyWatchPlugin {
constructor({config}) {}
}
```

## Choosing a good key

Jest allows third-party plugins to override some of its built-in feature keys, but not all. Specifically, the following keys are **not overwritable** :

- `c` (clears filter patterns)
- `i` (updates non-matching snapshots interactively)
- `q` (quits)
- `u` (updates all non-matching snapshots)
- `w` (displays watch mode usage / available actions)

The following keys for built-in functionality **can be overwritten** :

- `p` (test filename pattern)
- `t` (test name pattern)

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.)

### When a conflict happens

Should your plugin attempt to overwrite a reserved key, Jest will error out with a descriptive message, something like:

> Watch plugin YourFaultyPlugin attempted to register key <q>, that is reserved internally for quitting watch mode. Please change the configuration key for this plugin.

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:

> 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.
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Watch Usage
]
`;

exports[`Watch mode flows allows WatchPlugins to override internal plugins 1`] = `
exports[`Watch mode flows allows WatchPlugins to override eligible internal plugins 1`] = `
Array [
"
Watch Usage
Expand All @@ -60,8 +60,8 @@ Watch Usage
› 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 r to do something else.
› Press s to do nothing.
› Press u to do something else.
› Press Enter to trigger a test run.
",
],
Expand Down
136 changes: 134 additions & 2 deletions packages/jest-cli/src/__tests__/watch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ jest.doMock(
class WatchPlugin2 {
getUsageInfo() {
return {
key: 'u',
key: 'r',
prompt: 'do something else',
};
}
Expand Down Expand Up @@ -323,7 +323,7 @@ describe('Watch mode flows', () => {
expect(apply).toHaveBeenCalled();
});

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

describe('when dealing with potential watch plugin key conflicts', () => {
it.each`
key | plugin
${'q'} | ${'Quit'}
${'u'} | ${'UpdateSnapshots'}
${'i'} | ${'UpdateSnapshotsInteractive'}
`(
'forbids WatchPlugins overriding reserved internal plugins',
({key, plugin}) => {
const run = jest.fn(() => Promise.resolve());
const pluginPath = `${__dirname}/__fixtures__/plugin_bad_override_${key}`;
jest.doMock(
pluginPath,
() =>
class OffendingWatchPlugin {
constructor() {
this.run = run;
}
getUsageInfo() {
return {
key,
prompt: `custom "${key.toUpperCase()}" plugin`,
};
}
},
{virtual: true},
);

expect(() => {
watch(
Object.assign({}, globalConfig, {
rootDir: __dirname,
watchPlugins: [{config: {}, path: pluginPath}],
}),
contexts,
pipe,
hasteMapInstances,
stdin,
);
}).toThrowError(
new RegExp(
`Watch plugin OffendingWatchPlugin attempted to register key <${key}>,\\s+that is reserved internally for .+\\.\\s+Please change the configuration key for this plugin\\.`,
'm',
),
);
},
);

// The jury's still out on 'a', 'c', 'f', 'o', 'w' and '?'…
// See https://github.com/facebook/jest/issues/6693
it.each`
key | plugin
${'t'} | ${'TestNamePattern'}
${'p'} | ${'TestPathPattern'}
`(
'allows WatchPlugins to override non-reserved internal plugins',
({key, plugin}) => {
const run = jest.fn(() => Promise.resolve());
const pluginPath = `${__dirname}/__fixtures__/plugin_valid_override_${key}`;
jest.doMock(
pluginPath,
() =>
class ValidWatchPlugin {
constructor() {
this.run = run;
}
getUsageInfo() {
return {
key,
prompt: `custom "${key.toUpperCase()}" plugin`,
};
}
},
{virtual: true},
);

watch(
Object.assign({}, globalConfig, {
rootDir: __dirname,
watchPlugins: [{config: {}, path: pluginPath}],
}),
contexts,
pipe,
hasteMapInstances,
stdin,
);
},
);

it('forbids third-party WatchPlugins overriding each other', () => {
const pluginPaths = ['Foo', 'Bar'].map(ident => {
const run = jest.fn(() => Promise.resolve());
const pluginPath = `${__dirname}/__fixtures__/plugin_bad_override_${ident.toLowerCase()}`;
jest.doMock(
pluginPath,
() => {
class OffendingThirdPartyWatchPlugin {
constructor() {
this.run = run;
}
getUsageInfo() {
return {
key: '!',
prompt: `custom "!" plugin ${ident}`,
};
}
}
OffendingThirdPartyWatchPlugin.displayName = `Offending${ident}ThirdPartyWatchPlugin`;
return OffendingThirdPartyWatchPlugin;
},
{virtual: true},
);
return pluginPath;
});

expect(() => {
watch(
Object.assign({}, globalConfig, {
rootDir: __dirname,
watchPlugins: pluginPaths.map(path => ({config: {}, path})),
}),
contexts,
pipe,
hasteMapInstances,
stdin,
);
}).toThrowError(
/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,
);
});
});

it('allows WatchPlugins to be configured', async () => {
const pluginPath = `${__dirname}/__fixtures__/plugin_path_with_config`;
jest.doMock(
Expand Down
91 changes: 86 additions & 5 deletions packages/jest-cli/src/watch.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
getSortedUsageRows,
filterInteractivePlugins,
} from './lib/watch_plugins_helpers';
import {ValidationError} from 'jest-validate';
import activeFilters from './lib/active_filters_message';

let hasExitListener = false;
Expand All @@ -49,6 +50,18 @@ const INTERNAL_PLUGINS = [
QuitPlugin,
];

const RESERVED_KEY_PLUGINS = new Map([
[
UpdateSnapshotsPlugin,
{forbiddenOverwriteMessage: 'updating snapshots', key: 'u'},
],
[
UpdateSnapshotsInteractivePlugin,
{forbiddenOverwriteMessage: 'updating snapshots interactively', key: 'i'},
],
[QuitPlugin, {forbiddenOverwriteMessage: 'quitting watch mode'}],
]);

export default function watch(
initialGlobalConfig: GlobalConfig,
contexts: Array<Context>,
Expand Down Expand Up @@ -122,6 +135,21 @@ export default function watch(
});

if (globalConfig.watchPlugins != null) {
const watchPluginKeys = new Map();
for (const plugin of watchPlugins) {
const reservedInfo = RESERVED_KEY_PLUGINS.get(plugin.constructor) || {};
const key = reservedInfo.key || getPluginKey(plugin, globalConfig);
if (!key) {
continue;
}
const {forbiddenOverwriteMessage} = reservedInfo;
watchPluginKeys.set(key, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that the key is gotten from getUsageInfo(...) , a plugin can update their designated key at any time. This is not a documented(or common) use case, so I think if we don't worry about it in this PR.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed. There is no formal way to obtain a list of all possible keys claimed by a plugin, so we have to trust first invocation. Context-based key changes are expected to be rather rare, though (although snapshot updates are a prime example, for instance), so let's not focus too much on that now.

forbiddenOverwriteMessage,
overwritable: forbiddenOverwriteMessage == null,
plugin,
});
}

for (const pluginWithConfig of globalConfig.watchPlugins) {
// $FlowFixMe dynamic require
const ThirdPartyPlugin = require(pluginWithConfig.path);
Expand All @@ -130,6 +158,8 @@ export default function watch(
stdin,
stdout: outputStream,
});
checkForConflicts(watchPluginKeys, plugin, globalConfig);

const hookSubscriber = hooks.getSubscriber();
if (plugin.apply) {
plugin.apply(hookSubscriber);
Expand Down Expand Up @@ -286,11 +316,7 @@ export default function watch(
const matchingWatchPlugin = filterInteractivePlugins(
watchPlugins,
globalConfig,
).find(plugin => {
const usageData =
(plugin.getUsageInfo && plugin.getUsageInfo(globalConfig)) || {};
return usageData.key === key;
});
).find(plugin => getPluginKey(plugin, globalConfig) === key);

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

const checkForConflicts = (watchPluginKeys, plugin, globalConfig) => {
const key = getPluginKey(plugin, globalConfig);
if (!key) {
return;
}

const conflictor = watchPluginKeys.get(key);
if (!conflictor || conflictor.overwritable) {
watchPluginKeys.set(key, {
overwritable: false,
plugin,
});
return;
}

let error;
if (conflictor.forbiddenOverwriteMessage) {
error = `
Watch plugin ${chalk.bold.red(
getPluginIdentifier(plugin),
)} attempted to register key ${chalk.bold.red(`<${key}>`)},
that is reserved internally for ${chalk.bold.red(
conflictor.forbiddenOverwriteMessage,
)}.
Please change the configuration key for this plugin.`.trim();
} else {
const plugins = [conflictor.plugin, plugin]
.map(p => chalk.bold.red(getPluginIdentifier(p)))
.join(' and ');
error = `
Watch plugins ${plugins} both attempted to register key ${chalk.bold.red(
`<${key}>`,
)}.
Please change the key configuration for one of the conflicting plugins to avoid overlap.`.trim();
}

throw new ValidationError('Watch plugin configuration error', error);
};

const getPluginIdentifier = plugin =>
// This breaks as `displayName` is not defined as a static, but since
// WatchPlugin is an interface, and it is my understanding interface
// static fields are not definable anymore, no idea how to circumvent
// this :-(
// $FlowFixMe: leave `displayName` be.
plugin.constructor.displayName || plugin.constructor.name;

const getPluginKey = (plugin, globalConfig) => {
if (typeof plugin.getUsageInfo === 'function') {
return (plugin.getUsageInfo(globalConfig) || {}).key;
}

return null;
};

const usage = (
globalConfig,
watchPlugins: Array<WatchPlugin>,
Expand Down