From 49f5e704ba0670cebff41853e29a747d93af0874 Mon Sep 17 00:00:00 2001 From: Pierre Vanduynslager Date: Fri, 2 Feb 2018 15:24:57 -0500 Subject: [PATCH] feat: add `success` and `fail` notification plugins - Allow `publish` plugins to return an `Object` with information related to the releases - Add the `success` plugin hook, called when all `publish` are successful, receiving a list of release - Add the `fail` plugin hook, called when an error happens at any point, receiving a list of errors - Add detailed message for each error --- README.md | 4 +- cli.js | 9 +- docs/extending/plugins-list.md | 6 +- docs/usage/configuration.md | 4 +- docs/usage/plugins.md | 12 ++ index.js | 108 +++++++---- lib/definitions/errors.js | 118 ++++++++++++ lib/definitions/plugins.js | 61 ++++++ lib/definitions/release-types.js | 1 + lib/get-config.js | 4 +- lib/get-error.js | 7 + lib/git.js | 1 - lib/plugins/definitions.js | 55 ------ lib/plugins/index.js | 27 +-- lib/plugins/normalize.js | 43 +++-- lib/plugins/pipeline.js | 60 ++++-- lib/utils.js | 7 + lib/verify.js | 29 +-- package.json | 7 +- test/definitions/plugins.test.js | 122 ++++++++++++ test/fixtures/plugin-errors.js | 5 + test/fixtures/plugin-identity.js | 1 + test/index.test.js | 320 ++++++++++++++++++++++--------- test/integration.test.js | 27 ++- test/plugins/definitions.test.js | 77 -------- test/plugins/normalize.test.js | 88 +++++++-- test/plugins/pipeline.test.js | 73 +++++-- test/plugins/plugins.test.js | 26 +-- test/verify.test.js | 23 +-- 29 files changed, 917 insertions(+), 408 deletions(-) create mode 100644 lib/definitions/errors.js create mode 100644 lib/definitions/plugins.js create mode 100644 lib/definitions/release-types.js create mode 100644 lib/get-error.js delete mode 100644 lib/plugins/definitions.js create mode 100644 lib/utils.js create mode 100644 test/definitions/plugins.test.js create mode 100644 test/fixtures/plugin-errors.js create mode 100644 test/fixtures/plugin-identity.js delete mode 100644 test/plugins/definitions.test.js diff --git a/README.md b/README.md index 45283501..4368f490 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,11 @@ This removes the immediate connection between human emotions and version numbers - Fully automated release - Enforce [Semantic Versioning](https://semver.org) specification - New features and fixes are immediately available to users +- Notify maintainers and users of new releases - Use formalized commit message convention to document changes in the codebase - Integrate with your [continuous integration workflow](docs/recipes/README.md#ci-configurations) - Avoid potential errors associated with manual releases -- Support any [package managers and languages](docs/recipes/README.md#package-managers-and-languages) via [plugins](docs/usage/plugins.md) +- Support any [package managers and languages](docs/recipes/README.md#package-managers-and-languages) via [plugins](docs/usage/plugins.md) - Simple and reusable configuration via [shareable configurations](docs/usage/shareable-configurations.md) ## How does it work? @@ -86,6 +87,7 @@ After running the tests the command `semantic-release` will execute the followin | 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 the new release version | | 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. | ## Documentation diff --git a/cli.js b/cli.js index 6d48c841..658bd495 100755 --- a/cli.js +++ b/cli.js @@ -2,7 +2,10 @@ const program = require('commander'); const {pickBy, isUndefined} = require('lodash'); function list(values) { - return values.split(',').map(value => value.trim()); + return values + .split(',') + .map(value => value.trim()) + .filter(value => value && value !== 'false'); } module.exports = async () => { @@ -26,6 +29,8 @@ module.exports = async () => { ) .option('--generate-notes ', 'Path or package name for the generateNotes plugin') .option('--publish ', 'Comma separated list of paths or packages name for the publish plugin(s)', list) + .option('--success ', 'Comma separated list of paths or packages name for the success plugin(s)', list) + .option('--fail ', 'Comma separated list of paths or packages name for the fail plugin(s)', list) .option( '--no-ci', 'Skip Continuous Integration environment verifications, allowing to make releases from a local machine' @@ -48,7 +53,7 @@ module.exports = async () => { process.exitCode = 1; } else { const opts = program.opts(); - // Set the `noCi` options as commander.js sets the `ci` options instead (becasue args starts with `--no`) + // Set the `noCi` options as commander.js sets the `ci` options instead (because args starts with `--no`) opts.noCi = opts.ci === false ? true : undefined; // Remove option with undefined values, as commander.js sets non defined options as `undefined` await require('.')(pickBy(opts, value => !isUndefined(value))); diff --git a/docs/extending/plugins-list.md b/docs/extending/plugins-list.md index e3af9d11..bfc82308 100644 --- a/docs/extending/plugins-list.md +++ b/docs/extending/plugins-list.md @@ -5,6 +5,8 @@ - [@semantic-release/github](https://github.com/semantic-release/github) - [verifyConditions](https://github.com/semantic-release/github#verifyconditions): Verify the presence and the validity of the GitHub authentication and release configuration - [publish](https://github.com/semantic-release/github#publish): Publish a [GitHub release](https://help.github.com/articles/about-releases) + - [success](https://github.com/semantic-release/github#success): Add a comment to GitHub issues and pull requests resolved in the release + - [fail](https://github.com/semantic-release/github#fail): Open a GitHub issue when a release fails - [@semantic-release/npm](https://github.com/semantic-release/npm) - [verifyConditions](https://github.com/semantic-release/npm#verifyconditions): Verify the presence and the validity of the npm authentication and release configuration - [publish](https://github.com/semantic-release/npm#publish): Publish the package on the npm registry @@ -25,6 +27,8 @@ - [analyzeCommits](https://github.com/semantic-release/exec#analyzecommits): Execute a shell command to determine the type of release - [verifyRelease](https://github.com/semantic-release/exec#verifyrelease): Execute a shell command to verifying a release that was determined before and is about to be published. - [generateNotes](https://github.com/semantic-release/exec#analyzecommits): Execute a shell command to generate the release note - - [publish](https://github.com/semantic-release/exec#publish): Execute a shell command to publish the release. + - [publish](https://github.com/semantic-release/exec#publish): Execute a shell command to publish the release + - [success](https://github.com/semantic-release/exec#success): Execute a shell command to notify of a new release + - [fail](https://github.com/semantic-release/exec#fail): Execute a shell command to notify of a failed release ## Community plugins diff --git a/docs/usage/configuration.md b/docs/usage/configuration.md index b4062285..c66ae8a7 100644 --- a/docs/usage/configuration.md +++ b/docs/usage/configuration.md @@ -59,7 +59,7 @@ Default: `repository` property in `package.json` or [git origin url](https://git CLI arguments: `-r`, `--repository-url` -The git repository URL +The git repository URL. Any valid git url format is supported (See [Git protocols](https://git-scm.com/book/en/v2/Git-on-the-Server-The-Protocols)). @@ -75,7 +75,7 @@ CLI arguments: `-t`, `--tag-format` The [Git tag](https://git-scm.com/book/en/v2/Git-Basics-Tagging) format used by **semantic-release** to identify releases. The tag name is generated with [Lodash template](https://lodash.com/docs#template) and will be compiled with the `version` variable. -**Note**: The `tagFormat` must contain the `version` variable and compile to a [valid Git reference](https://git-scm.com/docs/git-check-ref-format#_description). +**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). ### dryRun diff --git a/docs/usage/plugins.md b/docs/usage/plugins.md index 89beec61..62e2c05d 100644 --- a/docs/usage/plugins.md +++ b/docs/usage/plugins.md @@ -34,6 +34,18 @@ Plugin responsible for publishing the release. Default implementation: [npm](https://github.com/semantic-release/npm#publish) and [github](https://github.com/semantic-release/github#publish). +### success plugin + +Plugin responsible for notifying of a new release. + +Default implementation: [github](https://github.com/semantic-release/github#success). + +### fail plugin + +Plugin responsible for notifying of a failed release. + +Default implementation: [github](https://github.com/semantic-release/github#fail). + ## 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`. diff --git a/index.js b/index.js index fa6799c9..61ef9626 100644 --- a/index.js +++ b/index.js @@ -1,4 +1,4 @@ -const {template, isFunction} = require('lodash'); +const {template, isPlainObject, castArray} = require('lodash'); const marked = require('marked'); const TerminalRenderer = require('marked-terminal'); const envCi = require('env-ci'); @@ -9,13 +9,14 @@ const verify = require('./lib/verify'); const getNextVersion = require('./lib/get-next-version'); const getCommits = require('./lib/get-commits'); const getLastRelease = require('./lib/get-last-release'); +const {extractErrors} = require('./lib/utils'); const logger = require('./lib/logger'); const {unshallow, gitHead: getGitHead, tag, push, deleteTag} = require('./lib/git'); -async function run(opts) { +marked.setOptions({renderer: new TerminalRenderer()}); + +async function run(options, plugins) { const {isCi, branch, isPr} = envCi(); - const config = await getConfig(opts, logger); - const {plugins, options} = config; if (!isCi && !options.dryRun && !options.noCi) { logger.log('This run was not triggered in a known CI environment, running in dry-run mode.'); @@ -34,7 +35,7 @@ async function run(opts) { logger.log('Run automated release from branch %s', options.branch); logger.log('Call plugin %s', 'verify-conditions'); - await plugins.verifyConditions({options, logger}, true); + await plugins.verifyConditions({options, logger}, {settleAll: true}); // Unshallow the repo in order to get all the tags await unshallow(); @@ -57,14 +58,13 @@ async function run(opts) { const nextRelease = {type, version, gitHead: await getGitHead(), gitTag: template(options.tagFormat)({version})}; logger.log('Call plugin %s', 'verify-release'); - await plugins.verifyRelease({options, logger, lastRelease, commits, nextRelease}, true); + await plugins.verifyRelease({options, logger, lastRelease, commits, nextRelease}, {settleAll: true}); const generateNotesParam = {options, logger, lastRelease, commits, nextRelease}; if (options.dryRun) { logger.log('Call plugin %s', 'generate-notes'); const notes = await plugins.generateNotes(generateNotesParam); - marked.setOptions({renderer: new TerminalRenderer()}); logger.log('Release note for version %s:\n', nextRelease.version); process.stdout.write(`${marked(notes)}\n`); } else { @@ -77,45 +77,85 @@ async function run(opts) { await push(options.repositoryUrl, branch); logger.log('Call plugin %s', 'publish'); - await plugins.publish({options, logger, lastRelease, commits, nextRelease}, false, async prevInput => { - const newGitHead = await getGitHead(); - // If previous publish plugin has created a commit (gitHead changed) - if (prevInput.nextRelease.gitHead !== newGitHead) { - // Delete the previously created tag - await deleteTag(options.repositoryUrl, nextRelease.gitTag); - // Recreate the tag, referencing the new gitHead - logger.log('Create tag %s', nextRelease.gitTag); - await tag(nextRelease.gitTag); - await push(options.repositoryUrl, branch); + const releases = await plugins.publish( + {options, logger, lastRelease, commits, nextRelease}, + { + getNextInput: async lastResult => { + const newGitHead = await getGitHead(); + // If previous publish plugin has created a commit (gitHead changed) + if (lastResult.nextRelease.gitHead !== newGitHead) { + // Delete the previously created tag + await deleteTag(options.repositoryUrl, nextRelease.gitTag); + // Recreate the tag, referencing the new gitHead + logger.log('Create tag %s', nextRelease.gitTag); + await tag(nextRelease.gitTag); + await push(options.repositoryUrl, branch); - nextRelease.gitHead = newGitHead; - // Regenerate the release notes - logger.log('Call plugin %s', 'generateNotes'); - nextRelease.notes = await plugins.generateNotes(generateNotesParam); + nextRelease.gitHead = newGitHead; + // Regenerate the release notes + logger.log('Call plugin %s', 'generateNotes'); + nextRelease.notes = await plugins.generateNotes(generateNotesParam); + } + // Call the next publish plugin with the updated `nextRelease` + return {options, logger, lastRelease, commits, nextRelease}; + }, + // Add nextRelease and plugin properties to published release + transform: (release, step) => ({...(isPlainObject(release) ? release : {}), ...nextRelease, ...step}), } - // Call the next publish plugin with the updated `nextRelease` - return {options, logger, lastRelease, commits, nextRelease}; - }); + ); + + await plugins.success( + {options, logger, lastRelease, commits, nextRelease, releases: castArray(releases)}, + {settleAll: true} + ); + logger.log('Published release: %s', nextRelease.version); } return true; } +function logErrors(err) { + const errors = extractErrors(err).sort(error => (error.semanticRelease ? -1 : 0)); + for (const error of errors) { + if (error.semanticRelease) { + logger.log(`%s ${error.message}`, error.code); + if (error.details) { + process.stdout.write(`${marked(error.details)}\n`); + } + } else { + logger.error('An error occurred while running semantic-release: %O', error); + } + } +} + +async function callFail(plugins, options, error) { + const errors = extractErrors(error).filter(error => error.semanticRelease); + if (errors.length > 0) { + try { + await plugins.fail({options, logger, errors}, {settleAll: true}); + } catch (err) { + logErrors(err); + } + } +} + module.exports = async opts => { const unhook = hookStd({silent: false}, hideSensitive); try { - const result = await run(opts); - unhook(); - return result; - } catch (err) { - const errors = err && isFunction(err[Symbol.iterator]) ? [...err].sort(error => !error.semanticRelease) : [err]; - for (const error of errors) { - if (error.semanticRelease) { - logger.log(`%s ${error.message}`, error.code); - } else { - logger.error('An error occurred while running semantic-release: %O', error); + const config = await getConfig(opts, logger); + const {plugins, options} = config; + try { + const result = await run(options, plugins); + unhook(); + return result; + } catch (err) { + if (!options.dryRun) { + await callFail(plugins, options, err); } + throw err; } + } catch (err) { + logErrors(err); unhook(); throw err; } diff --git a/lib/definitions/errors.js b/lib/definitions/errors.js new file mode 100644 index 00000000..4e742c89 --- /dev/null +++ b/lib/definitions/errors.js @@ -0,0 +1,118 @@ +const url = require('url'); +const {inspect} = require('util'); +const {toLower, isString} = require('lodash'); +const pkg = require('../../package.json'); +const RELEASE_TYPE = require('./release-types'); + +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}`; + +module.exports = { + ENOGITREPO: () => ({ + message: 'Not running from a git repository.', + details: `The \`semantic-release\` command must be executed from a Git repository. + +The current working directory is \`${process.cwd()}\`. + +Please verify your CI configuration to make sure the \`semantic-release\` command is executed from the root of the cloned repository.`, + }), + ENOREPOURL: () => ({ + message: 'The `repositoryUrl` option is required.', + details: `The [repositoryUrl option](${linkify( + 'docs/usage/configuration.md#repositoryurl' + )}) cannot be determined from the semantic-release configuration, the \`package.json\` nor the [git origin url](https://git-scm.com/book/en/v2/Git-Basics-Working-with-Remotes). + +Please make sure to add the \`repositoryUrl\` to the [semantic-release configuration] (${linkify( + 'docs/usage/configuration.md' + )}).`, + }), + EGITNOPERMISSION: ({options}) => ({ + message: 'The push permission to the Git repository is required.', + details: `**semantic-release** cannot push the version tag to the branch \`${ + options.branch + }\` on remote Git repository. + +Please refer to the [authentication configuration documentation](${linkify( + 'docs/usage/ci-configuration.md#authentication' + )}) to configure the Git credentials on your CI environment.`, + }), + EINVALIDTAGFORMAT: ({tagFormat}) => ({ + message: 'Invalid `tagFormat` option.', + details: `The [tagFormat](${linkify( + 'docs/usage/configuration.md#tagformat' + )}) must compile to a [valid Git reference](https://git-scm.com/docs/git-check-ref-format#_description). + +Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`, + }), + ETAGNOVERSION: ({tagFormat}) => ({ + message: 'Invalid `tagFormat` option.', + details: `The [tagFormat](${linkify( + 'docs/usage/configuration.md#tagformat' + )}) option must contain the variable \`version\` exactly once. + +Your configuration for the \`tagFormat\` option is \`${stringify(tagFormat)}\`.`, + }), + EPLUGINCONF: ({pluginName, pluginConf}) => ({ + message: `The \`${pluginName}\` plugin configuration is invalid.`, + details: `The [${pluginName} plugin configuration](${linkify( + `docs/usage/plugins.md#${toLower(pluginName)}-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 \`${pluginName}\` 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}\`. + +The plugin \`${pluginName}\` doesn't have the property \`${pluginType}\` and cannot be used for the \`${pluginType}\` step. + +Please refer to the \`${pluginName}\` and [semantic-release plugins configuration](${linkify( + 'docs/usage/plugins.md' + )}) documentation for more details.`, + }), + EANALYZEOUTPUT: ({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}\`` + ).join(', ')}. + +The \`analyzeCommits\` function of the \`${pluginName}\` returned \`${stringify(result)}\` instead. + +We recommend to report the issue to the \`${pluginName}\` authors, providing the following informations: +- The **semantic-release** version: \`${pkg.version}\` +- The **semantic-release** logs from your CI job +- The value returned by the plugin: \`${stringify(result)}\` +- A link to the **semantic-release** plugin developer guide: [${linkify('docs/developer-guide/plugin.md')}](${linkify( + 'docs/developer-guide/plugin.md' + )})`, + }), + ERELEASENOTESOUTPUT: ({result, pluginName}) => ({ + message: 'The `generateNotes` plugin returned an invalid value. It must return a `String`.', + details: `The \`generateNotes\` plugin must return a \`String\`. + +The \`generateNotes\` function of the \`${pluginName}\` returned \`${stringify(result)}\` instead. + +We recommend to report the issue to the \`${pluginName}\` authors, providing the following informations: +- The **semantic-release** version: \`${pkg.version}\` +- The **semantic-release** logs from your CI job +- The value returned by the plugin: \`${stringify(result)}\` +- A link to the **semantic-release** plugin developer guide: [${linkify('docs/developer-guide/plugin.md')}](${linkify( + 'docs/developer-guide/plugin.md' + )})`, + }), + EPUBLISHOUTPUT: ({result, pluginName}) => ({ + message: 'A `publish` plugin returned an invalid value. It must return an `Object`.', + details: `The \`publish\` plugins must return an \`Object\`. + +The \`publish\` function of the \`${pluginName}\` returned \`${stringify(result)}\` instead. + +We recommend to report the issue to the \`${pluginName}\` authors, providing the following informations: +- The **semantic-release** version: \`${pkg.version}\` +- The **semantic-release** logs from your CI job +- The value returned by the plugin: \`${stringify(result)}\` +- A link to the **semantic-release** plugin developer guide: [${linkify('docs/developer-guide/plugin.md')}](${linkify( + 'docs/developer-guide/plugin.md' + )})`, + }), +}; diff --git a/lib/definitions/plugins.js b/lib/definitions/plugins.js new file mode 100644 index 00000000..2e956e32 --- /dev/null +++ b/lib/definitions/plugins.js @@ -0,0 +1,61 @@ +const {isString, isFunction, isArray, isPlainObject} = require('lodash'); +const RELEASE_TYPE = require('./release-types'); + +const validatePluginConfig = conf => isString(conf) || isString(conf.path) || isFunction(conf); + +module.exports = { + verifyConditions: { + default: ['@semantic-release/npm', '@semantic-release/github'], + config: { + validator: 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', + }, + }, + verifyRelease: { + default: false, + config: { + validator: 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', + }, + }, + publish: { + default: ['@semantic-release/npm', '@semantic-release/github'], + config: { + validator: conf => Boolean(conf) && (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), + }, + output: { + validator: output => !output || isPlainObject(output), + error: 'EPUBLISHOUTPUT', + }, + }, + success: { + default: ['@semantic-release/github'], + config: { + validator: 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)), + }, + }, +}; diff --git a/lib/definitions/release-types.js b/lib/definitions/release-types.js new file mode 100644 index 00000000..9bb7d3df --- /dev/null +++ b/lib/definitions/release-types.js @@ -0,0 +1 @@ +module.exports = ['major', 'premajor', 'minor', 'preminor', 'patch', 'prepatch', 'prerelease']; diff --git a/lib/get-config.js b/lib/get-config.js index 65587a2d..c3432b69 100644 --- a/lib/get-config.js +++ b/lib/get-config.js @@ -4,7 +4,7 @@ const cosmiconfig = require('cosmiconfig'); const resolveFrom = require('resolve-from'); const debug = require('debug')('semantic-release:config'); const {repoUrl} = require('./git'); -const PLUGINS_DEFINITION = require('./plugins/definitions'); +const PLUGINS_DEFINITIONS = require('./definitions/plugins'); const plugins = require('./plugins'); const getGitAuthUrl = require('./get-git-auth-url'); @@ -25,7 +25,7 @@ module.exports = async (opts, logger) => { // 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.keys(extendsOpts).reduce((pluginsPath, option) => { - if (PLUGINS_DEFINITION[option]) { + if (PLUGINS_DEFINITIONS[option]) { castArray(extendsOpts[option]) .filter(plugin => isString(plugin) || (isPlainObject(plugin) && isString(plugin.path))) .map(plugin => (isString(plugin) ? plugin : plugin.path)) diff --git a/lib/get-error.js b/lib/get-error.js new file mode 100644 index 00000000..56a09c0d --- /dev/null +++ b/lib/get-error.js @@ -0,0 +1,7 @@ +const SemanticReleaseError = require('@semantic-release/error'); +const ERROR_DEFINITIONS = require('./definitions/errors'); + +module.exports = (code, ctx = {}) => { + const {message, details} = ERROR_DEFINITIONS[code](ctx); + return new SemanticReleaseError(message, code, details); +}; diff --git a/lib/git.js b/lib/git.js index 24f39b7a..dcca5da2 100644 --- a/lib/git.js +++ b/lib/git.js @@ -120,7 +120,6 @@ async function push(origin, branch) { * * @param {String} origin The remote repository URL. * @param {String} tagName The tag name to delete. - * @throws {SemanticReleaseError} if the remote tag exists and references a commit that is not the local head commit. */ async function deleteTag(origin, tagName) { // Delete the local tag diff --git a/lib/plugins/definitions.js b/lib/plugins/definitions.js deleted file mode 100644 index cf0168f9..00000000 --- a/lib/plugins/definitions.js +++ /dev/null @@ -1,55 +0,0 @@ -const {isString, isFunction, isArray} = require('lodash'); - -const RELEASE_TYPE = ['major', 'premajor', 'minor', 'preminor', 'patch', 'prepatch', 'prerelease']; -const validatePluginConfig = conf => isString(conf) || isString(conf.path) || isFunction(conf); - -module.exports = { - verifyConditions: { - default: ['@semantic-release/npm', '@semantic-release/github'], - config: { - validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), - message: - 'The "verifyConditions" 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.', - }, - }, - analyzeCommits: { - default: '@semantic-release/commit-analyzer', - config: { - validator: conf => Boolean(conf) && validatePluginConfig(conf), - message: - 'The "analyzeCommits" plugin is mandatory, and must be a single plugin definition. A plugin definition is either a string or an object with a path property.', - }, - output: { - validator: output => !output || RELEASE_TYPE.includes(output), - message: 'The "analyzeCommits" plugin output, if defined, must be a valid semver release type.', - }, - }, - verifyRelease: { - default: false, - config: { - validator: conf => !conf || (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), - message: - 'The "verifyRelease" 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.', - }, - }, - generateNotes: { - default: '@semantic-release/release-notes-generator', - config: { - validator: conf => !conf || validatePluginConfig(conf), - message: - 'The "generateNotes" plugin, if defined, must be a single plugin definition. A plugin definition is either a string or an object with a path property.', - }, - output: { - validator: output => !output || isString(output), - message: 'The "generateNotes" plugin output, if defined, must be a string.', - }, - }, - publish: { - default: ['@semantic-release/npm', '@semantic-release/github'], - config: { - validator: conf => Boolean(conf) && (isArray(conf) ? conf : [conf]).every(conf => validatePluginConfig(conf)), - message: - 'The "publish" plugin is mandatory, and must be a single or an array of plugins definition. A plugin definition is either a string or an object with a path property.', - }, - }, -}; diff --git a/lib/plugins/index.js b/lib/plugins/index.js index c5808c0d..133fa9ba 100644 --- a/lib/plugins/index.js +++ b/lib/plugins/index.js @@ -1,34 +1,35 @@ -const {isArray, isObject, omit} = require('lodash'); +const {isArray, isObject, omit, castArray, isUndefined} = require('lodash'); const AggregateError = require('aggregate-error'); -const SemanticReleaseError = require('@semantic-release/error'); -const PLUGINS_DEFINITION = require('./definitions'); +const getError = require('../get-error'); +const PLUGINS_DEFINITIONS = require('../definitions/plugins'); const pipeline = require('./pipeline'); const normalize = require('./normalize'); module.exports = (options, pluginsPath, logger) => { const errors = []; - const plugins = Object.keys(PLUGINS_DEFINITION).reduce((plugins, pluginType) => { - const {config, output, default: def} = PLUGINS_DEFINITION[pluginType]; + const plugins = Object.keys(PLUGINS_DEFINITIONS).reduce((plugins, pluginType) => { + const {config, default: def} = PLUGINS_DEFINITIONS[pluginType]; let pluginConfs; - if (options[pluginType]) { + + if (isUndefined(options[pluginType])) { + pluginConfs = def; + } else { // If an object is passed and the path is missing, set the default one for single plugins if (isObject(options[pluginType]) && !options[pluginType].path && !isArray(def)) { options[pluginType].path = def; } if (config && !config.validator(options[pluginType])) { - errors.push(new SemanticReleaseError(config.message, 'EPLUGINCONF')); + errors.push(getError('EPLUGINCONF', {pluginType, pluginConf: options[pluginType]})); return plugins; } pluginConfs = options[pluginType]; - } else { - pluginConfs = def; } - const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITION)); + const globalOpts = omit(options, Object.keys(PLUGINS_DEFINITIONS)); - plugins[pluginType] = isArray(pluginConfs) - ? pipeline(pluginConfs.map(conf => normalize(pluginType, pluginsPath, globalOpts, conf, logger, output))) - : normalize(pluginType, pluginsPath, globalOpts, pluginConfs, logger, output); + plugins[pluginType] = pipeline( + castArray(pluginConfs).map(conf => normalize(pluginType, pluginsPath, globalOpts, conf, logger)) + ); return plugins; }, {}); diff --git a/lib/plugins/normalize.js b/lib/plugins/normalize.js index d045cd85..bf71bffa 100644 --- a/lib/plugins/normalize.js +++ b/lib/plugins/normalize.js @@ -1,15 +1,18 @@ const {dirname} = require('path'); -const {inspect} = require('util'); -const SemanticReleaseError = require('@semantic-release/error'); -const {isString, isObject, isFunction, noop, cloneDeep} = require('lodash'); +const {isString, isPlainObject, isFunction, noop, cloneDeep} = require('lodash'); const resolveFrom = require('resolve-from'); +const getError = require('../get-error'); +const {extractErrors} = require('../utils'); +const PLUGINS_DEFINITIONS = require('../definitions/plugins'); -module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger, validator) => { +module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger) => { if (!pluginOpts) { return noop; } const {path, ...config} = isString(pluginOpts) || isFunction(pluginOpts) ? {path: pluginOpts} : pluginOpts; + const pluginName = isFunction(path) ? `[Function: ${path.name}]` : path; + if (!isFunction(pluginOpts)) { if (pluginsPath[path]) { logger.log('Load plugin %s from %s in shareable config %s', pluginType, path, pluginsPath[path]); @@ -28,21 +31,27 @@ module.exports = (pluginType, pluginsPath, globalOpts, pluginOpts, logger, valid let func; if (isFunction(plugin)) { func = plugin.bind(null, cloneDeep({...globalOpts, ...config})); - } else if (isObject(plugin) && plugin[pluginType] && isFunction(plugin[pluginType])) { + } else if (isPlainObject(plugin) && plugin[pluginType] && isFunction(plugin[pluginType])) { func = plugin[pluginType].bind(null, cloneDeep({...globalOpts, ...config})); } else { - throw new SemanticReleaseError( - `The ${pluginType} plugin must be a function, or an object with a function in the property ${pluginType}.`, - 'EPLUGINCONF' - ); + throw getError('EPLUGIN', {pluginType, pluginName}); } - return async input => { - const result = await func(cloneDeep(input)); - - if (validator && !validator.validator(result)) { - throw new Error(`${validator.message} Received: ${inspect(result)}`); - } - return result; - }; + return Object.defineProperty( + async input => { + const definition = PLUGINS_DEFINITIONS[pluginType]; + try { + const result = await func(cloneDeep(input)); + if (definition && definition.output && !definition.output.validator(result)) { + throw getError(PLUGINS_DEFINITIONS[pluginType].output.error, {result, pluginName}); + } + return result; + } catch (err) { + extractErrors(err).forEach(err => Object.assign(err, {pluginName})); + throw err; + } + }, + 'pluginName', + {value: pluginName, writable: false, enumerable: true} + ); }; diff --git a/lib/plugins/pipeline.js b/lib/plugins/pipeline.js index f8802896..69bc9d37 100644 --- a/lib/plugins/pipeline.js +++ b/lib/plugins/pipeline.js @@ -1,37 +1,55 @@ -const {identity, isFunction} = require('lodash'); -const pReflect = require('p-reflect'); +const {identity} = require('lodash'); const pReduce = require('p-reduce'); const AggregateError = require('aggregate-error'); +const {extractErrors} = require('../utils'); -module.exports = steps => async (input, settleAll = false, getNextInput = identity) => { +/** + * A Function that execute a list of function sequencially. If at least one Function ins the pipeline throw an Error or rejects, the pipeline function rejects as well. + * + * @typedef {Function} Pipeline + * @param {Any} input Argument to pass to the first step in the pipeline. + * @param {Object} options Pipeline options. + * @param {Boolean} [options.settleAll=false] If `true` all the steps in the pipeline are executed, even if one rejects, if `false` the execution stops after a steps rejects. + * @param {Function} [options.getNextInput=identity] Function called after each step is executed, with the last and current step results; the returned value will be used as the argument of the next step. + * @param {Function} [options.transform=identity] Function called after each step is executed, with the current step result and the step function; the returned value will be saved in the pipeline results. + * + * @return {Array<*>|*} An Array with the result of each step in the pipeline; if there is only 1 step in the pipeline, the result of this step is returned directly. + * + * @throws {AggregateError|Error} An AggregateError with the errors of each step in the pipeline that rejected; if there is only 1 step in the pipeline, the error of this step is thrown directly. + */ + +/** + * Create a Pipeline with a list of Functions. + * + * @param {Array} steps The list of Function to execute. + * @return {Pipeline} A Function that execute the `steps` sequencially + */ +module.exports = steps => async (input, {settleAll = false, getNextInput = identity, transform = identity} = {}) => { const results = []; const errors = []; await pReduce( steps, - async (prevResult, nextStep) => { + async (lastResult, step) => { let result; - - // Call the next step with the input computed at the end of the previous iteration and save intermediary result - if (settleAll) { - const {isFulfilled, value, reason} = await pReflect(nextStep(prevResult)); - result = isFulfilled ? value : reason; - if (isFulfilled) { - results.push(result); - } else { - errors.push(...(result && isFunction(result[Symbol.iterator]) ? result : [result])); - } - } else { - result = await nextStep(prevResult); + try { + // Call the step with the input computed at the end of the previous iteration and save intermediary result + result = await transform(await step(lastResult), step); results.push(result); + } catch (err) { + if (settleAll) { + errors.push(...extractErrors(err)); + result = err; + } else { + throw err; + } } - - // Prepare input for next step, passing the result of the previous iteration and the current one - return getNextInput(prevResult, result); + // Prepare input for the next step, passing the result of the last iteration (or initial parameter for the first iteration) and the current one + return getNextInput(lastResult, result); }, input ); if (errors.length > 0) { - throw new AggregateError(errors); + throw errors.length === 1 ? errors[0] : new AggregateError(errors); } - return results; + return results.length <= 1 ? results[0] : results; }; diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 00000000..66fa78ed --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,7 @@ +const {isFunction} = require('lodash'); + +function extractErrors(err) { + return err && isFunction(err[Symbol.iterator]) ? [...err] : [err]; +} + +module.exports = {extractErrors}; diff --git a/lib/verify.js b/lib/verify.js index 7cbc23f1..f0a85a89 100644 --- a/lib/verify.js +++ b/lib/verify.js @@ -1,44 +1,29 @@ const {template} = require('lodash'); -const SemanticReleaseError = require('@semantic-release/error'); const AggregateError = require('aggregate-error'); const {isGitRepo, verifyAuth, verifyTagName} = require('./git'); +const getError = require('./get-error'); module.exports = async (options, branch, logger) => { const errors = []; if (!await isGitRepo()) { - logger.error('Semantic-release must run from a git repository.'); - return false; - } - - if (!options.repositoryUrl) { - errors.push(new SemanticReleaseError('The repositoryUrl option is required', 'ENOREPOURL')); + errors.push(getError('ENOGITREPO')); + } else if (!options.repositoryUrl) { + errors.push(getError('ENOREPOURL')); } else if (!await verifyAuth(options.repositoryUrl, options.branch)) { - errors.push( - new SemanticReleaseError( - `The git credentials doesn't allow to push on the branch ${options.branch}.`, - 'EGITNOPERMISSION' - ) - ); + errors.push(getError('EGITNOPERMISSION', {options})); } // Verify that compiling the `tagFormat` produce a valid Git tag if (!await verifyTagName(template(options.tagFormat)({version: '0.0.0'}))) { - errors.push( - new SemanticReleaseError('The tagFormat template must compile to a valid Git tag format', 'EINVALIDTAGFORMAT') - ); + errors.push(getError('EINVALIDTAGFORMAT', {tagFormat: options.tagFormat})); } // Verify the `tagFormat` contains the variable `version` by compiling the `tagFormat` template // with a space as the `version` value and verify the result contains the space. // The space is used as it's an invalid tag character, so it's guaranteed to no be present in the `tagFormat`. if ((template(options.tagFormat)({version: ' '}).match(/ /g) || []).length !== 1) { - errors.push( - new SemanticReleaseError( - `The tagFormat template must contain the variable "\${version}" exactly once`, - 'ETAGNOVERSION' - ) - ); + errors.push(getError('ETAGNOVERSION', {tagFormat: options.tagFormat})); } if (errors.length > 0) { diff --git a/package.json b/package.json index 0997059f..26578ee5 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,9 @@ ], "dependencies": { "@semantic-release/commit-analyzer": "^5.0.0", - "@semantic-release/error": "^2.1.0", - "@semantic-release/github": "^4.0.2", - "@semantic-release/npm": "^3.0.0", + "@semantic-release/error": "^2.2.0", + "@semantic-release/github": "^4.1.0", + "@semantic-release/npm": "^3.1.0", "@semantic-release/release-notes-generator": "^6.0.0", "aggregate-error": "^1.0.0", "chalk": "^2.3.0", @@ -40,7 +40,6 @@ "marked-terminal": "^2.0.0", "p-locate": "^2.0.0", "p-reduce": "^1.0.0", - "p-reflect": "^1.0.0", "read-pkg-up": "^3.0.0", "resolve-from": "^4.0.0", "semver": "^5.4.1" diff --git a/test/definitions/plugins.test.js b/test/definitions/plugins.test.js new file mode 100644 index 00000000..1dd7b5fd --- /dev/null +++ b/test/definitions/plugins.test.js @@ -0,0 +1,122 @@ +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.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', () => {}])); +}); + +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.true(plugins.analyzeCommits.config.validator({path: 'plugin-path.js'})); + t.true(plugins.analyzeCommits.config.validator('plugin-path.js')); + t.true(plugins.analyzeCommits.config.validator(() => {})); +}); + +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.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', () => {}])); +}); + +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.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(() => {})); +}); + +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.config.validator()); + + t.true(plugins.publish.config.validator({path: 'plugin-path.js'})); + 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', () => {}])); +}); + +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.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', () => {}])); +}); + +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.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', () => {}])); +}); + +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.true(plugins.analyzeCommits.output.validator()); + t.true(plugins.analyzeCommits.output.validator(null)); + t.true(plugins.analyzeCommits.output.validator('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.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')); +}); + +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.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)); +}); diff --git a/test/fixtures/plugin-errors.js b/test/fixtures/plugin-errors.js new file mode 100644 index 00000000..e89211da --- /dev/null +++ b/test/fixtures/plugin-errors.js @@ -0,0 +1,5 @@ +const AggregateError = require('aggregate-error'); + +module.exports = () => { + throw new AggregateError([new Error('a'), new Error('b')]); +}; diff --git a/test/fixtures/plugin-identity.js b/test/fixtures/plugin-identity.js new file mode 100644 index 00000000..6afb293e --- /dev/null +++ b/test/fixtures/plugin-identity.js @@ -0,0 +1 @@ +module.exports = (pluginConfig, options) => options; diff --git a/test/index.test.js b/test/index.test.js index 2f233a9f..a6db4015 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,11 +1,10 @@ import test from 'ava'; import proxyquire from 'proxyquire'; -import {stub} from 'sinon'; -import tempy from 'tempy'; +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/plugins/definitions'; +import DEFINITIONS from '../lib/definitions/plugins'; import { gitHead as getGitHead, gitTagHead, @@ -21,10 +20,10 @@ import { const envBackup = Object.assign({}, process.env); // Save the current working diretory const cwd = process.cwd(); +const pluginNoop = require.resolve('./fixtures/plugin-noop'); test.beforeEach(t => { clearModule('../lib/hide-sensitive'); - // Delete environment variables that could have been set on the machine running the tests delete process.env.GIT_CREDENTIALS; delete process.env.GH_TOKEN; @@ -32,8 +31,8 @@ test.beforeEach(t => { delete process.env.GL_TOKEN; delete process.env.GITLAB_TOKEN; // Stub the logger functions - t.context.log = stub(); - t.context.error = stub(); + t.context.log = spy(); + t.context.error = spy(); t.context.logger = {log: t.context.log, error: t.context.error}; t.context.stdout = stub(process.stdout, 'write'); t.context.stderr = stub(process.stderr, 'write'); @@ -67,7 +66,9 @@ test.serial('Plugins are called with expected values', async t => { const analyzeCommits = stub().resolves(nextRelease.type); const verifyRelease = stub().resolves(); const generateNotes = stub().resolves(notes); - const publish = stub().resolves(); + const release1 = {name: 'Release 1', url: 'https://release1.com'}; + const publish1 = stub().resolves(release1); + const success = stub().resolves(); const config = {branch: 'master', repositoryUrl, globalOpt: 'global', tagFormat: `v\${version}`}; const options = { @@ -76,7 +77,8 @@ test.serial('Plugins are called with expected values', async t => { analyzeCommits, verifyRelease, generateNotes, - publish, + publish: [publish1, pluginNoop], + success, }; const semanticRelease = proxyquire('..', { @@ -117,14 +119,27 @@ test.serial('Plugins are called with expected values', async t => { t.deepEqual(generateNotes.args[0][1].commits[0].message, commits[0].message); t.deepEqual(generateNotes.args[0][1].nextRelease, nextRelease); - t.is(publish.callCount, 1); - t.deepEqual(publish.args[0][0], config); - t.deepEqual(publish.args[0][1].options, options); - t.deepEqual(publish.args[0][1].logger, t.context.logger); - t.deepEqual(publish.args[0][1].lastRelease, lastRelease); - t.deepEqual(publish.args[0][1].commits[0].hash, commits[0].hash); - t.deepEqual(publish.args[0][1].commits[0].message, commits[0].message); - t.deepEqual(publish.args[0][1].nextRelease, Object.assign({}, nextRelease, {notes})); + t.is(publish1.callCount, 1); + t.deepEqual(publish1.args[0][0], config); + t.deepEqual(publish1.args[0][1].options, options); + t.deepEqual(publish1.args[0][1].logger, t.context.logger); + t.deepEqual(publish1.args[0][1].lastRelease, lastRelease); + t.deepEqual(publish1.args[0][1].commits[0].hash, commits[0].hash); + t.deepEqual(publish1.args[0][1].commits[0].message, commits[0].message); + t.deepEqual(publish1.args[0][1].nextRelease, {...nextRelease, ...{notes}}); + + t.is(success.callCount, 1); + t.deepEqual(success.args[0][0], config); + t.deepEqual(success.args[0][1].options, options); + t.deepEqual(success.args[0][1].logger, t.context.logger); + t.deepEqual(success.args[0][1].lastRelease, lastRelease); + t.deepEqual(success.args[0][1].commits[0].hash, commits[0].hash); + t.deepEqual(success.args[0][1].commits[0].message, commits[0].message); + t.deepEqual(success.args[0][1].nextRelease, {...nextRelease, ...{notes}}); + t.deepEqual(success.args[0][1].releases, [ + {...release1, ...nextRelease, ...{notes}, ...{pluginName: '[Function: proxy]'}}, + {...nextRelease, ...{notes}, ...{pluginName: pluginNoop}}, + ]); // Verify the tag has been created on the local and remote repo and reference the gitHead t.is(await gitTagHead(nextRelease.gitTag), nextRelease.gitHead); @@ -139,20 +154,16 @@ test.serial('Use custom tag format', async t => { const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'test-2.0.0'}; const notes = 'Release notes'; - const verifyConditions = stub().resolves(); - const analyzeCommits = stub().resolves(nextRelease.type); - const verifyRelease = stub().resolves(); - const generateNotes = stub().resolves(notes); - const publish = stub().resolves(); - const config = {branch: 'master', repositoryUrl, globalOpt: 'global', tagFormat: `test-\${version}`}; const options = { ...config, - verifyConditions, - analyzeCommits, - verifyRelease, - generateNotes, - publish, + verifyConditions: stub().resolves(), + analyzeCommits: stub().resolves(nextRelease.type), + verifyRelease: stub().resolves(), + generateNotes: stub().resolves(notes), + publish: stub().resolves(), + success: stub().resolves(), + fail: stub().resolves(), }; const semanticRelease = proxyquire('..', { @@ -193,6 +204,8 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre verifyRelease: stub().resolves(), generateNotes, publish: [publish1, publish2], + success: stub().resolves(), + fail: stub().resolves(), }; const semanticRelease = proxyquire('..', { @@ -205,19 +218,69 @@ test.serial('Use new gitHead, and recreate release notes if a publish plugin cre t.is(generateNotes.callCount, 2); t.deepEqual(generateNotes.args[0][1].nextRelease, nextRelease); t.is(publish1.callCount, 1); - t.deepEqual(publish1.args[0][1].nextRelease, Object.assign({}, nextRelease, {notes})); + t.deepEqual(publish1.args[0][1].nextRelease, {...nextRelease, ...{notes}}); nextRelease.gitHead = await getGitHead(); - t.deepEqual(generateNotes.secondCall.args[1].nextRelease, Object.assign({}, nextRelease, {notes})); + t.deepEqual(generateNotes.secondCall.args[1].nextRelease, {...nextRelease, ...{notes}}); t.is(publish2.callCount, 1); - t.deepEqual(publish2.args[0][1].nextRelease, Object.assign({}, nextRelease, {notes})); + t.deepEqual(publish2.args[0][1].nextRelease, {...nextRelease, ...{notes}}); // Verify the tag has been created on the local and remote repo and reference the last gitHead t.is(await gitTagHead(nextRelease.gitTag), commits[0].hash); t.is(await gitRemoteTagHead(repositoryUrl, nextRelease.gitTag), commits[0].hash); }); +test.serial('Call all "success" plugins even if one errors out', async t => { + // Create a git repository, set the current working directory at the root of the repo + const repositoryUrl = await gitRepo(true); + // Add commits to the master branch + await gitCommits(['First']); + // Create the tag corresponding to version 1.0.0 + await gitTagVersion('v1.0.0'); + // Add new commits to the master branch + await gitCommits(['Second']); + + const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'}; + const notes = 'Release notes'; + const verifyConditions1 = stub().resolves(); + const verifyConditions2 = stub().resolves(); + const analyzeCommits = stub().resolves(nextRelease.type); + const generateNotes = stub().resolves(notes); + const release = {name: 'Release', url: 'https://release.com'}; + const publish = stub().resolves(release); + const success1 = stub().rejects(); + const success2 = stub().resolves(); + + const config = {branch: 'master', repositoryUrl, globalOpt: 'global', tagFormat: `v\${version}`}; + const options = { + ...config, + verifyConditions: [verifyConditions1, verifyConditions2], + analyzeCommits, + generateNotes, + publish, + success: [success1, success2], + }; + + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + + await t.throws(semanticRelease(options)); + + t.is(success1.callCount, 1); + t.deepEqual(success1.args[0][0], config); + t.deepEqual(success1.args[0][1].releases, [ + {...release, ...nextRelease, ...{notes}, ...{pluginName: '[Function: proxy]'}}, + ]); + + t.is(success2.callCount, 1); + t.deepEqual(success2.args[0][1].releases, [ + {...release, ...nextRelease, ...{notes}, ...{pluginName: '[Function: proxy]'}}, + ]); +}); + test.serial('Log all "verifyConditions" errors', async t => { // Create a git repository, set the current working directory at the root of the repo const repositoryUrl = await gitRepo(true); @@ -227,10 +290,12 @@ test.serial('Log all "verifyConditions" errors', async t => { const error1 = new Error('error 1'); const error2 = new SemanticReleaseError('error 2', 'ERR2'); const error3 = new SemanticReleaseError('error 3', 'ERR3'); + const fail = stub().resolves(); + const config = {branch: 'master', repositoryUrl, tagFormat: `v\${version}`}; const options = { - branch: 'master', - repositoryUrl, + ...config, verifyConditions: [stub().rejects(new AggregateError([error1, error2])), stub().rejects(error3)], + fail, }; const semanticRelease = proxyquire('..', { @@ -247,6 +312,11 @@ test.serial('Log all "verifyConditions" errors', async t => { error1, ]); t.true(t.context.error.calledAfter(t.context.log)); + t.is(fail.callCount, 1); + t.deepEqual(fail.args[0][0], config); + t.deepEqual(fail.args[0][1].options, options); + t.deepEqual(fail.args[0][1].logger, t.context.logger); + t.deepEqual(fail.args[0][1].errors, [error2, error3]); }); test.serial('Log all "verifyRelease" errors', async t => { @@ -261,12 +331,14 @@ test.serial('Log all "verifyRelease" errors', async t => { const error1 = new SemanticReleaseError('error 1', 'ERR1'); const error2 = new SemanticReleaseError('error 2', 'ERR2'); + const fail = stub().resolves(); + const config = {branch: 'master', repositoryUrl, tagFormat: `v\${version}`}; const options = { - branch: 'master', - repositoryUrl, + ...config, verifyConditions: stub().resolves(), analyzeCommits: stub().resolves('major'), verifyRelease: [stub().rejects(error1), stub().rejects(error2)], + fail, }; const semanticRelease = proxyquire('..', { @@ -278,9 +350,12 @@ test.serial('Log all "verifyRelease" errors', async t => { t.deepEqual(Array.from(errors), [error1, error2]); t.deepEqual(t.context.log.args[t.context.log.args.length - 2], ['%s error 1', 'ERR1']); t.deepEqual(t.context.log.args[t.context.log.args.length - 1], ['%s error 2', 'ERR2']); + t.is(fail.callCount, 1); + t.deepEqual(fail.args[0][0], config); + t.deepEqual(fail.args[0][1].errors, [error1, error2]); }); -test.serial('Dry-run skips publish', async t => { +test.serial('Dry-run skips publish and success', async t => { // Create a git repository, set the current working directory at the root of the repo const repositoryUrl = await gitRepo(true); // Add commits to the master branch @@ -298,6 +373,7 @@ test.serial('Dry-run skips publish', async t => { const verifyRelease = stub().resolves(); const generateNotes = stub().resolves(notes); const publish = stub().resolves(); + const success = stub().resolves(); const options = { dryRun: true, @@ -308,6 +384,7 @@ test.serial('Dry-run skips publish', async t => { verifyRelease, generateNotes, publish, + success, }; const semanticRelease = proxyquire('..', { @@ -322,6 +399,41 @@ test.serial('Dry-run skips publish', async t => { t.is(verifyRelease.callCount, 1); t.is(generateNotes.callCount, 1); t.is(publish.callCount, 0); + t.is(success.callCount, 0); +}); + +test.serial('Dry-run skips fail', async t => { + // Create a git repository, set the current working directory at the root of the repo + const repositoryUrl = await gitRepo(true); + // Add commits to the master branch + await gitCommits(['First']); + // Create the tag corresponding to version 1.0.0 + await gitTagVersion('v1.0.0'); + // Add new commits to the master branch + await gitCommits(['Second']); + + const error1 = new SemanticReleaseError('error 1', 'ERR1'); + const error2 = new SemanticReleaseError('error 2', 'ERR2'); + const fail = stub().resolves(); + + const options = { + dryRun: true, + branch: 'master', + repositoryUrl, + verifyConditions: [stub().rejects(error1), stub().rejects(error2)], + fail, + }; + + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + const errors = await t.throws(semanticRelease(options)); + + t.deepEqual(Array.from(errors), [error1, error2]); + t.deepEqual(t.context.log.args[t.context.log.args.length - 2], ['%s error 1', 'ERR1']); + t.deepEqual(t.context.log.args[t.context.log.args.length - 1], ['%s error 2', 'ERR2']); + t.is(fail.callCount, 0); }); test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', async t => { @@ -342,6 +454,7 @@ test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', a const verifyRelease = stub().resolves(); const generateNotes = stub().resolves(notes); const publish = stub().resolves(); + const success = stub().resolves(); const options = { dryRun: false, @@ -352,6 +465,8 @@ test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', a verifyRelease, generateNotes, publish, + success, + fail: stub().resolves(), }; const semanticRelease = proxyquire('..', { @@ -366,6 +481,7 @@ test.serial('Force a dry-run if not on a CI and "noCi" is not explicitly set', a t.is(verifyRelease.callCount, 1); t.is(generateNotes.callCount, 1); t.is(publish.callCount, 0); + t.is(success.callCount, 0); }); test.serial('Allow local releases with "noCi" option', async t => { @@ -386,6 +502,7 @@ test.serial('Allow local releases with "noCi" option', async t => { const verifyRelease = stub().resolves(); const generateNotes = stub().resolves(notes); const publish = stub().resolves(); + const success = stub().resolves(); const options = { noCi: true, @@ -396,6 +513,8 @@ test.serial('Allow local releases with "noCi" option', async t => { verifyRelease, generateNotes, publish, + success, + fail: stub().resolves(), }; const semanticRelease = proxyquire('..', { @@ -414,6 +533,7 @@ test.serial('Allow local releases with "noCi" option', async t => { t.is(verifyRelease.callCount, 1); t.is(generateNotes.callCount, 1); t.is(publish.callCount, 1); + t.is(success.callCount, 1); }); test.serial('Accept "undefined" value returned by the "generateNotes" plugins', async t => { @@ -428,7 +548,6 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins', const lastRelease = {version: '1.0.0', gitHead: commits[commits.length - 1].hash, gitTag: 'v1.0.0'}; const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'}; - const verifyConditions = stub().resolves(); const analyzeCommits = stub().resolves(nextRelease.type); const verifyRelease = stub().resolves(); const generateNotes = stub().resolves(); @@ -437,11 +556,13 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins', const options = { branch: 'master', repositoryUrl, - verifyConditions: [verifyConditions], + verifyConditions: stub().resolves(), analyzeCommits, verifyRelease, generateNotes, publish, + success: stub().resolves(), + fail: stub().resolves(), }; const semanticRelease = proxyquire('..', { @@ -464,18 +585,6 @@ test.serial('Accept "undefined" value returned by the "generateNotes" plugins', t.falsy(publish.args[0][1].nextRelease.notes); }); -test.serial('Returns falsy value if not running from a git repository', async t => { - // Set the current working directory to a temp directory - process.chdir(tempy.directory()); - - const semanticRelease = proxyquire('..', { - './lib/logger': t.context.logger, - 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), - }); - t.falsy(await semanticRelease({repositoryUrl: 'git@hostname.com:owner/module.git'})); - t.is(t.context.error.args[0][0], 'Semantic-release must run from a git repository.'); -}); - test.serial('Returns falsy value if triggered by a PR', async t => { // Create a git repository, set the current working directory at the root of the repo const repositoryUrl = await gitRepo(true); @@ -487,7 +596,7 @@ test.serial('Returns falsy value if triggered by a PR', async t => { t.falsy(await semanticRelease({repositoryUrl})); t.is( - t.context.log.args[6][0], + t.context.log.args[8][0], "This run was triggered by a pull request and therefore a new version won't be published." ); }); @@ -495,21 +604,16 @@ test.serial('Returns falsy value if triggered by a PR', async t => { test.serial('Returns falsy value if not running from the configured branch', async t => { // Create a git repository, set the current working directory at the root of the repo const repositoryUrl = await gitRepo(true); - - const verifyConditions = stub().resolves(); - const analyzeCommits = stub().resolves(); - const verifyRelease = stub().resolves(); - const generateNotes = stub().resolves(); - const publish = stub().resolves(); - const options = { branch: 'master', repositoryUrl, - verifyConditions: [verifyConditions], - analyzeCommits, - verifyRelease, - generateNotes, - publish, + verifyConditions: stub().resolves(), + analyzeCommits: stub().resolves(), + verifyRelease: stub().resolves(), + generateNotes: stub().resolves(), + publish: stub().resolves(), + success: stub().resolves(), + fail: stub().resolves(), }; const semanticRelease = proxyquire('..', { @@ -530,7 +634,6 @@ test.serial('Returns falsy value if there is no relevant changes', async t => { // Add commits to the master branch await gitCommits(['First']); - const verifyConditions = stub().resolves(); const analyzeCommits = stub().resolves(); const verifyRelease = stub().resolves(); const generateNotes = stub().resolves(); @@ -539,11 +642,13 @@ test.serial('Returns falsy value if there is no relevant changes', async t => { const options = { branch: 'master', repositoryUrl, - verifyConditions: [verifyConditions], + verifyConditions: [stub().resolves()], analyzeCommits, verifyRelease, generateNotes, publish, + success: stub().resolves(), + fail: stub().resolves(), }; const semanticRelease = proxyquire('..', { @@ -573,22 +678,17 @@ test.serial('Exclude commits with [skip release] or [release skip] from analysis 'Test commit\n\n commit body\n[skip release]', 'Test commit\n\n commit body\n[release skip]', ]); - - const verifyConditions1 = stub().resolves(); - const verifyConditions2 = stub().resolves(); const analyzeCommits = stub().resolves(); - const verifyRelease = stub().resolves(); - const generateNotes = stub().resolves(); - const publish = stub().resolves(); - const config = {branch: 'master', repositoryUrl, globalOpt: 'global'}; const options = { ...config, - verifyConditions: [verifyConditions1, verifyConditions2], + verifyConditions: [stub().resolves(), stub().resolves()], analyzeCommits, - verifyRelease, - generateNotes, - publish, + verifyRelease: stub().resolves(), + generateNotes: stub().resolves(), + publish: stub().resolves(), + success: stub().resolves(), + fail: stub().resolves(), }; const semanticRelease = proxyquire('..', { @@ -623,12 +723,60 @@ test.serial('Hide sensitive environment variable values from the logs', async t await t.throws(semanticRelease(options)); - t.regex(t.context.stdout.args[6][0], /Console: The token \[secure\] is invalid/); - t.regex(t.context.stdout.args[7][0], /Log: The token \[secure\] is invalid/); + t.regex(t.context.stdout.args[8][0], /Console: The token \[secure\] is invalid/); + t.regex(t.context.stdout.args[9][0], /Log: The token \[secure\] is invalid/); t.regex(t.context.stderr.args[0][0], /Error: The token \[secure\] is invalid/); t.regex(t.context.stderr.args[1][0], /Invalid token \[secure\]/); }); +test.serial('Log both plugins errors and errors thrown by "fail" plugin', async t => { + process.env.MY_TOKEN = 'secret token'; + const repositoryUrl = await gitRepo(true); + const pluginError = new SemanticReleaseError('Plugin error', 'ERR'); + const failError1 = new Error('Fail error 1'); + const failError2 = new Error('Fail error 2'); + + const options = { + branch: 'master', + repositoryUrl, + verifyConditions: stub().rejects(pluginError), + fail: [stub().rejects(failError1), stub().rejects(failError2)], + }; + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + + await t.throws(semanticRelease(options)); + + t.is(t.context.error.args[t.context.error.args.length - 2][1], failError1); + t.is(t.context.error.args[t.context.error.args.length - 1][1], failError2); + t.deepEqual(t.context.log.args[t.context.log.args.length - 1], ['%s Plugin error', 'ERR']); +}); + +test.serial('Call "fail" only if a plugin returns a SemanticReleaseError', async t => { + process.env.MY_TOKEN = 'secret token'; + const repositoryUrl = await gitRepo(true); + const pluginError = new Error('Plugin error'); + const fail = stub().resolves(); + + const options = { + branch: 'master', + repositoryUrl, + verifyConditions: stub().rejects(pluginError), + fail, + }; + const semanticRelease = proxyquire('..', { + './lib/logger': t.context.logger, + 'env-ci': () => ({isCi: true, branch: 'master', isPr: false}), + }); + + await t.throws(semanticRelease(options)); + + t.true(fail.notCalled); + t.is(t.context.error.args[t.context.error.args.length - 1][1], pluginError); +}); + test.serial('Throw SemanticReleaseError if repositoryUrl is not set and cannot be found from repo config', async t => { // Create a git repository, set the current working directory at the root of the repo await gitRepo(); @@ -662,6 +810,8 @@ test.serial('Throw an Error if plugin returns an unexpected value', async t => { repositoryUrl, verifyConditions: [verifyConditions], analyzeCommits, + success: stub().resolves(), + fail: stub().resolves(), }; const semanticRelease = proxyquire('..', { @@ -672,7 +822,7 @@ test.serial('Throw an Error if plugin returns an unexpected value', async t => { // Verify error message t.regex(error.message, new RegExp(DEFINITIONS.analyzeCommits.output.message)); - t.regex(error.message, /Received: 'string'/); + t.regex(error.details, /string/); }); test.serial('Get all commits including the ones not in the shallow clone', async t => { @@ -685,20 +835,18 @@ test.serial('Get all commits including the ones not in the shallow clone', async const nextRelease = {type: 'major', version: '2.0.0', gitHead: await getGitHead(), gitTag: 'v2.0.0'}; const notes = 'Release notes'; - const verifyConditions = stub().resolves(); const analyzeCommits = stub().resolves(nextRelease.type); - const verifyRelease = stub().resolves(); - const generateNotes = stub().resolves(notes); - const publish = stub().resolves(); const config = {branch: 'master', repositoryUrl, globalOpt: 'global'}; const options = { ...config, - verifyConditions, + verifyConditions: stub().resolves(), analyzeCommits, - verifyRelease, - generateNotes, - publish, + verifyRelease: stub().resolves(), + generateNotes: stub().resolves(notes), + publish: stub().resolves(), + success: stub().resolves(), + fail: stub().resolves(), }; const semanticRelease = proxyquire('..', { diff --git a/test/integration.test.js b/test/integration.test.js index 4b8f5e31..3238dd01 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -101,6 +101,7 @@ test.serial('Release patch, minor and major versions', async t => { version: '0.0.0-dev', repository: {url: repositoryUrl}, publishConfig: {registry: npmRegistry.url}, + release: {success: false, fail: false}, }); // Create a npm-shrinkwrap.json file await execa('npm', ['shrinkwrap'], {env: testEnv}); @@ -298,7 +299,7 @@ test.serial('Exit with 1 if a plugin is not found', async t => { name: packageName, version: '0.0.0-dev', repository: {url: `git+https://github.com/${owner}/${packageName}`}, - release: {analyzeCommits: 'non-existing-path'}, + release: {analyzeCommits: 'non-existing-path', success: false, fail: false}, }); const {code, stderr} = await t.throws(execa(cli, [], {env})); @@ -316,7 +317,7 @@ test.serial('Exit with 1 if a shareable config is not found', async t => { name: packageName, version: '0.0.0-dev', repository: {url: `git+https://github.com/${owner}/${packageName}`}, - release: {extends: 'non-existing-path'}, + release: {extends: 'non-existing-path', success: false, fail: false}, }); const {code, stderr} = await t.throws(execa(cli, [], {env})); @@ -336,7 +337,7 @@ test.serial('Exit with 1 if a shareable config reference a not found plugin', as name: packageName, version: '0.0.0-dev', repository: {url: `git+https://github.com/${owner}/${packageName}`}, - release: {extends: './shareable.json'}, + release: {extends: './shareable.json', success: false, fail: false}, }); await writeJson('./shareable.json', shareable); @@ -357,6 +358,7 @@ test.serial('Dry-run', async t => { version: '0.0.0-dev', repository: {url: repositoryUrl}, publishConfig: {registry: npmRegistry.url}, + release: {success: false, fail: false}, }); /* Initial release */ @@ -394,6 +396,7 @@ test.serial('Allow local releases with "noCi" option', async t => { version: '0.0.0-dev', repository: {url: repositoryUrl}, publishConfig: {registry: npmRegistry.url}, + release: {success: false, fail: false}, }); /* Initial release */ @@ -459,7 +462,17 @@ test.serial('Pass options via CLI arguments', async t => { t.log('$ semantic-release'); const {stdout, code} = await execa( cli, - ['--verify-conditions', '@semantic-release/npm', '--publish', '@semantic-release/npm', '--debug'], + [ + '--verify-conditions', + '@semantic-release/npm', + '--publish', + '@semantic-release/npm', + `--success`, + false, + `--fail`, + false, + '--debug', + ], {env} ); t.regex(stdout, new RegExp(`Publishing version ${version} to npm registry`)); @@ -515,7 +528,7 @@ test.serial('Run via JS API', async t => { t.log('Commit a feature'); await gitCommits(['feat: Initial commit']); t.log('$ Call semantic-release via API'); - await semanticRelease(); + await semanticRelease({fail: false, success: false}); // Verify package.json and has been updated t.is((await readJson('./package.json')).version, version); @@ -545,7 +558,7 @@ test.serial('Log unexpected errors from plugins and exit with 1', async t => { name: packageName, version: '0.0.0-dev', repository: {url: repositoryUrl}, - release: {verifyConditions: pluginError}, + release: {verifyConditions: pluginError, fail: false, success: false}, }); /* Initial release */ @@ -572,7 +585,7 @@ test.serial('Log errors inheriting SemanticReleaseError and exit with 1', async name: packageName, version: '0.0.0-dev', repository: {url: repositoryUrl}, - release: {verifyConditions: pluginInheritedError}, + release: {verifyConditions: pluginInheritedError, fail: false, success: false}, }); /* Initial release */ diff --git a/test/plugins/definitions.test.js b/test/plugins/definitions.test.js deleted file mode 100644 index e28393fa..00000000 --- a/test/plugins/definitions.test.js +++ /dev/null @@ -1,77 +0,0 @@ -import test from 'ava'; -import definitions from '../../lib/plugins/definitions'; - -test('The "verifyConditions" plugin, if defined, must be a single or an array of plugins definition', t => { - t.false(definitions.verifyConditions.config.validator({})); - t.false(definitions.verifyConditions.config.validator({path: null})); - - t.true(definitions.verifyConditions.config.validator({path: 'plugin-path.js'})); - t.true(definitions.verifyConditions.config.validator()); - t.true(definitions.verifyConditions.config.validator('plugin-path.js')); - t.true(definitions.verifyConditions.config.validator(() => {})); - t.true(definitions.verifyConditions.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); -}); - -test('The "analyzeCommits" plugin is mandatory, and must be a single plugin definition', t => { - t.false(definitions.analyzeCommits.config.validator({})); - t.false(definitions.analyzeCommits.config.validator({path: null})); - t.false(definitions.analyzeCommits.config.validator([])); - t.false(definitions.analyzeCommits.config.validator()); - - t.true(definitions.analyzeCommits.config.validator({path: 'plugin-path.js'})); - t.true(definitions.analyzeCommits.config.validator('plugin-path.js')); - t.true(definitions.analyzeCommits.config.validator(() => {})); -}); - -test('The "verifyRelease" plugin, if defined, must be a single or an array of plugins definition', t => { - t.false(definitions.verifyRelease.config.validator({})); - t.false(definitions.verifyRelease.config.validator({path: null})); - - t.true(definitions.verifyRelease.config.validator({path: 'plugin-path.js'})); - t.true(definitions.verifyRelease.config.validator()); - t.true(definitions.verifyRelease.config.validator('plugin-path.js')); - t.true(definitions.verifyRelease.config.validator(() => {})); - t.true(definitions.verifyRelease.config.validator([{path: 'plugin-path.js'}, 'plugin-path.js', () => {}])); -}); - -test('The "generateNotes" plugin, if defined, must be a single plugin definition', t => { - t.false(definitions.generateNotes.config.validator({})); - t.false(definitions.generateNotes.config.validator({path: null})); - t.false(definitions.generateNotes.config.validator([])); - - t.true(definitions.generateNotes.config.validator()); - t.true(definitions.generateNotes.config.validator({path: 'plugin-path.js'})); - t.true(definitions.generateNotes.config.validator('plugin-path.js')); - t.true(definitions.generateNotes.config.validator(() => {})); -}); - -test('The "publish" plugin is mandatory, and must be a single or an array of plugins definition', t => { - t.false(definitions.publish.config.validator({})); - t.false(definitions.publish.config.validator({path: null})); - t.false(definitions.publish.config.validator()); - - t.true(definitions.publish.config.validator({path: 'plugin-path.js'})); - t.true(definitions.publish.config.validator('plugin-path.js')); - t.true(definitions.publish.config.validator(() => {})); - t.true(definitions.publish.config.validator([{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(definitions.analyzeCommits.output.validator('invalid')); - t.false(definitions.analyzeCommits.output.validator(1)); - t.false(definitions.analyzeCommits.output.validator({})); - - t.true(definitions.analyzeCommits.output.validator()); - t.true(definitions.analyzeCommits.output.validator(null)); - t.true(definitions.analyzeCommits.output.validator('major')); -}); - -test('The "generateNotes" plugin output, if defined, must be a string', t => { - t.false(definitions.generateNotes.output.validator(1)); - t.false(definitions.generateNotes.output.validator({})); - - t.true(definitions.generateNotes.output.validator()); - t.true(definitions.generateNotes.output.validator(null)); - t.true(definitions.generateNotes.output.validator('')); - t.true(definitions.generateNotes.output.validator('string')); -}); diff --git a/test/plugins/normalize.test.js b/test/plugins/normalize.test.js index 5f1900c0..6fbbaa56 100644 --- a/test/plugins/normalize.test.js +++ b/test/plugins/normalize.test.js @@ -12,6 +12,7 @@ test.beforeEach(t => { test('Normalize and load plugin from string', t => { const plugin = normalize('verifyConditions', {}, {}, './test/fixtures/plugin-noop', t.context.logger); + t.is(plugin.pluginName, './test/fixtures/plugin-noop'); t.is(typeof plugin, 'function'); t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'verifyConditions', './test/fixtures/plugin-noop']); }); @@ -19,6 +20,7 @@ test('Normalize and load plugin from string', t => { test('Normalize and load plugin from object', t => { const plugin = normalize('publish', {}, {}, {path: './test/fixtures/plugin-noop'}, t.context.logger); + t.is(plugin.pluginName, './test/fixtures/plugin-noop'); t.is(typeof plugin, 'function'); t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'publish', './test/fixtures/plugin-noop']); }); @@ -32,6 +34,7 @@ test('Normalize and load plugin from a base file path', t => { t.context.logger ); + t.is(plugin.pluginName, './plugin-noop'); t.is(typeof plugin, 'function'); t.deepEqual(t.context.log.args[0], [ 'Load plugin %s from %s in shareable config %s', @@ -41,9 +44,40 @@ test('Normalize and load plugin from a base file path', t => { ]); }); -test('Normalize and load plugin from function', t => { - const plugin = normalize('', {}, {}, () => {}, t.context.logger); +test('Wrap plugin in a function that add the "pluginName" to the error"', async t => { + const plugin = normalize( + 'verifyConditions', + {'./plugin-error': './test/fixtures'}, + {}, + './plugin-error', + t.context.logger + ); + const error = await t.throws(plugin()); + + t.is(error.pluginName, './plugin-error'); +}); + +test('Wrap plugin in a function that add the "pluginName" to multiple errors"', async t => { + const plugin = normalize( + 'verifyConditions', + {'./plugin-errors': './test/fixtures'}, + {}, + './plugin-errors', + t.context.logger + ); + + const errors = [...(await t.throws(plugin()))]; + for (const error of errors) { + t.is(error.pluginName, './plugin-errors'); + } +}); + +test('Normalize and load plugin from function', t => { + const pluginFunction = () => {}; + const plugin = normalize('', {}, {}, pluginFunction, t.context.logger); + + t.is(plugin.pluginName, '[Function: pluginFunction]'); t.is(typeof plugin, 'function'); }); @@ -54,18 +88,42 @@ test('Normalize and load plugin that retuns multiple functions', t => { t.deepEqual(t.context.log.args[0], ['Load plugin %s from %s', 'verifyConditions', './test/fixtures/multi-plugin']); }); -test('Wrap plugin in a function that validate the output of the plugin', async t => { - const pluginFunction = stub().resolves(1); - const plugin = normalize('', {}, {}, pluginFunction, t.context.logger, { - validator: output => output === 1, - message: 'The output must be 1.', - }); +test('Wrap "analyzeCommits" plugin in a function that validate the output of the plugin', async t => { + const analyzeCommits = stub().resolves(2); + const plugin = normalize('analyzeCommits', {}, {}, analyzeCommits, t.context.logger); - await t.notThrows(plugin()); + const error = await t.throws(plugin()); - pluginFunction.resolves(2); - const error = await t.throws(plugin(), Error); - t.is(error.message, 'The output must be 1. Received: 2'); + t.is(error.code, 'EANALYZEOUTPUT'); + t.is(error.name, 'SemanticReleaseError'); + t.regex(error.details, /2/); +}); + +test('Wrap "generateNotes" plugin in a function that validate the output of the plugin', async t => { + const generateNotes = stub().resolves(2); + const plugin = normalize('generateNotes', {}, {}, generateNotes, t.context.logger); + + const error = await t.throws(plugin()); + + t.is(error.code, 'ERELEASENOTESOUTPUT'); + t.is(error.name, 'SemanticReleaseError'); + t.regex(error.details, /2/); +}); + +test('Wrap "publish" plugin in a function that validate the output of the plugin', async t => { + const plugin = normalize( + 'publish', + {'./plugin-identity': './test/fixtures'}, + {}, + './plugin-identity', + t.context.logger + ); + + const error = await t.throws(plugin(2)); + + t.is(error.code, 'EPUBLISHOUTPUT'); + t.is(error.name, 'SemanticReleaseError'); + t.regex(error.details, /2/); }); test('Plugin is called with "pluginConfig" (omitting "path", adding global config) and input', async t => { @@ -127,12 +185,8 @@ test('Always pass a defined "pluginConfig" for plugin defined with path', async test('Throws an error if the plugin return an object without the expected plugin function', t => { const error = t.throws(() => normalize('inexistantPlugin', {}, {}, './test/fixtures/multi-plugin', t.context.logger)); - t.is(error.code, 'EPLUGINCONF'); + t.is(error.code, 'EPLUGIN'); t.is(error.name, 'SemanticReleaseError'); - t.is( - error.message, - 'The inexistantPlugin plugin must be a function, or an object with a function in the property inexistantPlugin.' - ); }); test('Throws an error if the plugin is not found', t => { diff --git a/test/plugins/pipeline.test.js b/test/plugins/pipeline.test.js index c2261846..4af1abbd 100644 --- a/test/plugins/pipeline.test.js +++ b/test/plugins/pipeline.test.js @@ -18,13 +18,32 @@ test('Execute each function in series passing the same input', async t => { t.true(step2.calledBefore(step3)); }); -test('Execute each function in series passing a transformed input', async t => { +test('With one step, returns the step values rather than an Array ', async t => { + const step1 = stub().resolves(1); + + const result = await pipeline([step1])(0); + + t.deepEqual(result, 1); + t.true(step1.calledWith(0)); +}); + +test('With one step, throws the error rather than an AggregateError ', async t => { + const error = new Error('test error 1'); + const step1 = stub().rejects(error); + + const thrown = await t.throws(pipeline([step1])(0)); + + t.is(error, thrown); +}); + +test('Execute each function in series passing a transformed input from "getNextInput"', async t => { const step1 = stub().resolves(1); const step2 = stub().resolves(2); const step3 = stub().resolves(3); const step4 = stub().resolves(4); + const getNextInput = (lastResult, result) => lastResult + result; - const result = await pipeline([step1, step2, step3, step4])(0, false, (prevResult, result) => prevResult + result); + const result = await pipeline([step1, step2, step3, step4])(0, {settleAll: false, getNextInput}); t.deepEqual(result, [1, 2, 3, 4]); t.true(step1.calledWith(0)); @@ -36,22 +55,45 @@ test('Execute each function in series passing a transformed input', async t => { t.true(step3.calledBefore(step4)); }); -test('Execute each function in series passing the result of the previous one', async t => { +test('Execute each function in series passing the "lastResult" and "result" to "getNextInput"', async t => { const step1 = stub().resolves(1); const step2 = stub().resolves(2); const step3 = stub().resolves(3); const step4 = stub().resolves(4); + const getNextInput = stub().returnsArg(0); - const result = await pipeline([step1, step2, step3, step4])(0, false, (prevResult, result) => result); + const result = await pipeline([step1, step2, step3, step4])(5, {settleAll: false, getNextInput}); t.deepEqual(result, [1, 2, 3, 4]); - t.true(step1.calledWith(0)); - t.true(step2.calledWith(1)); - t.true(step3.calledWith(2)); - t.true(step4.calledWith(3)); - t.true(step1.calledBefore(step2)); - t.true(step2.calledBefore(step3)); - t.true(step3.calledBefore(step4)); + t.deepEqual(getNextInput.args, [[5, 1], [5, 2], [5, 3], [5, 4]]); +}); + +test('Execute each function in series calling "transform" to modify the results', async t => { + const step1 = stub().resolves(1); + const step2 = stub().resolves(2); + const step3 = stub().resolves(3); + const step4 = stub().resolves(4); + const getNextInput = stub().returnsArg(0); + const transform = stub().callsFake(result => result + 1); + + const result = await pipeline([step1, step2, step3, step4])(5, {getNextInput, transform}); + + t.deepEqual(result, [1 + 1, 2 + 1, 3 + 1, 4 + 1]); + t.deepEqual(getNextInput.args, [[5, 1 + 1], [5, 2 + 1], [5, 3 + 1], [5, 4 + 1]]); +}); + +test('Execute each function in series calling "transform" to modify the results with "settleAll"', async t => { + const step1 = stub().resolves(1); + const step2 = stub().resolves(2); + const step3 = stub().resolves(3); + const step4 = stub().resolves(4); + const getNextInput = stub().returnsArg(0); + const transform = stub().callsFake(result => result + 1); + + const result = await pipeline([step1, step2, step3, step4])(5, {settleAll: true, getNextInput, transform}); + + t.deepEqual(result, [1 + 1, 2 + 1, 3 + 1, 4 + 1]); + t.deepEqual(getNextInput.args, [[5, 1 + 1], [5, 2 + 1], [5, 3 + 1], [5, 4 + 1]]); }); test('Stop execution and throw error is a step rejects', async t => { @@ -89,7 +131,7 @@ test('Execute all even if a Promise rejects', async t => { const step2 = stub().rejects(error1); const step3 = stub().rejects(error2); - const errors = await t.throws(pipeline([step1, step2, step3])(0, true)); + const errors = await t.throws(pipeline([step1, step2, step3])(0, {settleAll: true})); t.deepEqual(Array.from(errors), [error1, error2]); t.true(step1.calledWith(0)); @@ -105,7 +147,7 @@ test('Throw all errors from all steps throwing an AggregateError', async t => { const step1 = stub().rejects(new AggregateError([error1, error2])); const step2 = stub().rejects(new AggregateError([error3, error4])); - const errors = await t.throws(pipeline([step1, step2])(0, true)); + const errors = await t.throws(pipeline([step1, step2])(0, {settleAll: true})); t.deepEqual(Array.from(errors), [error1, error2, error3, error4]); t.true(step1.calledWith(0)); @@ -119,10 +161,9 @@ test('Execute each function in series passing a transformed input even if a step const step2 = stub().rejects(error2); const step3 = stub().rejects(error3); const step4 = stub().resolves(4); + const getNextInput = (prevResult, result) => prevResult + result; - const errors = await t.throws( - pipeline([step1, step2, step3, step4])(0, true, (prevResult, result) => prevResult + result) - ); + const errors = await t.throws(pipeline([step1, step2, step3, step4])(0, {settleAll: true, getNextInput})); t.deepEqual(Array.from(errors), [error2, error3]); t.true(step1.calledWith(0)); diff --git a/test/plugins/plugins.test.js b/test/plugins/plugins.test.js index f60727c4..c4aee490 100644 --- a/test/plugins/plugins.test.js +++ b/test/plugins/plugins.test.js @@ -28,6 +28,8 @@ test('Export default plugins', t => { t.is(typeof plugins.verifyRelease, 'function'); t.is(typeof plugins.generateNotes, 'function'); t.is(typeof plugins.publish, 'function'); + t.is(typeof plugins.success, 'function'); + t.is(typeof plugins.fail, 'function'); }); test('Export plugins based on config', t => { @@ -48,6 +50,8 @@ test('Export plugins based on config', t => { t.is(typeof plugins.verifyRelease, 'function'); t.is(typeof plugins.generateNotes, 'function'); t.is(typeof plugins.publish, 'function'); + t.is(typeof plugins.success, 'function'); + t.is(typeof plugins.fail, 'function'); }); test.serial('Export plugins loaded from the dependency of a shareable config module', async t => { @@ -76,6 +80,8 @@ test.serial('Export plugins loaded from the dependency of a shareable config mod t.is(typeof plugins.verifyRelease, 'function'); t.is(typeof plugins.generateNotes, 'function'); t.is(typeof plugins.publish, 'function'); + t.is(typeof plugins.success, 'function'); + t.is(typeof plugins.fail, 'function'); }); test.serial('Export plugins loaded from the dependency of a shareable config file', async t => { @@ -101,6 +107,8 @@ test.serial('Export plugins loaded from the dependency of a shareable config fil t.is(typeof plugins.verifyRelease, 'function'); t.is(typeof plugins.generateNotes, 'function'); t.is(typeof plugins.publish, 'function'); + t.is(typeof plugins.success, 'function'); + t.is(typeof plugins.fail, 'function'); }); test('Use default when only options are passed for a single plugin', t => { @@ -128,22 +136,10 @@ test('Merge global options with plugin options', async t => { }); test('Throw an error if plugins configuration are missing a path for plugin pipeline', t => { - const errors = Array.from( - t.throws(() => getPlugins({verifyConditions: {}, verifyRelease: {}}, {}, t.context.logger)) - ); + const errors = Array.from(t.throws(() => getPlugins({verifyConditions: {}}, {}, t.context.logger))); t.is(errors[0].name, 'SemanticReleaseError'); t.is(errors[0].code, 'EPLUGINCONF'); - t.is( - errors[0].message, - 'The "verifyConditions" 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.' - ); - t.is(errors[1].name, 'SemanticReleaseError'); - t.is(errors[1].code, 'EPLUGINCONF'); - t.is( - errors[1].message, - 'The "verifyRelease" 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.' - ); }); test('Throw an error if an array of plugin configuration is missing a path for plugin pipeline', t => { @@ -153,8 +149,4 @@ test('Throw an error if an array of plugin configuration is missing a path for p t.is(errors[0].name, 'SemanticReleaseError'); t.is(errors[0].code, 'EPLUGINCONF'); - t.is( - errors[0].message, - 'The "verifyConditions" 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.' - ); }); diff --git a/test/verify.test.js b/test/verify.test.js index 08e1122e..d8cfd42a 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -29,29 +29,29 @@ test.afterEach.always(() => { process.chdir(cwd); }); -test.serial('Return "false" if does not run on a git repository', async t => { - const dir = tempy.directory(); - process.chdir(dir); - - t.false(await verify({}, 'master', t.context.logger)); -}); - test.serial('Throw a AggregateError', async t => { await gitRepo(); const errors = Array.from(await t.throws(verify({}, 'master', t.context.logger))); t.is(errors[0].name, 'SemanticReleaseError'); - t.is(errors[0].message, 'The repositoryUrl option is required'); t.is(errors[0].code, 'ENOREPOURL'); t.is(errors[1].name, 'SemanticReleaseError'); - t.is(errors[1].message, 'The tagFormat template must compile to a valid Git tag format'); t.is(errors[1].code, 'EINVALIDTAGFORMAT'); t.is(errors[2].name, 'SemanticReleaseError'); - t.is(errors[2].message, `The tagFormat template must contain the variable "\${version}" exactly once`); t.is(errors[2].code, 'ETAGNOVERSION'); }); +test.serial('Throw a SemanticReleaseError if does not run on a git repository', async t => { + const dir = tempy.directory(); + process.chdir(dir); + + const errors = Array.from(await t.throws(verify({}, 'master', t.context.logger))); + + t.is(errors[0].name, 'SemanticReleaseError'); + t.is(errors[0].code, 'ENOGITREPO'); +}); + test.serial('Throw a SemanticReleaseError if the "tagFormat" is not valid', async t => { const repositoryUrl = await gitRepo(true); const options = {repositoryUrl, tagFormat: `?\${version}`}; @@ -59,7 +59,6 @@ test.serial('Throw a SemanticReleaseError if the "tagFormat" is not valid', asyn const errors = Array.from(await t.throws(verify(options, 'master', t.context.logger))); t.is(errors[0].name, 'SemanticReleaseError'); - t.is(errors[0].message, 'The tagFormat template must compile to a valid Git tag format'); t.is(errors[0].code, 'EINVALIDTAGFORMAT'); }); @@ -70,7 +69,6 @@ test.serial('Throw a SemanticReleaseError if the "tagFormat" does not contains t const errors = Array.from(await t.throws(verify(options, 'master', t.context.logger))); t.is(errors[0].name, 'SemanticReleaseError'); - t.is(errors[0].message, `The tagFormat template must contain the variable "\${version}" exactly once`); t.is(errors[0].code, 'ETAGNOVERSION'); }); @@ -81,7 +79,6 @@ test.serial('Throw a SemanticReleaseError if the "tagFormat" contains multiple " const errors = Array.from(await t.throws(verify(options, 'master', t.context.logger))); t.is(errors[0].name, 'SemanticReleaseError'); - t.is(errors[0].message, `The tagFormat template must contain the variable "\${version}" exactly once`); t.is(errors[0].code, 'ETAGNOVERSION'); });