refactor: simplify plugin validation

This commit is contained in:
Pierre Vanduynslager 2018-07-07 00:54:11 -04:00
parent f7f4aabe9e
commit 576eb6027f
7 changed files with 125 additions and 159 deletions

View File

@ -4,7 +4,7 @@ const {toLower, isString} = require('lodash');
const pkg = require('../../package.json');
const {RELEASE_TYPE} = require('./constants');
const homepage = url.format({...url.parse(pkg.homepage), ...{hash: null}});
const homepage = url.format({...url.parse(pkg.homepage), hash: null});
const stringify = obj => (isString(obj) ? obj : inspect(obj, {breakLength: Infinity, depth: 2, maxArrayLength: 5}));
const linkify = file => `${homepage}/blob/caribou/${file}`;
@ -55,25 +55,25 @@ Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`
Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`,
}),
EPLUGINCONF: ({pluginType, pluginConf}) => ({
message: `The \`${pluginType}\` plugin configuration is invalid.`,
details: `The [${pluginType} plugin configuration](${linkify(
`docs/usage/plugins.md#${toLower(pluginType)}-plugin`
EPLUGINCONF: ({type, pluginConf}) => ({
message: `The \`${type}\` plugin configuration is invalid.`,
details: `The [${type} plugin configuration](${linkify(
`docs/usage/plugins.md#${toLower(type)}-plugin`
)}) if defined, must be a single or an array of plugins definition. A plugin definition is either a string or an object with a \`path\` property.
Your configuration for the \`${pluginType}\` plugin is \`${stringify(pluginConf)}\`.`,
Your configuration for the \`${type}\` plugin is \`${stringify(pluginConf)}\`.`,
}),
EPLUGIN: ({pluginName, pluginType}) => ({
message: `A plugin configured in the step ${pluginType} is not a valid semantic-release plugin.`,
details: `A valid \`${pluginType}\` **semantic-release** plugin must be a function or an object with a function in the property \`${pluginType}\`.
EPLUGIN: ({pluginName, type}) => ({
message: `A plugin configured in the step ${type} is not a valid semantic-release plugin.`,
details: `A valid \`${type}\` **semantic-release** plugin must be a function or an object with a function in the property \`${type}\`.
The plugin \`${pluginName}\` doesn't have the property \`${pluginType}\` and cannot be used for the \`${pluginType}\` step.
The plugin \`${pluginName}\` doesn't have the property \`${type}\` and cannot be used for the \`${type}\` step.
Please refer to the \`${pluginName}\` and [semantic-release plugins configuration](${linkify(
'docs/usage/plugins.md'
)}) documentation for more details.`,
}),
EANALYZEOUTPUT: ({result, pluginName}) => ({
EANALYZECOMMITSOUTPUT: ({result, pluginName}) => ({
message: 'The `analyzeCommits` plugin returned an invalid value. It must return a valid semver release type.',
details: `The \`analyzeCommits\` plugin must return a valid [semver](https://semver.org) release type. The valid values are: ${RELEASE_TYPE.map(
type => `\`${type}\``
@ -89,7 +89,7 @@ We recommend to report the issue to the \`${pluginName}\` authors, providing the
'docs/developer-guide/plugin.md'
)})`,
}),
ERELEASENOTESOUTPUT: ({result, pluginName}) => ({
EGENERATENOTESOUTPUT: ({result, pluginName}) => ({
message: 'The `generateNotes` plugin returned an invalid value. It must return a `String`.',
details: `The \`generateNotes\` plugin must return a \`String\`.

View File

@ -6,62 +6,37 @@ const validatePluginConfig = conf => isString(conf) || isString(conf.path) || is
module.exports = {
verifyConditions: {
default: ['@semantic-release/npm', '@semantic-release/github'],
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
analyzeCommits: {
default: '@semantic-release/commit-analyzer',
config: {
validator: conf => Boolean(conf) && validatePluginConfig(conf),
},
output: {
validator: output => !output || RELEASE_TYPE.includes(output),
error: 'EANALYZEOUTPUT',
},
configValidator: conf => Boolean(conf) && validatePluginConfig(conf),
outputValidator: output => !output || RELEASE_TYPE.includes(output),
},
verifyRelease: {
default: false,
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
generateNotes: {
default: '@semantic-release/release-notes-generator',
config: {
validator: conf => !conf || validatePluginConfig(conf),
},
output: {
validator: output => !output || isString(output),
error: 'ERELEASENOTESOUTPUT',
},
configValidator: conf => !conf || validatePluginConfig(conf),
outputValidator: output => !output || isString(output),
},
prepare: {
default: ['@semantic-release/npm'],
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
publish: {
default: ['@semantic-release/npm', '@semantic-release/github'],
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
output: {
validator: output => !output || isPlainObject(output),
error: 'EPUBLISHOUTPUT',
},
configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
outputValidator: output => !output || isPlainObject(output),
},
success: {
default: ['@semantic-release/github'],
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
fail: {
default: ['@semantic-release/github'],
config: {
validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
configValidator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)),
},
};

View File

@ -7,28 +7,27 @@ const normalize = require('./normalize');
module.exports = (options, pluginsPath, logger) => {
const errors = [];
const plugins = Object.keys(PLUGINS_DEFINITIONS).reduce((plugins, pluginType) => {
const {config, default: def} = PLUGINS_DEFINITIONS[pluginType];
const plugins = Object.entries(PLUGINS_DEFINITIONS).reduce((plugins, [type, {configValidator, default: def}]) => {
let pluginConfs;
if (isUndefined(options[pluginType])) {
if (isUndefined(options[type])) {
pluginConfs = def;
} else {
// If an object is passed and the path is missing, set the default one for single plugins
if (isPlainObject(options[pluginType]) && !options[pluginType].path && castArray(def).length === 1) {
options[pluginType].path = def;
if (isPlainObject(options[type]) && !options[type].path && castArray(def).length === 1) {
options[type].path = def;
}
if (config && !config.validator(options[pluginType])) {
errors.push(getError('EPLUGINCONF', {pluginType, pluginConf: options[pluginType]}));
if (configValidator && !configValidator(options[type])) {
errors.push(getError('EPLUGINCONF', {type, pluginConf: options[type]}));
return plugins;
}
pluginConfs = options[pluginType];
pluginConfs = options[type];
}
const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITIONS));
plugins[pluginType] = pipeline(
castArray(pluginConfs).map(conf => normalize(pluginType, pluginsPath, globalOpts, conf, logger))
plugins[type] = pipeline(
castArray(pluginConfs).map(conf => normalize(type, pluginsPath, globalOpts, conf, logger))
);
return plugins;

View File

@ -7,7 +7,7 @@ const PLUGINS_DEFINITIONS = require('../definitions/plugins');
/* eslint max-params: ["error", 5] */
module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger) => {
module.exports = (type, pluginsPath, globalOpts, pluginOpts, logger) => {
if (!pluginOpts) {
return noop;
}
@ -17,9 +17,9 @@ module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger) => {
if (!isFunction(pluginOpts)) {
if (pluginsPath[path]) {
logger.log('Load plugin "%s" from %s in shareable config %s', pluginType, path, pluginsPath[path]);
logger.log('Load plugin "%s" from %s in shareable config %s', type, path, pluginsPath[path]);
} else {
logger.log('Load plugin "%s" from %s', pluginType, path);
logger.log('Load plugin "%s" from %s', type, path);
}
}
@ -33,18 +33,18 @@ module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger) => {
let func;
if (isFunction(plugin)) {
func = plugin.bind(null, cloneDeep({...globalOpts, ...config}));
} else if (isPlainObject(plugin) && plugin[pluginType] && isFunction(plugin[pluginType])) {
func = plugin[pluginType].bind(null, cloneDeep({...globalOpts, ...config}));
} else if (isPlainObject(plugin) && plugin[type] && isFunction(plugin[type])) {
func = plugin[type].bind(null, cloneDeep({...globalOpts, ...config}));
} else {
throw getError('EPLUGIN', {pluginType, pluginName});
throw getError('EPLUGIN', {type, pluginName});
}
const validator = async input => {
const definition = PLUGINS_DEFINITIONS[pluginType];
const {outputValidator} = PLUGINS_DEFINITIONS[type] || {};
try {
const result = await func(cloneDeep(input));
if (definition && definition.output && !definition.output.validator(result)) {
throw getError(PLUGINS_DEFINITIONS[pluginType].output.error, {result, pluginName});
if (outputValidator && !outputValidator(result)) {
throw getError(`E${type.toUpperCase()}OUTPUT`, {result, pluginName});
}
return result;
} catch (err) {

View File

@ -1,133 +1,120 @@
import test from 'ava';
import plugins from '../../lib/definitions/plugins';
import errors from '../../lib/definitions/errors';
test('The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition', t => {
t.false(plugins.verifyConditions.config.validator({}));
t.false(plugins.verifyConditions.config.validator({path: null}));
t.false(plugins.verifyConditions.configValidator({}));
t.false(plugins.verifyConditions.configValidator({path: null}));
t.true(plugins.verifyConditions.config.validator({path: 'plugin-path.js'}));
t.true(plugins.verifyConditions.config.validator());
t.true(plugins.verifyConditions.config.validator('plugin-path.js'));
t.true(plugins.verifyConditions.config.validator(() => {}));
t.true(plugins.verifyConditions.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
t.true(plugins.verifyConditions.configValidator({path: 'plugin-path.js'}));
t.true(plugins.verifyConditions.configValidator());
t.true(plugins.verifyConditions.configValidator('plugin-path.js'));
t.true(plugins.verifyConditions.configValidator(() => {}));
t.true(plugins.verifyConditions.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "analyzeCommits" plugin is mandatory, and must be a single plugin definition', t => {
t.false(plugins.analyzeCommits.config.validator({}));
t.false(plugins.analyzeCommits.config.validator({path: null}));
t.false(plugins.analyzeCommits.config.validator([]));
t.false(plugins.analyzeCommits.config.validator());
t.false(plugins.analyzeCommits.configValidator({}));
t.false(plugins.analyzeCommits.configValidator({path: null}));
t.false(plugins.analyzeCommits.configValidator([]));
t.false(plugins.analyzeCommits.configValidator());
t.true(plugins.analyzeCommits.config.validator({path: 'plugin-path.js'}));
t.true(plugins.analyzeCommits.config.validator('plugin-path.js'));
t.true(plugins.analyzeCommits.config.validator(() => {}));
t.true(plugins.analyzeCommits.configValidator({path: 'plugin-path.js'}));
t.true(plugins.analyzeCommits.configValidator('plugin-path.js'));
t.true(plugins.analyzeCommits.configValidator(() => {}));
});
test('The "verifyRelease" plugin, if defined, must be a single or an array of plugins definition', t => {
t.false(plugins.verifyRelease.config.validator({}));
t.false(plugins.verifyRelease.config.validator({path: null}));
t.false(plugins.verifyRelease.configValidator({}));
t.false(plugins.verifyRelease.configValidator({path: null}));
t.true(plugins.verifyRelease.config.validator({path: 'plugin-path.js'}));
t.true(plugins.verifyRelease.config.validator());
t.true(plugins.verifyRelease.config.validator('plugin-path.js'));
t.true(plugins.verifyRelease.config.validator(() => {}));
t.true(plugins.verifyRelease.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
t.true(plugins.verifyRelease.configValidator({path: 'plugin-path.js'}));
t.true(plugins.verifyRelease.configValidator());
t.true(plugins.verifyRelease.configValidator('plugin-path.js'));
t.true(plugins.verifyRelease.configValidator(() => {}));
t.true(plugins.verifyRelease.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "generateNotes" plugin, if defined, must be a single plugin definition', t => {
t.false(plugins.generateNotes.config.validator({}));
t.false(plugins.generateNotes.config.validator({path: null}));
t.false(plugins.generateNotes.config.validator([]));
t.false(plugins.generateNotes.configValidator({}));
t.false(plugins.generateNotes.configValidator({path: null}));
t.false(plugins.generateNotes.configValidator([]));
t.true(plugins.generateNotes.config.validator());
t.true(plugins.generateNotes.config.validator({path: 'plugin-path.js'}));
t.true(plugins.generateNotes.config.validator('plugin-path.js'));
t.true(plugins.generateNotes.config.validator(() => {}));
t.true(plugins.generateNotes.configValidator());
t.true(plugins.generateNotes.configValidator({path: 'plugin-path.js'}));
t.true(plugins.generateNotes.configValidator('plugin-path.js'));
t.true(plugins.generateNotes.configValidator(() => {}));
});
test('The "prepare" plugin, if defined, must be a single or an array of plugins definition', t => {
t.false(plugins.verifyRelease.config.validator({}));
t.false(plugins.verifyRelease.config.validator({path: null}));
t.false(plugins.verifyRelease.configValidator({}));
t.false(plugins.verifyRelease.configValidator({path: null}));
t.true(plugins.verifyRelease.config.validator({path: 'plugin-path.js'}));
t.true(plugins.verifyRelease.config.validator());
t.true(plugins.verifyRelease.config.validator('plugin-path.js'));
t.true(plugins.verifyRelease.config.validator(() => {}));
t.true(plugins.verifyRelease.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
t.true(plugins.verifyRelease.configValidator({path: 'plugin-path.js'}));
t.true(plugins.verifyRelease.configValidator());
t.true(plugins.verifyRelease.configValidator('plugin-path.js'));
t.true(plugins.verifyRelease.configValidator(() => {}));
t.true(plugins.verifyRelease.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "publish" plugin is mandatory, and must be a single or an array of plugins definition', t => {
t.false(plugins.publish.config.validator({}));
t.false(plugins.publish.config.validator({path: null}));
t.false(plugins.publish.configValidator({}));
t.false(plugins.publish.configValidator({path: null}));
t.true(plugins.publish.config.validator({path: 'plugin-path.js'}));
t.true(plugins.publish.config.validator());
t.true(plugins.publish.config.validator('plugin-path.js'));
t.true(plugins.publish.config.validator(() => {}));
t.true(plugins.publish.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
t.true(plugins.publish.configValidator({path: 'plugin-path.js'}));
t.true(plugins.publish.configValidator());
t.true(plugins.publish.configValidator('plugin-path.js'));
t.true(plugins.publish.configValidator(() => {}));
t.true(plugins.publish.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "success" plugin, if defined, must be a single or an array of plugins definition', t => {
t.false(plugins.success.config.validator({}));
t.false(plugins.success.config.validator({path: null}));
t.false(plugins.success.configValidator({}));
t.false(plugins.success.configValidator({path: null}));
t.true(plugins.success.config.validator({path: 'plugin-path.js'}));
t.true(plugins.success.config.validator());
t.true(plugins.success.config.validator('plugin-path.js'));
t.true(plugins.success.config.validator(() => {}));
t.true(plugins.success.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
t.true(plugins.success.configValidator({path: 'plugin-path.js'}));
t.true(plugins.success.configValidator());
t.true(plugins.success.configValidator('plugin-path.js'));
t.true(plugins.success.configValidator(() => {}));
t.true(plugins.success.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "fail" plugin, if defined, must be a single or an array of plugins definition', t => {
t.false(plugins.fail.config.validator({}));
t.false(plugins.fail.config.validator({path: null}));
t.false(plugins.fail.configValidator({}));
t.false(plugins.fail.configValidator({path: null}));
t.true(plugins.fail.config.validator({path: 'plugin-path.js'}));
t.true(plugins.fail.config.validator());
t.true(plugins.fail.config.validator('plugin-path.js'));
t.true(plugins.fail.config.validator(() => {}));
t.true(plugins.fail.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
t.true(plugins.fail.configValidator({path: 'plugin-path.js'}));
t.true(plugins.fail.configValidator());
t.true(plugins.fail.configValidator('plugin-path.js'));
t.true(plugins.fail.configValidator(() => {}));
t.true(plugins.fail.configValidator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}]));
});
test('The "analyzeCommits" plugin output must be either undefined or a valid semver release type', t => {
t.false(plugins.analyzeCommits.output.validator('invalid'));
t.false(plugins.analyzeCommits.output.validator(1));
t.false(plugins.analyzeCommits.output.validator({}));
t.false(plugins.analyzeCommits.outputValidator('invalid'));
t.false(plugins.analyzeCommits.outputValidator(1));
t.false(plugins.analyzeCommits.outputValidator({}));
t.true(plugins.analyzeCommits.output.validator());
t.true(plugins.analyzeCommits.output.validator(null));
t.true(plugins.analyzeCommits.output.validator('major'));
t.true(plugins.analyzeCommits.outputValidator());
t.true(plugins.analyzeCommits.outputValidator(null));
t.true(plugins.analyzeCommits.outputValidator('major'));
});
test('The "generateNotes" plugin output, if defined, must be a string', t => {
t.false(plugins.generateNotes.output.validator(1));
t.false(plugins.generateNotes.output.validator({}));
t.false(plugins.generateNotes.outputValidator(1));
t.false(plugins.generateNotes.outputValidator({}));
t.true(plugins.generateNotes.output.validator());
t.true(plugins.generateNotes.output.validator(null));
t.true(plugins.generateNotes.output.validator(''));
t.true(plugins.generateNotes.output.validator('string'));
t.true(plugins.generateNotes.outputValidator());
t.true(plugins.generateNotes.outputValidator(null));
t.true(plugins.generateNotes.outputValidator(''));
t.true(plugins.generateNotes.outputValidator('string'));
});
test('The "publish" plugin output, if defined, must be an object', t => {
t.false(plugins.publish.output.validator(1));
t.false(plugins.publish.output.validator('string'));
t.false(plugins.publish.outputValidator(1));
t.false(plugins.publish.outputValidator('string'));
t.true(plugins.publish.output.validator({}));
t.true(plugins.publish.output.validator());
t.true(plugins.publish.output.validator(null));
t.true(plugins.publish.output.validator(''));
});
test('The "analyzeCommits" plugin output definition return an existing error code', t => {
t.true(Object.keys(errors).includes(plugins.analyzeCommits.output.error));
});
test('The "generateNotes" plugin output definition return an existing error code', t => {
t.true(Object.keys(errors).includes(plugins.generateNotes.output.error));
});
test('The "publish" plugin output definition return an existing error code', t => {
t.true(Object.keys(errors).includes(plugins.publish.output.error));
t.true(plugins.publish.outputValidator({}));
t.true(plugins.publish.outputValidator());
t.true(plugins.publish.outputValidator(null));
t.true(plugins.publish.outputValidator(''));
});

View File

@ -4,7 +4,6 @@ import {spy, stub} from 'sinon';
import clearModule from 'clear-module';
import AggregateError from 'aggregate-error';
import SemanticReleaseError from '@semantic-release/error';
import DEFINITIONS from '../lib/definitions/plugins';
import {COMMIT_NAME, COMMIT_EMAIL} from '../lib/definitions/constants';
import {
gitHead as getGitHead,
@ -931,8 +930,6 @@ test.serial('Throw an Error if plugin returns an unexpected value', async t => {
});
const error = await t.throws(semanticRelease(options), Error);
// Verify error message
t.regex(error.message, new RegExp(DEFINITIONS.analyzeCommits.output.message));
t.regex(error.details, /string/);
});

View File

@ -94,8 +94,10 @@ test('Wrap "analyzeCommits" plugin in a function that validate the output of the
const error = await t.throws(plugin());
t.is(error.code, 'EANALYZEOUTPUT');
t.is(error.code, 'EANALYZECOMMITSOUTPUT');
t.is(error.name, 'SemanticReleaseError');
t.truthy(error.message);
t.truthy(error.details);
t.regex(error.details, /2/);
});
@ -105,8 +107,10 @@ test('Wrap "generateNotes" plugin in a function that validate the output of the
const error = await t.throws(plugin());
t.is(error.code, 'ERELEASENOTESOUTPUT');
t.is(error.code, 'EGENERATENOTESOUTPUT');
t.is(error.name, 'SemanticReleaseError');
t.truthy(error.message);
t.truthy(error.details);
t.regex(error.details, /2/);
});
@ -123,6 +127,8 @@ test('Wrap "publish" plugin in a function that validate the output of the plugin
t.is(error.code, 'EPUBLISHOUTPUT');
t.is(error.name, 'SemanticReleaseError');
t.truthy(error.message);
t.truthy(error.details);
t.regex(error.details, /2/);
});
@ -187,6 +193,8 @@ test('Throws an error if the plugin return an object without the expected plugin
t.is(error.code, 'EPLUGIN');
t.is(error.name, 'SemanticReleaseError');
t.truthy(error.message);
t.truthy(error.details);
});
test('Throws an error if the plugin is not found', t => {