diff --git a/lib/plugin/factory.js b/lib/plugin/factory.js index c69c2d81..1e1c3650 100644 --- a/lib/plugin/factory.js +++ b/lib/plugin/factory.js @@ -1,6 +1,6 @@ import path from 'path'; +import { createRequire } from 'module'; import _ from 'lodash'; -import requireCwd from 'import-cwd'; import _debug from 'debug'; import Version from './version/Version.js'; import Git from './git/Git.js'; @@ -8,6 +8,8 @@ import GitLab from './gitlab/GitLab.js'; import GitHub from './github/GitHub.js'; import npm from './npm/npm.js'; +const require = createRequire(import.meta.url); + const debug = _debug('release-it:plugins'); const pluginNames = ['npm', 'git', 'github', 'gitlab', 'version']; @@ -20,12 +22,14 @@ const plugins = { npm: npm }; -const load = pluginName => { +const load = async pluginName => { let plugin = null; try { - plugin = require(pluginName); + const module = await import(require.resolve(pluginName)); + plugin = module.default; } catch (err) { - plugin = requireCwd(pluginName); + const module = await import(require.resolve(path.resolve(pluginName))); + plugin = module.default; } return [path.parse(pluginName).name, plugin]; }; @@ -37,7 +41,7 @@ export let getPlugins = async (config, container) => { const enabledExternalPlugins = await _.reduce( context.plugins, async (result, pluginConfig, pluginName) => { - const [name, Plugin] = load(pluginName); + const [name, Plugin] = await load(pluginName); const [namespace, options] = pluginConfig.length === 2 ? pluginConfig : [name, pluginConfig]; config.setContext({ [namespace]: options }); if (await Plugin.isEnabled(options)) { diff --git a/package.json b/package.json index 11c0d63d..e71662e6 100644 --- a/package.json +++ b/package.json @@ -71,7 +71,6 @@ "git-url-parse": "11.5.0", "globby": "11.0.4", "got": "11.8.2", - "import-cwd": "3.0.0", "inquirer": "8.1.1", "is-ci": "3.0.0", "lodash": "4.17.21", @@ -102,7 +101,6 @@ "nock": "13.1.1", "nyc": "15.1.0", "prettier": "2.3.2", - "proxyquire": "2.1.3", "sinon": "11.1.1", "strip-ansi": "6.0.0" }, diff --git a/test/config.js b/test/config.js index 8a08c705..02a728c5 100644 --- a/test/config.js +++ b/test/config.js @@ -44,8 +44,8 @@ test('should set CI mode', t => { t.is(config.isCI, true); }); -test('should detect CI mode', t => { - const isCI = require('is-ci'); +test('should detect CI mode', async t => { + const { default: isCI } = await import('is-ci'); const config = new Config(); t.is(config.options.ci, isCI); t.is(config.isCI, isCI); diff --git a/test/github.js b/test/github.js index 8177f1fc..83740a25 100644 --- a/test/github.js +++ b/test/github.js @@ -266,8 +266,8 @@ test.serial('should skip authentication and collaborator checks when running on authStub.restore(); collaboratorStub.restore(); - process.env.GITHUB_ACTIONS = GITHUB_ACTIONS; - process.env.GITHUB_ACTOR = GITHUB_ACTOR; + process.env.GITHUB_ACTIONS = GITHUB_ACTIONS ?? ''; + process.env.GITHUB_ACTOR = GITHUB_ACTOR ?? ''; }); test('should handle octokit client error (without retries)', async t => { diff --git a/test/npm.js b/test/npm.js index 512113b0..08d89295 100644 --- a/test/npm.js +++ b/test/npm.js @@ -279,7 +279,6 @@ test('should skip checks', async t => { }); test('should publish to a different/scoped registry', async t => { - delete require.cache[require.resolve('../package.json')]; mock({ [path.resolve('package.json')]: JSON.stringify({ name: '@my-scope/my-pkg', @@ -317,7 +316,6 @@ test('should publish to a different/scoped registry', async t => { }); test('should not publish when `npm version` fails', async t => { - delete require.cache[require.resolve('../package.json')]; mock({ [path.resolve('package.json')]: JSON.stringify({ name: '@my-scope/my-pkg', diff --git a/test/plugins.js b/test/plugins.js index ee82784b..bac566a7 100644 --- a/test/plugins.js +++ b/test/plugins.js @@ -1,13 +1,15 @@ -import path from 'path'; import test from 'ava'; import sh from 'shelljs'; -import proxyquire from 'proxyquire'; import sinon from 'sinon'; import Log from '../lib/log.js'; import Spinner from '../lib/spinner.js'; import Config from '../lib/config.js'; -import Plugin from '../lib/plugin/Plugin.js'; -import { mkTmpDir, gitAdd } from './util/helpers.js'; +import { parseGitUrl } from '../lib/util.js'; +import runTasks from '../lib/tasks.js'; +import MyPlugin from './stub/plugin.js'; +import ReplacePlugin from './stub/plugin-replace.js'; +import ContextPlugin from './stub/plugin-context.js'; +import { mkTmpDir } from './util/helpers.js'; import ShellStub from './stub/shell.js'; const noop = Promise.resolve(); @@ -36,45 +38,36 @@ const getContainer = options => { }; test.serial.beforeEach(t => { - const bare = mkTmpDir(); - const target = mkTmpDir(); - sh.pushd('-q', bare); - sh.exec(`git init --bare .`); - sh.exec(`git clone ${bare} ${target}`); - sh.pushd('-q', target); - gitAdd('line', 'file', 'Add file'); - t.context = { bare, target }; + const dir = mkTmpDir(); + sh.pushd('-q', dir); + t.context = { dir }; }); test.serial.afterEach(() => { sandbox.resetHistory(); }); -const myPlugin = sandbox.createStubInstance(Plugin); -myPlugin.namespace = 'my-plugin'; -const MyPlugin = sandbox.stub().callsFake(() => myPlugin); -const myLocalPlugin = sandbox.createStubInstance(Plugin); -const MyLocalPlugin = sandbox.stub().callsFake(() => myLocalPlugin); -const replacePlugin = sandbox.createStubInstance(Plugin); -const ReplacePlugin = sandbox.stub().callsFake(() => replacePlugin); - -const staticMembers = { isEnabled: () => true, disablePlugin: () => null }; -const options = { '@global': true, '@noCallThru': true }; -const runTasks = proxyquire('../lib/tasks', { - 'my-plugin': Object.assign(MyPlugin, staticMembers, options), - '/my/plugin': Object.assign(MyLocalPlugin, staticMembers, options), - 'replace-plugin': Object.assign(ReplacePlugin, staticMembers, options, { - disablePlugin: () => ['version', 'git'] - }) -}); - test.serial('should instantiate plugins and execute all release-cycle methods', async t => { + sh.exec('npm init -f'); + + sh.mkdir('my-plugin'); + sh.pushd('-q', 'my-plugin'); + sh.exec('npm link release-it'); + sh.ShellString("const { Plugin } = require('release-it'); module.exports = " + MyPlugin.toString()).toEnd('index.js'); + sh.popd(); + + sh.mkdir('-p', 'my/plugin'); + sh.pushd('-q', 'my/plugin'); + sh.exec('npm link release-it'); + sh.ShellString("const { Plugin } = require('release-it'); module.exports = " + MyPlugin.toString()).toEnd('index.js'); + sh.popd(); + const config = { plugins: { 'my-plugin': { name: 'foo' }, - '/my/plugin': [ + './my/plugin': [ 'named-plugin', { name: 'bar' @@ -84,38 +77,45 @@ test.serial('should instantiate plugins and execute all release-cycle methods', }; const container = getContainer(config); - await runTasks({}, container); + const result = await runTasks({}, container); - t.is(MyPlugin.firstCall.args[0].namespace, 'my-plugin'); - t.deepEqual(MyPlugin.firstCall.args[0].options['my-plugin'], { name: 'foo' }); - t.is(MyLocalPlugin.firstCall.args[0].namespace, 'named-plugin'); - t.deepEqual(MyLocalPlugin.firstCall.args[0].options['named-plugin'], { name: 'bar' }); - - [ - 'init', - 'getName', - 'getLatestVersion', - 'getIncrement', - 'getIncrementedVersionCI', - 'beforeBump', - 'bump', - 'beforeRelease', - 'release', - 'afterRelease' - ].forEach(method => { - t.is(myPlugin[method].callCount, 1); - t.is(myLocalPlugin[method].callCount, 1); - }); + t.deepEqual(container.log.info.args, [ + ['my-plugin:foo:init'], + ['named-plugin:bar:init'], + ['my-plugin:foo:getName'], + ['my-plugin:foo:getLatestVersion'], + ['my-plugin:foo:getIncrement'], + ['my-plugin:foo:getIncrementedVersionCI'], + ['named-plugin:bar:getIncrementedVersionCI'], + ['my-plugin:foo:beforeBump'], + ['named-plugin:bar:beforeBump'], + ['my-plugin:foo:bump:1.3.0'], + ['named-plugin:bar:bump:1.3.0'], + ['my-plugin:foo:beforeRelease'], + ['named-plugin:bar:beforeRelease'], + ['my-plugin:foo:release'], + ['named-plugin:bar:release'], + ['my-plugin:foo:afterRelease'], + ['named-plugin:bar:afterRelease'] + ]); - const incrementBase = { latestVersion: '0.0.0', increment: undefined, isPreRelease: false, preReleaseId: undefined }; - t.deepEqual(myPlugin.getIncrement.firstCall.args[0], incrementBase); - t.deepEqual(myPlugin.getIncrementedVersionCI.firstCall.args[0], incrementBase); - t.deepEqual(myLocalPlugin.getIncrementedVersionCI.firstCall.args[0], incrementBase); - t.is(myPlugin.bump.firstCall.args[0], '0.0.1'); - t.is(myLocalPlugin.bump.firstCall.args[0], '0.0.1'); + t.deepEqual(result, { + changelog: undefined, + name: 'new-project-name', + latestVersion: '1.2.3', + version: '1.3.0' + }); }); test.serial('should disable core plugins', async t => { + sh.exec('npm init -f'); + sh.mkdir('replace-plugin'); + sh.pushd('-q', 'replace-plugin'); + sh.exec('npm link release-it'); + const content = "const { Plugin } = require('release-it'); module.exports = " + ReplacePlugin.toString(); + sh.ShellString(content).toEnd('index.js'); + sh.popd(); + const config = { plugins: { 'replace-plugin': {} @@ -133,56 +133,50 @@ test.serial('should disable core plugins', async t => { }); }); -test.serial('should expose context to execute commands', async t => { - const { bare } = t.context; - const latestVersion = '1.0.0'; - const project = path.basename(bare); - const pkgName = 'plugin-context'; - const owner = path.basename(path.dirname(bare)); - gitAdd(`{"name":"${pkgName}","version":"${latestVersion}"}`, 'package.json', 'Add package.json'); - - class MyPlugin extends Plugin { - init() { - this.exec('echo ${version.isPreRelease}'); - } - beforeBump() { - const context = this.config.getContext(); - t.is(context.name, pkgName); - this.exec('echo ${name} ${repo.owner} ${repo.project} ${latestVersion} ${version}'); - } - bump() { - const repo = this.config.getContext('repo'); - t.is(repo.owner, owner); - t.is(repo.project, project); - t.is(repo.repository, `${owner}/${project}`); - this.exec('echo ${name} ${repo.owner} ${repo.project} ${latestVersion} ${version}'); - } - beforeRelease() { - const context = this.config.getContext(); - t.is(context.name, pkgName); - this.exec('echo ${name} ${repo.owner} ${repo.project} ${latestVersion} ${version} ${tagName}'); - } - release() { - const context = this.config.getContext(); - t.is(context.latestVersion, latestVersion); - t.is(context.version, '1.0.1'); - this.exec('echo ${name} ${repo.owner} ${repo.project} ${latestVersion} ${version} ${tagName}'); - } - afterRelease() { - const context = this.config.getContext(); - t.is(context.tagName, '1.0.1'); - this.exec('echo ${name} ${repo.owner} ${repo.project} ${latestVersion} ${version} ${tagName}'); +test.serial('should support ESM-based plugins', async t => { + sh.exec('npm init -f'); + sh.mkdir('my-plugin'); + sh.pushd('-q', 'my-plugin'); + sh.ShellString('{"name":"my-plugin","version":"1.0.0","type": "module"}').toEnd('package.json'); + sh.exec('npm link release-it'); + const content = "import { Plugin } from 'release-it'; " + MyPlugin.toString() + '; export default MyPlugin;'; + sh.ShellString(content).toEnd('index.js'); + sh.popd(); + + const config = { + plugins: { + 'my-plugin': {} } - } - const statics = { isEnabled: () => true, disablePlugin: () => null }; - const options = { '@global': true, '@noCallThru': true }; - const runTasks = proxyquire('../lib/tasks', { - 'my-plugin': Object.assign(MyPlugin, statics, options) + }; + const container = getContainer(config); + + const result = await runTasks({}, container); + + t.deepEqual(result, { + changelog: undefined, + name: 'new-project-name', + latestVersion: '1.2.3', + version: '1.3.0' }); +}); + +test.serial('should expose context to execute commands', async t => { + sh.ShellString('{"name":"pkg-name","version":"1.0.0"}').toEnd('package.json'); + const repo = parseGitUrl('https://github.com/user/pkg'); + + sh.mkdir('context-plugin'); + sh.pushd('-q', 'context-plugin'); + sh.exec('npm link release-it'); + const content = "const { Plugin } = require('release-it'); module.exports = " + ContextPlugin.toString(); + sh.ShellString(content).toEnd('index.js'); + sh.popd(); - const container = getContainer({ plugins: { 'my-plugin': {} } }); + const container = getContainer({ plugins: { 'context-plugin': {} } }); const exec = sinon.spy(container.shell, 'execFormattedCommand'); + container.config.setContext({ repo }); + container.config.setContext({ tagName: '1.0.1' }); + await runTasks({}, container); const pluginExecArgs = exec.args @@ -191,10 +185,16 @@ test.serial('should expose context to execute commands', async t => { t.deepEqual(pluginExecArgs, [ 'echo false', - `echo ${pkgName} ${owner} ${project} ${latestVersion} 1.0.1`, - `echo ${pkgName} ${owner} ${project} ${latestVersion} 1.0.1`, - `echo ${pkgName} ${owner} ${project} ${latestVersion} 1.0.1 1.0.1`, - `echo ${pkgName} ${owner} ${project} ${latestVersion} 1.0.1 1.0.1`, - `echo ${pkgName} ${owner} ${project} ${latestVersion} 1.0.1 1.0.1` + 'echo false', + `echo pkg-name user 1.0.0 1.0.1`, + `echo pkg-name user 1.0.0 1.0.1`, + `echo user pkg user/pkg 1.0.1`, + `echo user pkg user/pkg 1.0.1`, + `echo user pkg user/pkg 1.0.1`, + `echo user pkg user/pkg 1.0.1`, + `echo pkg 1.0.0 1.0.1 1.0.1`, + `echo pkg 1.0.0 1.0.1 1.0.1`, + `echo pkg 1.0.0 1.0.1 1.0.1`, + `echo pkg 1.0.0 1.0.1 1.0.1` ]); }); diff --git a/test/stub/plugin-context.js b/test/stub/plugin-context.js new file mode 100644 index 00000000..f6de5c1d --- /dev/null +++ b/test/stub/plugin-context.js @@ -0,0 +1,36 @@ +import Plugin from '../../lib/plugin/Plugin.js'; + +class ContextPlugin extends Plugin { + init() { + const context = this.config.getContext(); + this.exec(`echo ${context.version.isPreRelease}`); + this.exec('echo ${version.isPreRelease}'); + } + beforeBump() { + const context = this.config.getContext(); + this.exec(`echo ${context.name} ${context.repo.owner} ${context.latestVersion} ${context.version}`); + this.exec('echo ${name} ${repo.owner} ${latestVersion} ${version}'); + } + bump(version) { + const repo = this.config.getContext('repo'); + this.exec(`echo ${repo.owner} ${repo.project} ${repo.repository} ${version}`); + this.exec('echo ${repo.owner} ${repo.project} ${repo.repository} ${version}'); + } + beforeRelease() { + const { repo, tagName } = this.config.getContext(); + this.exec(`echo ${repo.owner} ${repo.project} ${repo.repository} ${tagName}`); + this.exec('echo ${repo.owner} ${repo.project} ${repo.repository} ${tagName}'); + } + release() { + const { repo, latestVersion, version, tagName } = this.config.getContext(); + this.exec(`echo ${repo.project} ${latestVersion} ${version} ${tagName}`); + this.exec('echo ${repo.project} ${latestVersion} ${version} ${tagName}'); + } + afterRelease() { + const { repo, latestVersion, version, tagName } = this.config.getContext(); + this.exec(`echo ${repo.project} ${latestVersion} ${version} ${tagName}`); + this.exec('echo ${repo.project} ${latestVersion} ${version} ${tagName}'); + } +} + +export default ContextPlugin; diff --git a/test/stub/plugin-replace.js b/test/stub/plugin-replace.js new file mode 100644 index 00000000..44cc5c74 --- /dev/null +++ b/test/stub/plugin-replace.js @@ -0,0 +1,9 @@ +import Plugin from '../../lib/plugin/Plugin.js'; + +class ReplacePlugin extends Plugin { + static disablePlugin() { + return ['version', 'git', 'npm']; + } +} + +export default ReplacePlugin; diff --git a/test/stub/plugin.js b/test/stub/plugin.js new file mode 100644 index 00000000..cc298826 --- /dev/null +++ b/test/stub/plugin.js @@ -0,0 +1,39 @@ +import Plugin from '../../lib/plugin/Plugin.js'; + +class MyPlugin extends Plugin { + init() { + this.log.info(`${this.namespace}:${this.getContext('name')}:init`); + } + getName() { + this.log.info(`${this.namespace}:${this.getContext('name')}:getName`); + return 'new-project-name'; + } + getLatestVersion() { + this.log.info(`${this.namespace}:${this.getContext('name')}:getLatestVersion`); + return '1.2.3'; + } + getIncrement() { + this.log.info(`${this.namespace}:${this.getContext('name')}:getIncrement`); + return 'minor'; + } + getIncrementedVersionCI() { + this.log.info(`${this.namespace}:${this.getContext('name')}:getIncrementedVersionCI`); + } + beforeBump() { + this.log.info(`${this.namespace}:${this.getContext('name')}:beforeBump`); + } + bump(version) { + this.log.info(`${this.namespace}:${this.getContext('name')}:bump:${version}`); + } + beforeRelease() { + this.log.info(`${this.namespace}:${this.getContext('name')}:beforeRelease`); + } + release() { + this.log.info(`${this.namespace}:${this.getContext('name')}:release`); + } + afterRelease() { + this.log.info(`${this.namespace}:${this.getContext('name')}:afterRelease`); + } +} + +export default MyPlugin; diff --git a/test/tasks.js b/test/tasks.js index a3ba5be6..974b3514 100644 --- a/test/tasks.js +++ b/test/tasks.js @@ -1,14 +1,12 @@ import path from 'path'; import test from 'ava'; import sh from 'shelljs'; -import proxyquire from 'proxyquire'; import _ from 'lodash'; import sinon from 'sinon'; import Log from '../lib/log.js'; import Spinner from '../lib/spinner.js'; import Config from '../lib/config.js'; import runTasks from '../lib/tasks.js'; -import Plugin from '../lib/plugin/Plugin.js'; import { mkTmpDir, gitAdd, getArgs } from './util/helpers.js'; import ShellStub from './stub/shell.js'; import { @@ -398,15 +396,16 @@ test.serial('should propagate errors', async t => { }); { - class MyPlugin extends Plugin {} - const statics = { isEnabled: () => true, disablePlugin: () => null }; - const options = { '@global': true, '@noCallThru': true }; - const runTasks = proxyquire('../lib/tasks', { - 'my-plugin': Object.assign(MyPlugin, statics, options) - }); - test.serial('should run all hooks', async t => { gitAdd(`{"name":"hooked","version":"1.0.0"}`, 'package.json', 'Add package.json'); + sh.mkdir('my-plugin'); + sh.pushd('-q', 'my-plugin'); + sh.exec('npm init -f'); + sh.exec('npm link release-it'); + const plugin = "const { Plugin } = require('release-it'); module.exports = class MyPlugin extends Plugin {};"; + sh.ShellString(plugin).toEnd('index.js'); + sh.popd(); + const hooks = {}; ['before', 'after'].forEach(prefix => { ['version', 'git', 'npm', 'my-plugin'].forEach(ns => {