diff --git a/README.md b/README.md index 25a487e8..7110e699 100644 --- a/README.md +++ b/README.md @@ -81,19 +81,19 @@ If you need more control over the timing of releases you have a couple of option ### Release steps -After running the tests the command `semantic-release` will execute the following steps: +After running the tests, the command `semantic-release` will execute the following steps: -| Step | Description | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Verify Conditions | Verify all the conditions to proceed with the release with the [verify conditions plugins](docs/usage/plugins.md#verifyconditions-plugin). | -| Get last release | Obtain the commit corresponding to the last release by analyzing [Git tags](https://git-scm.com/book/en/v2/Git-Basics-Tagging). | -| Analyze commits | Determine the type of release with the [analyze commits plugin](docs/usage/plugins.md#analyzecommits-plugin) based on the commits added since the last release. | -| Verify release | Verify the release conformity with the [verify release plugins](docs/usage/plugins.md#verifyrelease-plugin). | -| Generate notes | Generate release notes with the [generate notes plugin](docs/usage/plugins.md#generatenotes-plugin) for the commits added since the last release. | -| Create Git tag | Create a Git tag corresponding to the new release version | -| Prepare | Prepare the release with the [prepare plugins](docs/usage/plugins.md#prepare-plugin). | -| Publish | Publish the release with the [publish plugins](docs/usage/plugins.md#publish-plugin). | -| Notify | Notify of new releases or errors with the [success](docs/usage/plugins.md#success-plugin) and [fail](docs/usage/plugins.md#fail-plugin) plugins. | +| Step | Description | +|-------------------|---------------------------------------------------------------------------------------------------------------------------------| +| Verify Conditions | Verify all the conditions to proceed with the release. | +| Get last release | Obtain the commit corresponding to the last release by analyzing [Git tags](https://git-scm.com/book/en/v2/Git-Basics-Tagging). | +| Analyze commits | Determine the type of release based on the commits added since the last release. | +| Verify release | Verify the release conformity. | +| Generate notes | Generate release notes for the commits added since the last release. | +| Create Git tag | Create a Git tag corresponding to the new release version. | +| Prepare | Prepare the release. | +| Publish | Publish the release. | +| Notify | Notify of new releases or errors. | ## Documentation diff --git a/cli.js b/cli.js index 7583bb1d..1ef5f5ba 100755 --- a/cli.js +++ b/cli.js @@ -22,6 +22,7 @@ Usage: .option('b', {alias: 'branch', describe: 'Git branch to release from', type: 'string', group: 'Options'}) .option('r', {alias: 'repository-url', describe: 'Git repository URL', type: 'string', group: 'Options'}) .option('t', {alias: 'tag-format', describe: 'Git tag format', type: 'string', group: 'Options'}) + .option('p', {alias: 'plugins', describe: 'Plugins', ...stringList, group: 'Options'}) .option('e', {alias: 'extends', describe: 'Shareable configurations', ...stringList, group: 'Options'}) .option('ci', {describe: 'Toggle CI verifications', type: 'boolean', group: 'Options'}) .option('verify-conditions', {...stringList, group: 'Plugins'}) diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index f3fe4388..6db546f8 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -92,6 +92,18 @@ The [Git tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging) format used by **Note**: The `tagFormat` must contain the `version` variable exactly once and compile to a [valid Git reference](https://git-scm.com/docs/git-check-ref-format#_description). +### plugins + +Type: `Array`
+Default: `['@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator', '@semantic-release/npm', '@semantic-release/github']`
+CLI arguments: `-p`, `--plugins` + +Define the list of plugins to use. Plugins will run in series, in the order defined, for each [steps](../../README.md#release-steps) if they implement it. + +Plugins configuration can defined by wrapping the name and an options object in an array. + +See [Plugins configuration](plugins.md#configuration) for more details. + ### dryRun Type: `Boolean`
diff --git a/docs/usage/plugins.md b/docs/usage/plugins.md index d86632b9..beaf1950 100644 --- a/docs/usage/plugins.md +++ b/docs/usage/plugins.md @@ -1,114 +1,78 @@ # Plugins -Each [release step](../../README.md#release-steps) is implemented within a plugin or a list of plugins that can be configured. This allows for support of different [commit message formats](../../README.md#commit-message-format), release note generators and publishing platforms. +Each [release step](../../README.md#release-steps) is implemented by configurable plugins. This allows for support of different [commit message formats](../../README.md#commit-message-format), release note generators and publishing platforms. -See [plugins list](../extending/plugins-list.md). +A plugin is a npm module that can implement one or more of the following steps: -## Plugin types +| Step | Accept multiple | Required | Description | +|--------------------|-----------------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `verifyConditions` | Yes | No | Responsible for verifying conditions necessary to proceed with the release: configuration is correct, authentication token are valid, etc... | +| `analyzeCommits` | No | Yes | Responsible for determining the type of the next release (`major`, `minor` or `patch`). | +| `verifyRelease` | Yes | No | Responsible for verifying the parameters (version, type, dist-tag etc...) of the release that is about to be published. | +| `generateNotes` | Yes | No | Responsible for generating the content of the release note. If multiple `generateNotes` plugins are defined, the release notes will be the result of the concatenation of each plugin output. | +| `prepare` | Yes | No | Responsible for preparing the release, for example creating or updating files such as `package.json`, `CHANGELOG.md`, documentation or compiled assets and pushing a commit. | +| `publish` | Yes | No | Responsible for publishing the release. | +| `success` | Yes | No | Responsible for notifying of a new release. | +| `fail` | Yes | No | Responsible for notifying of a failed release. | -### verifyConditions plugin +See [available plugins](../extending/plugins-list.md). -Responsible for verifying conditions necessary to proceed with the release: configuration is correct, authentication token are valid, etc... +## Plugins configuration -Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#verifyconditions) and [@semantic-release/github](https://github.com/semantic-release/github#verifyconditions).
-Optional.
-Accept multiple plugins. +Each plugin must be installed and configured with the [`plugins` options](./configuration.md#plugins) by specifying the list of plugins by npm module name. -### analyzeCommits plugin +```bash +$ npm install @semantic-release/commit-analyzer @semantic-release/release-notes-generator @semantic-release/npm -D +``` -Responsible for determining the type of the next release (`major`, `minor` or `patch`). - -Default implementation: [@semantic-release/commit-analyzer](https://github.com/semantic-release/commit-analyzer).
-Required.
-Accept only one plugin. - -### verifyRelease plugin - -Responsible for verifying the parameters (version, type, dist-tag etc...) of the release that is about to be published. For example the [cracks plugin](https://github.com/semantic-release/cracks) is able to verify that if a release contains breaking changes, its type must be `major`. - -Default implementation: none.
-Optional.
-Accept multiple plugins. - -### generateNotes plugin - -Responsible for generating release notes. If multiple `generateNotes` plugins are defined, the release notes will be the result of the concatenation of plugin output. - -Default implementation: [@semantic-release/release-notes-generator](https://github.com/semantic-release/release-notes-generator).
-Optional.
-Accept multiple plugins. - -### prepare plugin - -Responsible for preparing the release, including: -- Creating or updating files such as `package.json`, `CHANGELOG.md`, documentation or compiled assets. -- Create and push commits - -Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#prepare).
-Optional.
-Accept multiple plugins. - -### publish plugin - -Responsible for publishing the release. - -Default implementation: [@semantic-release/npm](https://github.com/semantic-release/npm#publish) and [@semantic-release/github](https://github.com/semantic-release/github#publish).
-Optional.
-Accept multiple plugins. - -### success plugin - -Responsible for notifying of a new release. - -Default implementation: [@semantic-release/github](https://github.com/semantic-release/github#success).
-Optional.
-Accept multiple plugins. - -### fail plugin - -Responsible for notifying of a failed release. - -Default implementation: [@semantic-release/github](https://github.com/semantic-release/github#fail).
-Optional.
-Accept multiple plugins. - -## Configuration - -Plugin can be configured by specifying the plugin's module name or file path directly as a `String` or within the `path` key of an `Object`. - -Plugins specific options can be set similarly to the other **semantic-release** [options](configuration.md#options) or within the plugin `Object`. Plugins options defined along with the other **semantic-release** [options](configuration.md#options) will apply to all plugins. Options defined within the plugin `Object` will apply to that specific plugin. - -For example: ```json { - "release": { - "verifyConditions": [ - { - "path": "@semantic-release/exec", - "cmd": "verify-conditions.sh" - }, - "@semantic-release/npm", - "@semantic-release/github" - ], - "analyzeCommits": "custom-plugin", - "verifyRelease": [ - { - "path": "@semantic-release/exec", - "cmd": "verify-release.sh" - }, - ], - "generateNotes": "./build/my-plugin.js", - "githubUrl": "https://my-ghe.com", - "githubApiPathPrefix": "/api-prefix" - } + "plugins": ["@semantic-release/commit-analyzer", "@semantic-release/release-notes-generator", "@semantic-release/npm"] +} +``` + +## Plugin ordering + +For each [release step](../../README.md#release-steps) the plugins that implement that step will be executed in the order in which the are defined. + +```json +{ + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + "@semantic-release/npm", + "@semantic-release/git" + ] +} +``` + +With this configuration **semantic-release** will: +- execute the `verifyConditions` implementation of `@semantic-release/npm` then `@semantic-release/git` +- execute the `analyzeCommits` implementation of `@semantic-release/commit-analyzer` +- execute the `prepare` implementation of `@semantic-release/npm` then `@semantic-release/git` +- execute the `generateNotes` implementation of `@semantic-release/release-notes-generator` +- execute the `publish` implementation of `@semantic-release/npm` + +## Plugin options + +A plugin options can specified by wrapping the name and an options object in an array. Options configured this way will be passed only to that specific plugin. + +Global plugin options can defined at the root of the **semantic-release** configuration object. Options configured this way will be passed to all plugins. + +```json +{ + "plugins": [ + "@semantic-release/commit-analyzer", + "@semantic-release/release-notes-generator", + ["@semantic-release/github", { + "assets": ["dist/**"] + }], + "@semantic-release/git" + ], + "preset": "angular" } ``` With this configuration: -- the `custom-plugin` npm module will be used to [analyze commits](#analyzecommits-plugin) -- the `./build/my-plugin.js` script will be used to [generate release notes](#generatenotes-plugin) -- the [`@semantic-release/exec`](https://github.com/semantic-release/exec), [`@semantic-release/npm`](https://github.com/semantic-release/npm) and [`@semantic-release/github`](https://github.com/semantic-release/github) plugins will be used to [verify conditions](#verifyconditions-plugin) -- the [`@semantic-release/exec`](https://github.com/semantic-release/exec) plugin will be used to [verify the release](#verifyrelease-plugin) -- the `cmd` option will be set to `verify-conditions.sh` only for the [`@semantic-release/exec`](https://github.com/semantic-release/exec) plugin used to [verify conditions](#verifyconditions-plugin) -- the `cmd` option will be set to `verify-release.sh` only for the [`@semantic-release/exec`](https://github.com/semantic-release/exec) plugin used to [verify the release](#verifyrelease-plugin) -- the `githubUrl` and `githubApiPathPrefix` options will be set to respectively `https://my-ghe.com` and `/api-prefix` for all plugins +- All plugins will receive the `preset` option, which will be used by both `@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator` (and ignored by `@semantic-release/github` and `@semantic-release/git`) +- The `@semantic-release/github` plugin will receive the `assets` options (`@semantic-release/git` will not receive it and therefore will use it's default value for that option) diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js index a63b81ee..ccc60ef1 100644 --- a/lib/definitions/errors.js +++ b/lib/definitions/errors.js @@ -61,9 +61,17 @@ Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.` required ? 'is required and ' : '' }must be ${ multiple ? 'a single or an array of plugins' : 'a single plugin' - } definition. A plugin definition is either a string or an object with a \`path\` property. + } definition. A plugin definition is an npm module name, optionnaly wrapped in an array with an object. Your configuration for the \`${type}\` plugin is \`${stringify(pluginConf)}\`.`, + }), + EPLUGINSCONF: ({plugin}) => ({ + message: 'The `plugins` configuration is invalid.', + details: `The [plugins](${linkify( + 'docs/usage/configuration.md#plugins' + )}) option must be an array of plugin definions. A plugin definition is an npm module name, optionnaly wrapped in an array with an object. + +The invalid configuration is \`${stringify(plugin)}\`.`, }), EPLUGIN: ({pluginName, type}) => ({ message: `A plugin configured in the step ${type} is not a valid semantic-release plugin.`, diff --git a/lib/definitions/plugins.js b/lib/definitions/plugins.js index bac4c6c7..97138e88 100644 --- a/lib/definitions/plugins.js +++ b/lib/definitions/plugins.js @@ -6,13 +6,12 @@ const {RELEASE_TYPE, RELEASE_NOTES_SEPARATOR} = require('./constants'); module.exports = { verifyConditions: { - default: ['@semantic-release/npm', '@semantic-release/github'], multiple: true, required: false, pipelineConfig: () => ({settleAll: true}), }, analyzeCommits: { - default: '@semantic-release/commit-analyzer', + default: ['@semantic-release/commit-analyzer'], multiple: false, required: true, outputValidator: output => !output || RELEASE_TYPE.includes(output), @@ -23,13 +22,11 @@ module.exports = { postprocess: ([result]) => result, }, verifyRelease: { - default: false, multiple: true, required: false, pipelineConfig: () => ({settleAll: true}), }, generateNotes: { - default: ['@semantic-release/release-notes-generator'], multiple: true, required: false, outputValidator: output => !output || isString(output), @@ -45,7 +42,6 @@ module.exports = { postprocess: (results, {env}) => hideSensitive(env)(results.filter(Boolean).join(RELEASE_NOTES_SEPARATOR)), }, prepare: { - default: ['@semantic-release/npm'], multiple: true, required: false, pipelineConfig: ({generateNotes}, logger) => ({ @@ -64,7 +60,6 @@ module.exports = { }), }, publish: { - default: ['@semantic-release/npm', '@semantic-release/github'], multiple: true, required: false, outputValidator: output => !output || isPlainObject(output), @@ -78,14 +73,12 @@ module.exports = { }), }, success: { - default: ['@semantic-release/github'], multiple: true, required: false, pipelineConfig: () => ({settleAll: true}), preprocess: ({releases, env, ...inputs}) => ({...inputs, env, releases: hideSensitiveValues(env, releases)}), }, fail: { - default: ['@semantic-release/github'], multiple: true, required: false, pipelineConfig: () => ({settleAll: true}), diff --git a/lib/get-config.js b/lib/get-config.js index 3983e6f7..6ee269ea 100644 --- a/lib/get-config.js +++ b/lib/get-config.js @@ -1,4 +1,4 @@ -const {castArray, pickBy, isUndefined, isNull, isString, isPlainObject} = require('lodash'); +const {castArray, pickBy, isNil, isString, isPlainObject} = require('lodash'); const readPkgUp = require('read-pkg-up'); const cosmiconfig = require('cosmiconfig'); const resolveFrom = require('resolve-from'); @@ -35,7 +35,7 @@ module.exports = async (context, opts) => { // For each plugin defined in a shareable config, save in `pluginsPath` the extendable config path, // so those plugin will be loaded relatively to the config file Object.entries(extendsOpts).reduce((pluginsPath, [option, value]) => { - if (PLUGINS_DEFINITIONS[option]) { + if (PLUGINS_DEFINITIONS[option] || option === 'plugins') { castArray(value) .filter(plugin => isString(plugin) || (isPlainObject(plugin) && isString(plugin.path))) .map(plugin => (isString(plugin) ? plugin : plugin.path)) @@ -57,8 +57,14 @@ module.exports = async (context, opts) => { branch: 'master', repositoryUrl: (await pkgRepoUrl({normalize: false, cwd})) || (await repoUrl({cwd, env})), tagFormat: `v\${version}`, + plugins: [ + '@semantic-release/commit-analyzer', + '@semantic-release/release-notes-generator', + '@semantic-release/npm', + '@semantic-release/github', + ], // Remove `null` and `undefined` options so they can be replaced with default ones - ...pickBy(options, option => !isUndefined(option) && !isNull(option)), + ...pickBy(options, option => !isNil(option)), }; debug('options values: %O', options); diff --git a/lib/get-git-auth-url.js b/lib/get-git-auth-url.js index e19e366a..fe9493e5 100644 --- a/lib/get-git-auth-url.js +++ b/lib/get-git-auth-url.js @@ -1,5 +1,5 @@ const {parse, format} = require('url'); -const {isUndefined} = require('lodash'); +const {isNil} = require('lodash'); const gitUrlParse = require('git-url-parse'); const hostedGitInfo = require('hosted-git-info'); const {verifyAuth} = require('./git'); @@ -44,7 +44,7 @@ module.exports = async ({cwd, env, options: {repositoryUrl, branch}}) => { try { await verifyAuth(repositoryUrl, branch, {cwd, env}); } catch (error) { - const envVar = Object.keys(GIT_TOKENS).find(envVar => !isUndefined(env[envVar])); + const envVar = Object.keys(GIT_TOKENS).find(envVar => !isNil(env[envVar])); const gitCredentials = `${GIT_TOKENS[envVar] || ''}${env[envVar] || ''}`; const {protocols, ...parsed} = gitUrlParse(repositoryUrl); const protocol = protocols.includes('https') ? 'https' : protocols.includes('http') ? 'http' : 'https'; diff --git a/lib/plugins/index.js b/lib/plugins/index.js index 12a2055c..7f7e5708 100644 --- a/lib/plugins/index.js +++ b/lib/plugins/index.js @@ -1,52 +1,89 @@ -const {identity, isPlainObject, omit, castArray, isUndefined} = require('lodash'); +const {identity, isPlainObject, omit, castArray, isNil, isString} = require('lodash'); const AggregateError = require('aggregate-error'); const getError = require('../get-error'); const PLUGINS_DEFINITIONS = require('../definitions/plugins'); -const {validateConfig} = require('./utils'); +const {validatePlugin, validateStep, loadPlugin, parseConfig} = require('./utils'); const pipeline = require('./pipeline'); const normalize = require('./normalize'); module.exports = (context, pluginsPath) => { - const {options, logger} = context; + let {options, logger} = context; const errors = []; - const plugins = Object.entries(PLUGINS_DEFINITIONS).reduce( + + const plugins = options.plugins + ? castArray(options.plugins).reduce((plugins, plugin) => { + if (validatePlugin(plugin)) { + const [name, config] = parseConfig(plugin); + plugin = isString(name) ? loadPlugin(context, name, pluginsPath) : name; + + if (isPlainObject(plugin)) { + Object.entries(plugin).forEach(([type, func]) => { + if (PLUGINS_DEFINITIONS[type]) { + plugins[type] = [...(plugins[type] || []), [func, config]]; + } + }); + } else { + errors.push(getError('EPLUGINSCONF', {plugin})); + } + } else { + errors.push(getError('EPLUGINSCONF', {plugin})); + } + + return plugins; + }, {}) + : []; + + if (errors.length > 0) { + throw new AggregateError(errors); + } + + options = {...plugins, ...options}; + + const pluginsConf = Object.entries(PLUGINS_DEFINITIONS).reduce( ( - plugins, + pluginsConf, [type, {multiple, required, default: def, pipelineConfig, postprocess = identity, preprocess = identity}] ) => { let pluginOpts; - if (isUndefined(options[type])) { + if (isNil(options[type]) && def) { pluginOpts = def; } else { - const defaultPaths = castArray(def); - // If an object is passed and the path is missing, set the default one for single plugins - if (isPlainObject(options[type]) && !options[type].path && defaultPaths.length === 1) { - [options[type].path] = defaultPaths; + // If an object is passed and the path is missing, merge it with step options + if (isPlainObject(options[type]) && !options[type].path) { + options[type] = castArray(plugins[type]).map( + plugin => (plugin ? [plugin[0], Object.assign(plugin[1], options[type])] : plugin) + ); } - if (!validateConfig({multiple, required}, options[type])) { + if (!validateStep({multiple, required}, options[type])) { errors.push(getError('EPLUGINCONF', {type, multiple, required, pluginConf: options[type]})); - return plugins; + return pluginsConf; } pluginOpts = options[type]; } const steps = castArray(pluginOpts).map(pluginOpt => - normalize({...context, options: omit(options, Object.keys(PLUGINS_DEFINITIONS))}, type, pluginOpt, pluginsPath) + normalize( + {...context, options: omit(options, Object.keys(PLUGINS_DEFINITIONS), 'plugins')}, + type, + pluginOpt, + pluginsPath + ) ); - plugins[type] = async input => + pluginsConf[type] = async input => postprocess( - await pipeline(steps, pipelineConfig && pipelineConfig(plugins, logger))(await preprocess(input)), + await pipeline(steps, pipelineConfig && pipelineConfig(pluginsConf, logger))(await preprocess(input)), input ); - return plugins; + return pluginsConf; }, - {} + plugins ); if (errors.length > 0) { throw new AggregateError(errors); } - return plugins; + + return pluginsConf; }; diff --git a/lib/plugins/normalize.js b/lib/plugins/normalize.js index d71bd840..f7cf97e6 100644 --- a/lib/plugins/normalize.js +++ b/lib/plugins/normalize.js @@ -1,22 +1,18 @@ -const {dirname} = require('path'); -const {isString, isPlainObject, isFunction, noop, cloneDeep, omit} = require('lodash'); -const resolveFrom = require('resolve-from'); +const {isPlainObject, isFunction, noop, cloneDeep, omit} = require('lodash'); const getError = require('../get-error'); const {extractErrors} = require('../utils'); const PLUGINS_DEFINITIONS = require('../definitions/plugins'); +const {loadPlugin, parseConfig} = require('./utils'); -module.exports = ({cwd, stdout, stderr, options, logger}, type, pluginOpt, pluginsPath) => { +module.exports = (context, type, pluginOpt, pluginsPath) => { + const {stdout, stderr, options, logger} = context; if (!pluginOpt) { return noop; } - const {path, ...config} = isString(pluginOpt) || isFunction(pluginOpt) ? {path: pluginOpt} : pluginOpt; + const [path, config] = parseConfig(pluginOpt); const pluginName = isFunction(path) ? `[Function: ${path.name}]` : path; - - const basePath = pluginsPath[path] - ? dirname(resolveFrom.silent(__dirname, pluginsPath[path]) || resolveFrom(cwd, pluginsPath[path])) - : __dirname; - const plugin = isFunction(path) ? path : require(resolveFrom.silent(basePath, path) || resolveFrom(cwd, path)); + const plugin = loadPlugin(context, path, pluginsPath); let func; if (isFunction(plugin)) { diff --git a/lib/plugins/utils.js b/lib/plugins/utils.js index 1ece5627..368ac092 100644 --- a/lib/plugins/utils.js +++ b/lib/plugins/utils.js @@ -1,18 +1,68 @@ -const {isString, isFunction, castArray} = require('lodash'); +const {dirname} = require('path'); +const {isString, isFunction, castArray, isArray, isPlainObject, isNil} = require('lodash'); +const resolveFrom = require('resolve-from'); -const validateSingleConfig = conf => { +const validateStepArrayDefinition = conf => + isArray(conf) && + (conf.length === 1 || conf.length === 2) && + (isString(conf[0]) || isFunction(conf[0])) && + (isNil(conf[1]) || isPlainObject(conf[1])); + +const validateSingleStep = conf => { + if (validateStepArrayDefinition(conf)) { + return true; + } conf = castArray(conf); - return conf.length === 1 && (isString(conf[0]) || isString(conf[0].path) || isFunction(conf[0])); + + if (conf.length !== 1) { + return false; + } + + const [path, config] = parseConfig(conf[0]); + return (isString(path) || isFunction(path)) && isPlainObject(config); }; -const validateMultipleConfig = conf => castArray(conf).every(conf => validateSingleConfig(conf)); +const validateMultipleStep = conf => { + return conf.every(conf => validateSingleStep(conf)); +}; -const validateConfig = ({multiple, required}, conf) => { +function validatePlugin(conf) { + return ( + isString(conf) || + (isArray(conf) && + (conf.length === 1 || conf.length === 2) && + (isString(conf[0]) || isPlainObject(conf[0])) && + (isNil(conf[1]) || isPlainObject(conf[1]))) || + (isPlainObject(conf) && (isNil(conf.path) || isString(conf.path) || isPlainObject(conf.path))) + ); +} + +function validateStep({multiple, required}, conf) { conf = castArray(conf).filter(Boolean); if (required) { - return Boolean(conf) && conf.length >= 1 && (multiple ? validateMultipleConfig : validateSingleConfig)(conf); + return conf.length >= 1 && (multiple ? validateMultipleStep : validateSingleStep)(conf); } - return conf.length === 0 || (multiple ? validateMultipleConfig : validateSingleConfig)(conf); -}; + return conf.length === 0 || (multiple ? validateMultipleStep : validateSingleStep)(conf); +} -module.exports = {validateConfig}; +function loadPlugin({cwd}, path, pluginsPath) { + const basePath = pluginsPath[path] + ? dirname(resolveFrom.silent(__dirname, pluginsPath[path]) || resolveFrom(cwd, pluginsPath[path])) + : __dirname; + return isFunction(path) ? path : require(resolveFrom.silent(basePath, path) || resolveFrom(cwd, path)); +} + +function parseConfig(plugin) { + let path; + let config; + if (isArray(plugin)) { + [path, config] = plugin; + } else if (isPlainObject(plugin) && !isNil(plugin.path)) { + ({path, ...config} = plugin); + } else { + path = plugin; + } + return [path, config || {}]; +} + +module.exports = {validatePlugin, validateStep, loadPlugin, parseConfig}; diff --git a/package.json b/package.json index 4670530f..26580671 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,11 @@ "Pierre Vanduynslager (https://twitter.com/@pvdlg_)" ], "dependencies": { - "@semantic-release/commit-analyzer": "^6.0.0", + "@semantic-release/commit-analyzer": "^6.1.0", "@semantic-release/error": "^2.2.0", - "@semantic-release/github": "^5.0.0", - "@semantic-release/npm": "^5.0.1", - "@semantic-release/release-notes-generator": "^7.0.0", + "@semantic-release/github": "^5.1.0", + "@semantic-release/npm": "^5.0.5", + "@semantic-release/release-notes-generator": "^7.1.0", "aggregate-error": "^1.0.0", "cosmiconfig": "^5.0.1", "debug": "^4.0.0", diff --git a/test/cli.test.js b/test/cli.test.js index 1d21a58d..07778dc2 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -33,6 +33,9 @@ test.serial('Pass options to semantic-release API', async t => { 'https://github/com/owner/repo.git', '-t', `v\${version}`, + '-p', + 'plugin1', + 'plugin2', '-e', 'config1', 'config2', @@ -68,6 +71,7 @@ test.serial('Pass options to semantic-release API', async t => { t.is(run.args[0][0].branch, 'master'); t.is(run.args[0][0].repositoryUrl, 'https://github/com/owner/repo.git'); t.is(run.args[0][0].tagFormat, `v\${version}`); + t.deepEqual(run.args[0][0].plugins, ['plugin1', 'plugin2']); t.deepEqual(run.args[0][0].extends, ['config1', 'config2']); t.deepEqual(run.args[0][0].verifyConditions, ['condition1', 'condition2']); t.is(run.args[0][0].analyzeCommits, 'analyze'); @@ -94,6 +98,9 @@ test.serial('Pass options to semantic-release API with alias arguments', async t 'https://github/com/owner/repo.git', '--tag-format', `v\${version}`, + '--plugins', + 'plugin1', + 'plugin2', '--extends', 'config1', 'config2', @@ -106,6 +113,7 @@ test.serial('Pass options to semantic-release API with alias arguments', async t t.is(run.args[0][0].branch, 'master'); t.is(run.args[0][0].repositoryUrl, 'https://github/com/owner/repo.git'); t.is(run.args[0][0].tagFormat, `v\${version}`); + t.deepEqual(run.args[0][0].plugins, ['plugin1', 'plugin2']); t.deepEqual(run.args[0][0].extends, ['config1', 'config2']); t.is(run.args[0][0].dryRun, true); @@ -151,6 +159,8 @@ test.serial('Do not set properties in option for which arg is not in command lin t.false('debug' in run.args[0][0]); t.false('r' in run.args[0][0]); t.false('t' in run.args[0][0]); + t.false('p' in run.args[0][0]); + t.false('e' in run.args[0][0]); }); test.serial('Set "noCi" options to "true" with "--no-ci"', async t => { diff --git a/test/get-config.test.js b/test/get-config.test.js index c34c3855..1cc99612 100644 --- a/test/get-config.test.js +++ b/test/get-config.test.js @@ -8,6 +8,13 @@ import {stub} from 'sinon'; import yaml from 'js-yaml'; import {gitRepo, gitCommits, gitShallowClone, gitAddConfig} from './helpers/git-utils'; +const DEFAULT_PLUGINS = [ + '@semantic-release/commit-analyzer', + '@semantic-release/release-notes-generator', + '@semantic-release/npm', + '@semantic-release/github', +]; + test.beforeEach(t => { t.context.plugins = stub().returns({}); t.context.getConfig = proxyquire('../lib/get-config', {'./plugins': t.context.plugins}); @@ -69,6 +76,7 @@ test('Read options from package.json', async t => { branch: 'test_branch', repositoryUrl: 'https://host.null/owner/module.git', tagFormat: `v\${version}`, + plugins: false, }; // Create package.json in repository root await outputJson(path.resolve(cwd, 'package.json'), {release: options}); @@ -89,6 +97,7 @@ test('Read options from .releaserc.yml', async t => { branch: 'test_branch', repositoryUrl: 'https://host.null/owner/module.git', tagFormat: `v\${version}`, + plugins: false, }; // Create package.json in repository root await writeFile(path.resolve(cwd, '.releaserc.yml'), yaml.safeDump(options)); @@ -109,6 +118,7 @@ test('Read options from .releaserc.json', async t => { branch: 'test_branch', repositoryUrl: 'https://host.null/owner/module.git', tagFormat: `v\${version}`, + plugins: false, }; // Create package.json in repository root await outputJson(path.resolve(cwd, '.releaserc.json'), options); @@ -129,6 +139,7 @@ test('Read options from .releaserc.js', async t => { branch: 'test_branch', repositoryUrl: 'https://host.null/owner/module.git', tagFormat: `v\${version}`, + plugins: false, }; // Create package.json in repository root await writeFile(path.resolve(cwd, '.releaserc.js'), `module.exports = ${JSON.stringify(options)}`); @@ -149,6 +160,7 @@ test('Read options from release.config.js', async t => { branch: 'test_branch', repositoryUrl: 'https://host.null/owner/module.git', tagFormat: `v\${version}`, + plugins: false, }; // Create package.json in repository root await writeFile(path.resolve(cwd, 'release.config.js'), `module.exports = ${JSON.stringify(options)}`); @@ -176,6 +188,7 @@ test('Prioritise CLI/API parameters over file configuration and git repo', async branch: 'branch_cli', repositoryUrl: 'http://cli-url.com/owner/package', tagFormat: `cli\${version}`, + plugins: false, }; const pkg = {release: pkgOptions, repository: 'git@host.null:owner/module.git'}; // Create package.json in repository root @@ -199,6 +212,7 @@ test('Read configuration from file path in "extends"', async t => { branch: 'test_branch', repositoryUrl: 'https://host.null/owner/module.git', tagFormat: `v\${version}`, + plugins: false, }; // Create package.json and shareable.json in repository root await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); @@ -226,6 +240,7 @@ test('Read configuration from module path in "extends"', async t => { branch: 'test_branch', repositoryUrl: 'https://host.null/owner/module.git', tagFormat: `v\${version}`, + plugins: false, }; // Create package.json and shareable.json in repository root await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); @@ -259,6 +274,7 @@ test('Read configuration from an array of paths in "extends"', async t => { analyzeCommits: {path: 'analyzeCommits2', param: 'analyzeCommits_param2'}, branch: 'test_branch', tagFormat: `v\${version}`, + plugins: false, }; // Create package.json and shareable.json in repository root await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); @@ -296,6 +312,7 @@ test('Prioritize configuration from config file over "extends"', async t => { branch: 'test_branch', repositoryUrl: 'https://host.null/owner/module.git', tagFormat: `v\${version}`, + plugins: false, }; // Create package.json and shareable.json in repository root await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); @@ -341,6 +358,7 @@ test('Prioritize configuration from cli/API options over "extends"', async t => publish: [{path: 'publishShareable', param: 'publishShareable_param2'}], branch: 'test_branch2', tagFormat: `v\${version}`, + plugins: false, }; // Create package.json, shareable1.json and shareable2.json in repository root await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); @@ -366,11 +384,13 @@ test('Allow to unset properties defined in shareable config with "null"', async analyzeCommits: null, branch: 'test_branch', repositoryUrl: 'https://host.null/owner/module.git', + plugins: null, }; const options1 = { generateNotes: 'generateNotes', analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'}, tagFormat: `v\${version}`, + plugins: ['test-plugin'], }; // Create package.json and shareable.json in repository root await outputJson(path.resolve(cwd, 'package.json'), {release: pkgOptions}); @@ -378,19 +398,25 @@ test('Allow to unset properties defined in shareable config with "null"', async const {options} = await t.context.getConfig({cwd}); - // Verify the options contains the plugin config from shareable.json - t.deepEqual(options, {...omit(options1, 'analyzeCommits'), ...omit(pkgOptions, ['extends', 'analyzeCommits'])}); - // Verify the plugins module is called with the plugin options from shareable.json + // Verify the options contains the plugin config from shareable.json and the default `plugins` + t.deepEqual(options, { + ...omit(options1, ['analyzeCommits']), + ...omit(pkgOptions, ['extends', 'analyzeCommits']), + plugins: DEFAULT_PLUGINS, + }); + // Verify the plugins module is called with the plugin options from shareable.json and the default `plugins` t.deepEqual(t.context.plugins.args[0][0], { options: { ...omit(options1, 'analyzeCommits'), ...omit(pkgOptions, ['extends', 'analyzeCommits']), + plugins: DEFAULT_PLUGINS, }, cwd, }); t.deepEqual(t.context.plugins.args[0][1], { generateNotes: './shareable.json', analyzeCommits: './shareable.json', + 'test-plugin': './shareable.json', }); }); @@ -407,6 +433,7 @@ test('Allow to unset properties defined in shareable config with "undefined"', a generateNotes: 'generateNotes', analyzeCommits: {path: 'analyzeCommits', param: 'analyzeCommits_param'}, tagFormat: `v\${version}`, + plugins: false, }; // Create package.json and release.config.js in repository root await writeFile(path.resolve(cwd, 'release.config.js'), `module.exports = ${format(pkgOptions)}`); diff --git a/test/index.test.js b/test/index.test.js index 6c08debb..e5c39a5d 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -64,6 +64,7 @@ test('Plugins are called with expected values', async t => { const config = {branch: 'master', repositoryUrl, globalOpt: 'global', tagFormat: `v\${version}`}; const options = { ...config, + plugins: false, verifyConditions: [verifyConditions1, verifyConditions2], analyzeCommits, verifyRelease, @@ -359,6 +360,7 @@ test('Log all "verifyConditions" errors', async t => { const config = {branch: 'master', repositoryUrl, tagFormat: `v\${version}`}; const options = { ...config, + plugins: false, verifyConditions: [stub().rejects(new AggregateError([error1, error2])), stub().rejects(error3)], fail, }; diff --git a/test/plugins/normalize.test.js b/test/plugins/normalize.test.js index fbf045a6..96ae1d8e 100644 --- a/test/plugins/normalize.test.js +++ b/test/plugins/normalize.test.js @@ -152,7 +152,7 @@ test('Wrap "publish" plugin in a function that validate the output of the plugin t.regex(error.details, /2/); }); -test('Plugin is called with "pluginConfig" (omitting "path", adding global config) and input', async t => { +test('Plugin is called with "pluginConfig" (with object definition) and input', async t => { const pluginFunction = stub().resolves(); const pluginConf = {path: pluginFunction, conf: 'confValue'}; const options = {global: 'globalValue'}; @@ -167,6 +167,21 @@ test('Plugin is called with "pluginConfig" (omitting "path", adding global confi ); }); +test('Plugin is called with "pluginConfig" (with array definition) and input', async t => { + const pluginFunction = stub().resolves(); + const pluginConf = [pluginFunction, {conf: 'confValue'}]; + const options = {global: 'globalValue'}; + const plugin = normalize({cwd, options, logger: t.context.logger}, '', pluginConf, {}); + await plugin({param: 'param'}); + + t.true( + pluginFunction.calledWithMatch( + {conf: 'confValue', global: 'globalValue'}, + {param: 'param', logger: t.context.logger} + ) + ); +}); + test('Prevent plugins to modify "pluginConfig"', async t => { const pluginFunction = stub().callsFake(pluginConfig => { pluginConfig.conf.subConf = 'otherConf'; diff --git a/test/plugins/plugins.test.js b/test/plugins/plugins.test.js index 90059645..b42c39d4 100644 --- a/test/plugins/plugins.test.js +++ b/test/plugins/plugins.test.js @@ -29,7 +29,7 @@ test('Export default plugins', t => { t.is(typeof plugins.fail, 'function'); }); -test('Export plugins based on config', t => { +test('Export plugins based on steps config', t => { const plugins = getPlugins( { cwd, @@ -55,6 +55,88 @@ test('Export plugins based on config', t => { t.is(typeof plugins.fail, 'function'); }); +test('Export plugins based on "plugins" config (array)', async t => { + const plugin1 = {verifyConditions: stub(), publish: stub()}; + const plugin2 = {verifyConditions: stub(), verifyRelease: stub()}; + const plugins = getPlugins( + {cwd, logger: t.context.logger, options: {plugins: [plugin1, plugin2], verifyRelease: () => {}}}, + {} + ); + + await plugins.verifyConditions({}); + t.true(plugin1.verifyConditions.calledOnce); + t.true(plugin2.verifyConditions.calledOnce); + + await plugins.publish({}); + t.true(plugin1.publish.calledOnce); + + await plugins.verifyRelease({}); + t.true(plugin2.verifyRelease.notCalled); + + // Verify the module returns a function for each plugin + t.is(typeof plugins.verifyConditions, 'function'); + t.is(typeof plugins.analyzeCommits, 'function'); + t.is(typeof plugins.verifyRelease, 'function'); + t.is(typeof plugins.generateNotes, 'function'); + t.is(typeof plugins.prepare, 'function'); + t.is(typeof plugins.publish, 'function'); + t.is(typeof plugins.success, 'function'); + t.is(typeof plugins.fail, 'function'); +}); + +test('Export plugins based on "plugins" config (single definition)', async t => { + const plugin1 = {verifyConditions: stub(), publish: stub()}; + const plugins = getPlugins({cwd, logger: t.context.logger, options: {plugins: plugin1}}, {}); + + await plugins.verifyConditions({}); + t.true(plugin1.verifyConditions.calledOnce); + + await plugins.publish({}); + t.true(plugin1.publish.calledOnce); + + // Verify the module returns a function for each plugin + t.is(typeof plugins.verifyConditions, 'function'); + t.is(typeof plugins.analyzeCommits, 'function'); + t.is(typeof plugins.verifyRelease, 'function'); + t.is(typeof plugins.generateNotes, 'function'); + t.is(typeof plugins.prepare, 'function'); + t.is(typeof plugins.publish, 'function'); + t.is(typeof plugins.success, 'function'); + t.is(typeof plugins.fail, 'function'); +}); + +test('Merge global options, "plugins" options and sptep options', async t => { + const plugin1 = [{verifyConditions: stub(), publish: stub()}, {pluginOpt1: 'plugin1'}]; + const plugin2 = [{verifyConditions: stub()}, {pluginOpt2: 'plugin2'}]; + const plugin3 = [stub(), {pluginOpt3: 'plugin3'}]; + const plugins = getPlugins( + { + cwd, + logger: t.context.logger, + options: {globalOpt: 'global', plugins: [plugin1, plugin2], verifyRelease: [plugin3]}, + }, + {} + ); + + await plugins.verifyConditions({}); + t.deepEqual(plugin1[0].verifyConditions.args[0][0], {globalOpt: 'global', pluginOpt1: 'plugin1'}); + t.deepEqual(plugin2[0].verifyConditions.args[0][0], {globalOpt: 'global', pluginOpt2: 'plugin2'}); + + await plugins.publish({}); + t.deepEqual(plugin1[0].publish.args[0][0], {globalOpt: 'global', pluginOpt1: 'plugin1'}); + + await plugins.verifyRelease({}); + t.deepEqual(plugin3[0].args[0][0], {globalOpt: 'global', pluginOpt3: 'plugin3'}); +}); + +test('Unknown steps of plugins configured in "plugins" are ignored', t => { + const plugin1 = {verifyConditions: () => {}, unknown: () => {}}; + const plugins = getPlugins({cwd, logger: t.context.logger, options: {plugins: [plugin1]}}, {}); + + t.is(typeof plugins.verifyConditions, 'function'); + t.is(plugins.unknown, undefined); +}); + test('Export plugins loaded from the dependency of a shareable config module', async t => { const cwd = tempy.directory(); await copy( @@ -121,11 +203,23 @@ test('Export plugins loaded from the dependency of a shareable config file', asy test('Use default when only options are passed for a single plugin', t => { const analyzeCommits = {}; const generateNotes = {}; + const publish = {}; const success = () => {}; const fail = [() => {}]; const plugins = getPlugins( - {cwd, logger: t.context.logger, options: {analyzeCommits, generateNotes, success, fail}}, + { + cwd, + logger: t.context.logger, + options: { + plugins: ['@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator'], + analyzeCommits, + generateNotes, + publish, + success, + fail, + }, + }, {} ); @@ -159,14 +253,20 @@ test('Merge global options with plugin options', async t => { t.deepEqual(result.pluginConfig, {localOpt: 'local', globalOpt: 'global', otherOpt: 'locally-defined'}); }); -test('Throw an error if plugins configuration are invalid', t => { +test('Throw an error for each invalid plugin configuration', t => { const errors = [ ...t.throws(() => getPlugins( { cwd, logger: t.context.logger, - options: {verifyConditions: {}, analyzeCommits: [], verifyRelease: [{}], generateNotes: [{path: null}]}, + options: { + plugins: ['@semantic-release/commit-analyzer', '@semantic-release/release-notes-generator'], + verifyConditions: 1, + analyzeCommits: [], + verifyRelease: [{}], + generateNotes: [{path: null}], + }, }, {} ) @@ -182,3 +282,38 @@ test('Throw an error if plugins configuration are invalid', t => { t.is(errors[3].name, 'SemanticReleaseError'); t.is(errors[3].code, 'EPLUGINCONF'); }); + +test('Throw EPLUGINSCONF error if the "plugins" option contains an old plugin definition (returns a function)', t => { + const errors = [ + ...t.throws(() => + getPlugins( + { + cwd, + logger: t.context.logger, + options: {plugins: ['./test/fixtures/multi-plugin', './test/fixtures/plugin-noop', () => {}]}, + }, + {} + ) + ), + ]; + + t.is(errors[0].name, 'SemanticReleaseError'); + t.is(errors[0].code, 'EPLUGINSCONF'); + t.is(errors[1].name, 'SemanticReleaseError'); + t.is(errors[1].code, 'EPLUGINSCONF'); +}); + +test('Throw EPLUGINSCONF error for each invalid definition if the "plugins" option', t => { + const errors = [ + ...t.throws(() => + getPlugins({cwd, logger: t.context.logger, options: {plugins: [1, {path: 1}, [() => {}, {}, {}]]}}, {}) + ), + ]; + + t.is(errors[0].name, 'SemanticReleaseError'); + t.is(errors[0].code, 'EPLUGINSCONF'); + t.is(errors[1].name, 'SemanticReleaseError'); + t.is(errors[1].code, 'EPLUGINSCONF'); + t.is(errors[2].name, 'SemanticReleaseError'); + t.is(errors[2].code, 'EPLUGINSCONF'); +}); diff --git a/test/plugins/utils.test.js b/test/plugins/utils.test.js index 9a1bf350..b7c31e39 100644 --- a/test/plugins/utils.test.js +++ b/test/plugins/utils.test.js @@ -1,58 +1,306 @@ import test from 'ava'; -import {validateConfig} from '../../lib/plugins/utils'; +import {validatePlugin, validateStep, loadPlugin, parseConfig} from '../../lib/plugins/utils'; -test('Validate multiple/optional plugin configuration', t => { +test('validatePlugin', t => { + const path = 'plugin-module'; + const options = {option1: 'value1', option2: 'value2'}; + + t.true(validatePlugin(path), 'String definition'); + t.true(validatePlugin({publish: () => {}}), 'Object definition'); + t.true(validatePlugin([path]), 'Array definition'); + t.true(validatePlugin([path, options]), 'Array definition with options'); + t.true(validatePlugin([{publish: () => {}}, options]), 'Array definition with options and path as object'); + t.true(validatePlugin({path}), 'Object with path definition'); + t.true(validatePlugin({path, ...options}), 'Object with path definition with options'); + t.true( + validatePlugin({path: {publish: () => {}}, ...options}), + 'Object with path definition with options and path as object' + ); + + t.false(validatePlugin(1), 'String definition, wrong path'); + t.false(validatePlugin([]), 'Array definition, missing path'); + t.false(validatePlugin([path, options, {}]), 'Array definition, additional parameter'); + t.false(validatePlugin([1]), 'Array definition, wrong path'); + t.false(validatePlugin([path, 1]), 'Array definition, wrong options'); + t.false(validatePlugin({path: 1}), 'Object definition, wrong path'); +}); + +test('validateStep: multiple/optional plugin configuration', t => { const type = {multiple: true, required: false}; - t.false(validateConfig(type, {})); - t.false(validateConfig(type, {path: null})); - t.true(validateConfig(type, {path: 'plugin-path.js'})); - t.true(validateConfig(type)); - t.true(validateConfig(type, 'plugin-path.js')); - t.true(validateConfig(type, ['plugin-path.js'])); - t.true(validateConfig(type, () => {})); - t.true(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); + // Empty config + t.true(validateStep(type)); + t.true(validateStep(type, [])); + + // Single value definition + t.true(validateStep(type, 'plugin-path.js')); + t.true(validateStep(type, () => {})); + t.true(validateStep(type, ['plugin-path.js'])); + t.true(validateStep(type, [() => {}])); + t.false(validateStep(type, {})); + t.false(validateStep(type, [{}])); + + // Array type definition + t.true(validateStep(type, [['plugin-path.js']])); + t.true(validateStep(type, [['plugin-path.js', {options: 'value'}]])); + t.true(validateStep(type, [[() => {}, {options: 'value'}]])); + t.false(validateStep(type, [['plugin-path.js', 1]])); + + // Object type definition + t.true(validateStep(type, {path: 'plugin-path.js'})); + t.true(validateStep(type, {path: 'plugin-path.js', options: 'value'})); + t.true(validateStep(type, {path: () => {}, options: 'value'})); + t.false(validateStep(type, {path: null})); + + // Considered as an Array of 2 definitions and not as one Array definition in case of a muliple plugin type + t.false(validateStep(type, [() => {}, {options: 'value'}])); + t.false(validateStep(type, ['plugin-path.js', {options: 'value'}])); + + // Multiple definitions + t.true( + validateStep(type, [ + 'plugin-path.js', + () => {}, + ['plugin-path.js'], + ['plugin-path.js', {options: 'value'}], + [() => {}, {options: 'value'}], + {path: 'plugin-path.js'}, + {path: 'plugin-path.js', options: 'value'}, + {path: () => {}, options: 'value'}, + ]) + ); + t.false( + validateStep(type, [ + 'plugin-path.js', + () => {}, + ['plugin-path.js'], + ['plugin-path.js', 1], + [() => {}, {options: 'value'}], + {path: 'plugin-path.js'}, + {path: 'plugin-path.js', options: 'value'}, + {path: () => {}, options: 'value'}, + ]) + ); + t.false( + validateStep(type, [ + 'plugin-path.js', + {}, + ['plugin-path.js'], + ['plugin-path.js', {options: 'value'}], + [() => {}, {options: 'value'}], + {path: 'plugin-path.js'}, + {path: 'plugin-path.js', options: 'value'}, + {path: () => {}, options: 'value'}, + ]) + ); + t.false( + validateStep(type, [ + 'plugin-path.js', + () => {}, + ['plugin-path.js'], + ['plugin-path.js', {options: 'value'}], + [() => {}, {options: 'value'}], + {path: null}, + {path: 'plugin-path.js', options: 'value'}, + {path: () => {}, options: 'value'}, + ]) + ); }); -test('Validate multiple/required plugin configuration', t => { +test('validateStep: multiple/required plugin configuration', t => { const type = {multiple: true, required: true}; - t.false(validateConfig(type, {})); - t.false(validateConfig(type, {path: null})); - t.false(validateConfig(type)); - t.true(validateConfig(type, {path: 'plugin-path.js'})); - t.true(validateConfig(type, 'plugin-path.js')); - t.true(validateConfig(type, ['plugin-path.js'])); - t.true(validateConfig(type, () => {})); - t.true(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); + // Empty config + t.false(validateStep(type)); + t.false(validateStep(type, [])); + + // Single value definition + t.true(validateStep(type, 'plugin-path.js')); + t.true(validateStep(type, () => {})); + t.true(validateStep(type, ['plugin-path.js'])); + t.true(validateStep(type, [() => {}])); + t.false(validateStep(type, {})); + t.false(validateStep(type, [{}])); + + // Array type definition + t.true(validateStep(type, [['plugin-path.js']])); + t.true(validateStep(type, [['plugin-path.js', {options: 'value'}]])); + t.true(validateStep(type, [[() => {}, {options: 'value'}]])); + t.false(validateStep(type, [['plugin-path.js', 1]])); + + // Object type definition + t.true(validateStep(type, {path: 'plugin-path.js'})); + t.true(validateStep(type, {path: 'plugin-path.js', options: 'value'})); + t.true(validateStep(type, {path: () => {}, options: 'value'})); + t.false(validateStep(type, {path: null})); + + // Considered as an Array of 2 definitions and not as one Array definition in the case of a muliple plugin type + t.false(validateStep(type, [() => {}, {options: 'value'}])); + t.false(validateStep(type, ['plugin-path.js', {options: 'value'}])); + + // Multiple definitions + t.true( + validateStep(type, [ + 'plugin-path.js', + () => {}, + ['plugin-path.js'], + ['plugin-path.js', {options: 'value'}], + [() => {}, {options: 'value'}], + {path: 'plugin-path.js'}, + {path: 'plugin-path.js', options: 'value'}, + {path: () => {}, options: 'value'}, + ]) + ); + t.false( + validateStep(type, [ + 'plugin-path.js', + () => {}, + ['plugin-path.js'], + ['plugin-path.js', 1], + [() => {}, {options: 'value'}], + {path: 'plugin-path.js'}, + {path: 'plugin-path.js', options: 'value'}, + {path: () => {}, options: 'value'}, + ]) + ); + t.false( + validateStep(type, [ + 'plugin-path.js', + {}, + ['plugin-path.js'], + ['plugin-path.js', {options: 'value'}], + [() => {}, {options: 'value'}], + {path: 'plugin-path.js'}, + {path: 'plugin-path.js', options: 'value'}, + {path: () => {}, options: 'value'}, + ]) + ); + t.false( + validateStep(type, [ + 'plugin-path.js', + () => {}, + ['plugin-path.js'], + ['plugin-path.js', {options: 'value'}], + [() => {}, {options: 'value'}], + {path: null}, + {path: 'plugin-path.js', options: 'value'}, + {path: () => {}, options: 'value'}, + ]) + ); }); -test('Validate single/required plugin configuration', t => { +test('validateStep: single/required plugin configuration', t => { const type = {multiple: false, required: true}; - t.false(validateConfig(type, {})); - t.false(validateConfig(type, {path: null})); - t.false(validateConfig(type, [])); - t.false(validateConfig(type)); - t.false(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); + // Empty config + t.false(validateStep(type)); + t.false(validateStep(type, [])); - t.true(validateConfig(type, {path: 'plugin-path.js'})); - t.true(validateConfig(type, 'plugin-path.js')); - t.true(validateConfig(type, ['plugin-path.js'])); - t.true(validateConfig(type, () => {})); + // Single value definition + t.true(validateStep(type, 'plugin-path.js')); + t.true(validateStep(type, () => {})); + t.true(validateStep(type, ['plugin-path.js'])); + t.true(validateStep(type, [() => {}])); + t.false(validateStep(type, {})); + t.false(validateStep(type, [{}])); + + // Array type definition + t.true(validateStep(type, [['plugin-path.js']])); + t.true(validateStep(type, [['plugin-path.js', {options: 'value'}]])); + t.true(validateStep(type, [[() => {}, {options: 'value'}]])); + t.false(validateStep(type, [['plugin-path.js', 1]])); + + // Object type definition + t.true(validateStep(type, {path: 'plugin-path.js'})); + t.true(validateStep(type, {path: 'plugin-path.js', options: 'value'})); + t.true(validateStep(type, {path: () => {}, options: 'value'})); + t.false(validateStep(type, {path: null})); + + // Considered as one Array definition and not as an Array of 2 definitions in case of single plugin type + t.true(validateStep(type, [() => {}, {options: 'value'}])); + t.true(validateStep(type, ['plugin-path.js', {options: 'value'}])); + + // Multiple definitions + t.false( + validateStep(type, [ + 'plugin-path.js', + () => {}, + ['plugin-path.js'], + ['plugin-path.js', {options: 'value'}], + [() => {}, {options: 'value'}], + {path: 'plugin-path.js'}, + {path: 'plugin-path.js', options: 'value'}, + {path: () => {}, options: 'value'}, + ]) + ); }); -test('Validate single/optional plugin configuration', t => { +test('validateStep: single/optional plugin configuration', t => { const type = {multiple: false, required: false}; - t.false(validateConfig(type, {})); - t.false(validateConfig(type, {path: null})); - t.false(validateConfig(type, [{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); + // Empty config + t.true(validateStep(type)); + t.true(validateStep(type, [])); - t.true(validateConfig(type)); - t.true(validateConfig(type, [])); - t.true(validateConfig(type, {path: 'plugin-path.js'})); - t.true(validateConfig(type, 'plugin-path.js')); - t.true(validateConfig(type, ['plugin-path.js'])); - t.true(validateConfig(type, () => {})); + // Single value definition + t.true(validateStep(type, 'plugin-path.js')); + t.true(validateStep(type, () => {})); + t.true(validateStep(type, ['plugin-path.js'])); + t.true(validateStep(type, [() => {}])); + t.false(validateStep(type, {})); + t.false(validateStep(type, [{}])); + + // Array type definition + t.true(validateStep(type, [['plugin-path.js']])); + t.true(validateStep(type, [['plugin-path.js', {options: 'value'}]])); + t.true(validateStep(type, [[() => {}, {options: 'value'}]])); + t.false(validateStep(type, [['plugin-path.js', 1]])); + + // Object type definition + t.true(validateStep(type, {path: 'plugin-path.js'})); + t.true(validateStep(type, {path: 'plugin-path.js', options: 'value'})); + t.true(validateStep(type, {path: () => {}, options: 'value'})); + t.false(validateStep(type, {path: null})); + + // Considered as one Array definition and not as an Array of 2 definitions in case of single plugin type + t.true(validateStep(type, [() => {}, {options: 'value'}])); + t.true(validateStep(type, ['plugin-path.js', {options: 'value'}])); + + // Multiple definitions + t.false( + validateStep(type, [ + 'plugin-path.js', + () => {}, + ['plugin-path.js'], + ['plugin-path.js', {options: 'value'}], + [() => {}, {options: 'value'}], + {path: 'plugin-path.js'}, + {path: 'plugin-path.js', options: 'value'}, + {path: () => {}, options: 'value'}, + ]) + ); +}); + +test('loadPlugin', t => { + const cwd = process.cwd(); + const func = () => {}; + + t.is(require('../fixtures/plugin-noop'), loadPlugin({cwd: './test/fixtures'}, './plugin-noop', {}), 'From cwd'); + t.is( + require('../fixtures/plugin-noop'), + loadPlugin({cwd}, './plugin-noop', {'./plugin-noop': './test/fixtures'}), + 'From a shareable config context' + ); + t.is(func, loadPlugin({cwd}, func, {}), 'Defined as a function'); +}); + +test('parseConfig', t => { + const path = 'plugin-module'; + const options = {option1: 'value1', option2: 'value2'}; + + t.deepEqual(parseConfig(path), [path, {}], 'String definition'); + t.deepEqual(parseConfig({path}), [path, {}], 'Object definition'); + t.deepEqual(parseConfig({path, ...options}), [path, options], 'Object definition with options'); + t.deepEqual(parseConfig([path]), [path, {}], 'Array definition'); + t.deepEqual(parseConfig([path, options]), [path, options], 'Array definition with options'); });